this 深入解析

Rinsann 2021年11月13日 1,020次浏览

调用位置

function baz() {
    // 当前调用栈是:baz
    // 因此,当前调用位置是全局作用域

    console.log("baz");
    bar();// <--bar的调用位置
}
function bar() {
    //当前调用栈是 baz -> bar
    //因此当前调用位置在baz中

    console.log("bar");
    foo(); // <-- foo的调用位置
}

function foo() {
    debugger
    // 当前调用栈是 baz -> bar -> foo
    // 因此当前调用位置在bar中

    console.log("foo");
}

baz(); // <-- baz 的调用位置

绑定规则

默认绑定

这是最常用的函数调用类型:独立函数调用。可以把这条规则看做是无法应用其它规则时的默认规则。
思考下面代码

function foo(){
    console.log(this.a)
}

var a = 2;
foo(); // 2

这里需要注意的第一件事就是,声明在全局作用域中的变量就是全局对象的一个同名属性。
接下来当调用foo()时,this.a被解析成立全局变量a。
为什么?
这是因为函数调用时应用了this的默认绑定,因此this指向全局对象。
怎么知道应用了默认绑定?
通过分析调用位置来看foo()是如何调用的,foo()是直接使用不带任何修饰的函数引用进行调用的,因此只能使用默认绑定,无法应用其它规则。

这里有一个细节,虽然 this 绑定规则完全取决于调用位置,但是只有foo()运行在非 strict mode下时,默认绑定才能绑定到全局对象;

function foo(){
    "use strict";
    
    console.log( this.a );
}

var a = 2;

foo(); //TypeError: this is undefined

严格模式调用不影响默认绑定

function foo(){
    "use strict";
    
    console.log( this.a );
}

var a = 2;

(function(){
    "use strict";
    foo(); // 2
})();

通常来说不应该在代码中混合使用strict模式和非strict模式。

隐式绑定

隐式绑定需要考虑的规则时调用位置是否有上下文对象,或者说是否被某个对象拥有或者包含

function foo(){
    console.log( this.a );
}

var obj = {
    a: 2,
    foo: foo
}

obj.foo(); //2

调用位置会使用obj上下文来引用函数,因此可以说函数被调用时obj对象"拥有"或者"包含"函数引用。
当foo()被调用时,它的前面确实加上了对obj的引用。
当函数引用有上下文对象时,隐式绑定规则会把函数调用中的this绑定到这个上下文对象。
因为调用foo()时 this 被绑定到了 obj,因此 this.a 和 obj.a是一样的。

对象属性引用链中只有上一层或者说最后一层在调用位置起作用。

function foo(){
    console.log(this.a)
}
var obj2 = {
    a: 42,
    foo: foo //调用位置
};

var obj1 = {
    a: 2,
    obj2:obj2
};

obj1.obj2.foo(); //42

隐式丢失

一个常见的this绑定问题就是被隐式绑定的函数会丢失绑定对象,也就是说它会应用默认绑定,从而把this绑定到全局对象或者undefined上,取决于是否是严格模式。

function foo(){
    console.log( this.a )
}

var obj = {
    a: 2,
    foo: foo
};

var bar = obj.foo; //函数别名
var a = "oops, global"; // a 是全局对象的属性

bar(); // "oops, global"

虽然 bar 是 obj.foo 的一个引用,但是实际上,它引用的是foo函数本身,因此此时的bar()其实是一个不带任何修饰符的函数调用,因此使用了默认绑定。

更常见的情况是出现在传入回调函数时:

function foo(){
    console.log( this.a )
}

function doFoo(fn){
    // fn 其实引用的是 foo
    fn(); // <-- 调用位置
}

var obj = {
    a: 2,
    foo: foo
};

var a = "oops, global"; // a 是全局对象的属性

doFoo( obj.foo ); // "oops, global"

参数传递其实就是一种隐式赋值,因此传入函数时也会被隐式赋值,所以结果和上一个例子是一样的。

把函数传入JS内置的函数,结果也是一样的。

function foo(){
    console.log( this.a )
}

var obj = {
    a: 2,
    foo: foo
};

var a = "oops, global"; // a 是全局对象的属性

setTimeout(obj.foo,100); // "oops, global"

JavaScript 环境中内置的setTimeout函数实现类似下面的伪代码:
function setTimeout(fn,delay){
    // 等待 delay 毫秒
    fn(); // <-- 调用位置!
}

回调函数丢失 this 绑定是非常常见的。

同时调用回调函数的的函数可能会修改 this,在一些流行的JavaScript库中事件处理器常会把回调函数的this强制绑定到触发事件的DOM元素上,通常这些工具无法选择是否启用这个行为。

在这些情况下,this的改变都是意想不到的,实际上无法控制回调函数的执行方式,因此也没有办法控制调用位置来得到期望的绑定(固定this可以修复这个问题)。

显式绑定

在使用隐式绑定时,我们必须在一个对象内部包含一个指向函数的属性,并通过这个属性简介引用函数,从而把this间接绑定到这个对象上(隐式)。

如果不想再对象内部包含函数引用,而想在某个对象上强制调用函数,该怎么做?

JavaScript中的"所有"函数都有一些有用的特性(这和它们的[[Prototype]]有关),可以用来解决这个问题。

具体来说,可以使用函数的call(...)和apply方法。

严格的说,JavaScript的宿主环境有时会提供一些非常特殊的函数,它们并没有这两个方法。

但是这样的函数非常好久,JavaScript提供的绝大多数函数以及我们自己创建的所有函数都可以使用call(...)和apply(...)方法。

它们是怎么工作的呢?

它们的第一个参数是一个对象,是给this准备的,接着在调用函数时将其绑定到this。

因为我们可以直接制定this的绑定对象,所有我们称之为显式绑定。

function foo(){
    console.log( this.a );
}

var obj = {
    a:2
};

foo.call(obj); //2

通过 foo.call(...) 我们可以在调用 foo 时强制把它的this绑定到 obj 上。

如果传入了一个原始值(字符串类型、布尔类型或者数字类型) 来当作 this 的绑定对象,这个原始值会被转换成它的对象形式(也就是 new String(...)、new Boolean(...) 或者 new Number(...)),这通常被称为装箱。

从this绑定的角度来说,call(...) 和 apply(...) 是一样的,它们的区别在于其它参数上

可惜,显式绑定无法解决之前所说的丢失绑定问题。

硬绑定

显式绑定的一个变种 "硬绑定" 可以解决这个问题。

function foo(){
	console.log( this.a );
}

var obj = {
	a:2
};

var bar = function(){
	foo.call(obj);
};

bar(); //2
setTimeout(bar,100); //2

//硬绑定的bar不能在修改它的this
bar.call(window);//2

我们看看这个变种到底是怎样工作的,我们创建了一个函数bar(),并在它的内部手动调用了foo.call(obj),因此强制把 foo 的 this 绑定到了 obj,无论之后如何调用函数bar,它总会手动在boj上调用 foo。

这种绑定就是一种显式的强制绑定,因此我们成为硬绑定。

硬绑定的典型应用场景就是创建一个包裹函数,负责接受参数并返回值:

function foo(something){
    console.log( this.a, something);
    return this.a + something;
}
var obj = {
    a:2
};

var bar = function(){
    return foo.apply(obj, arguments);
};

var b = bar(3); // 2 3
console.log(b); // 5

另一种使用方法是创建一个可以重复使用的辅助函数:

function foo(something){
	console.log( this.a, something);
	return this.a + something;
}

// 简单的辅助绑定函数
function bind(fn,obj){
	return function(){
		return fn.apply(obj,arguments);
	};
}

var obj = {
    a:2
};

var bar = bind(foo,obj);

var b = bar(3); // 2 3
console.log(b); // 5

由于硬绑定是一种非常常用的模式,所以ES5提供了内置的方法 Function.prototype.bind,它的用法如下:

function foo(something){
	console.log(this.a, something);
	return this.a + something;
}
var obj = {
    a:2
}

var bar = foo.bind(obj);

var b = bar(3); // 2 3
console.log(b); // 5

bind(...) 会返回一个硬编码的新函数,它会把你制定的参数设置为this的上下文并调用原始函数。

API调用的上下文

JavaScript内置函数和很多第三方库都提供了一个可选的参数,通常被称为"上下文"(context),其作用和bind(...) 一样,确保你的回调函数使用指定的 this。

function foo(el){
	console.log(el, this.id);
}

var obj = {
	id: "awesome"
};

//调用 foo(...)时把 this绑定到 obj
[1,2,3].forEach(foo, obj); //1 awesome 2 awesome 3 awesome

这些函数实际上就是通过call(...) 或者 apply(...)实现了显示绑定,这样可以少写一些代码。

new 绑定

最后一条 this 绑定规则,在了解它之前需要先了解一个非常常见的关羽JavaScript中函数和对象的误解。

在传统的面向类的语言中,"构造函数"是类的一些特殊方法,使用 new 初始化类是会调用类中的构造函数。

通常形式是这样的: something = new MyClass(...);

JavaScript中也有 new 操作符,使用方法看起来和面向类的语言一样,大部分开发者认为JavaScript中的 new 的机制也是和那些语言一样的。

然而,JavaScript 中 new 的机制实际上和面向类的语言完全不同。

在JavaScript中构造函数只是一些使用new操作符时被调用的函数。

它们并不会属于某个类,也不会实例化一个类。

实际上它们甚至不能说是一种特殊的函数类型,它们只是被new操作符调用的普通函数而已。

包括内置对象(比如 Number(...))在内的所有函数都可以用new 来调用,这种函数调用被称为构造函数调用。

这里有一个非常重要但是非常细微的区别:实际上并不存在"构造函数",只有对于函数的"构造调用"。

使用new 来调用函数,或者说发生构造函数调用时,会自动执行下面的操作:

  1. 创建(或者说构造)一个全新的对象。
  2. 这个对象会被执行 [[Prototype]] 连接。
  3. 做个新对象会绑定到函数调用的 this。
  4. 如果函数没有返回其它对象,那么 new 表达式中的函数调用会自动返回这个新对象。
function foo(a){
	this.a = a
}

var bar = new foo(2);
console.log(bar.a) // 2

使用new来调用foo(...) 时,我们会构造一个新对象并把它绑定到foo(...)调用中的this上。

new 是最后一种可以影响函数调用时 this 绑定的方法,我们称为 new 绑定。

优先级

当我们了解了函数调用中this绑定的四条规则,需要做的就是找到函数的调用位置并判断应当应用那条规则。

但是当某个调用位置可以应用多条规则怎么办?

为了解决这个问题就必须给这些规则设定优先级。

首先,默认绑定的优先级是四条规则中最低的,先不考虑。

隐式绑定和显式绑定那个优先级更高?

function foo(){
	console.log( this.a );
}
var obj1 = {
	a:2,
	foo:foo
};
var obj2 = {
	a:3,
	foo:foo
};

obj1.foo() //2
obj2.foo() //3

obj1.foo.call( obj2 ); // 3
obj2.foo.call( obj1 ); // 2

可以看到,显式绑定的优先级更高,也就是说在判断时应当先考虑是否可以存在显式绑定。

然后我们在搞清楚 new 绑定 和隐式绑定的优先级:

function foo(something){
	this.a = something
}
var obj1 = {
	foo:foo
};
var obj2 = {};

obj1.foo(2);
console.log(obj1.a); // 2

obj1.foo.call( obj2, 3 );
console.log( obj2.a ); //3

var bar = new obj1.foo(4);
console.log(obj1.a); // 2
console.log( bar.a ); //4

可以看到new绑定比隐式绑定优先级更高,但是new 绑定和显式绑定谁的优先级更高呢?

new 和 call/apply 无法一起使用,因此无法通过 new foo.call(obj1) 来直接进行猜测是。但是可以使用硬绑定来测试它们的优先级。

function foo(something){
	this.a = something;
}

var obj1 = {};

var bar = foo.bind( obj1 );
bar( 2 );
console.log( obj1.a );// 2

var baz = new bar(3);
console.log( obj1.a ); // 2
console.log( baz.a ); // 3

bar 被硬绑定到了obj1上,但是new bar(3) 并没有像预期的那要把 obj1.a 修改为3。

相反,new 修改了硬绑定(得到obj1的)调用bar(...)中的this。

因为使用了 new 绑定,我们得到了一个名字为baz的新对象,并且baz.a的值是3.

再来看看之前介绍的辅助函数 bind:

// 伪代码,ES5 内置的 Function.prototype.bind(...)更加复杂
function bind(fn,obj){
	return function(){
		fn.apply(obj,arguments)
	}
}

看上去在辅助函数里面 new 操作符的调用无法修改 this 绑定,但是在刚刚的代码中 new 确实修改了 this 绑定。

下面是 new 修改 this 的相关代码:

this instanceof FNOP &&
oThis ? this : oThis

// ... 以及:

FNOP.prototype = this.prototype;
fBound.prototype = new FNOP();

这段代码会判断硬绑定函数是否被 new 调用,如果是就会使用新创建的 this 代替 硬绑定的 this。

那么,为什么要在 new 中使用硬绑定函数呢?

之所以要在new中使用硬绑定函数,主要目的是预先设置函数的一些参数,这样在使用 new 进行初始化的时候就可以只传入其余的参数。

bind(...) 的功能之一就是可以把除了第一个参数(第一个参数用于绑定 this)之外的其它参数都传给下层的函数(这种技术称为"部分应用",是"柯里化"的一种)。

例子:

function foo(p1,p2){
    this.val = p1 + p2;
}

// 之所以使用 null 是因为目前我们并不关心硬绑定的 this 是什么
// 反正使用new时 this 会被修改
var bar = foo.bind(null,"p1");

var baz = new bar("p2");

baz.val; // 'p1p2'

判断 this

现在我们可以根据优先级来判断函数在某个调用位置应用的是哪条规则。

1、函数是否在 new 中调用(new绑定)?如果是的话 this 绑定的是新创建的对象。

var bar = new foo()

2、函数是否通过 call、apply(显示绑定)或者硬绑定调用?如果是的话,this绑定的是制定的对象。

var bar = foo.call(obj2)

3、函数是否在某个上下文对象中调用(隐式绑定)?如果是的话,this 绑定的是那个上下文对象。

var bar = obj1.foo()

4、如果都不是的话,使用默认绑定。如果在严格模式下,就绑定到undefined,否则绑定到全局对象。

var bar = foo()

对于正常的函数调用来说,理解这些就可以明白 this 的绑定原理了,不过,凡事总有例外。

绑定例外

在某些场景下 this 的绑定行为会出乎意料,你认为应该应用其他绑定规则时,实际上应用的可能是默认绑定规则。

被忽略的 this

如果把 null 或者 undefined作为this的绑定对象传入call、apply或者bind,这些值在调用时会被忽略,实际应用的是默认规则。

function foo(){
	console.log( this.a );
}

var a = 2;

foo.call( null ); //2

那么什么情况可能会需要传 null 呢?

常见的做法是使用 apply(...) 来 "展开" 一个数组,并当作参数传入一个函数。

类似的,bind(...) 可以对参数柯里化(预先设置一些参数),这种方法有时非常有用。

function foo(a,b){
	console.log("a:"+ a + ", b:" + b);
}
// 把数组 "展开" 成参数
foo.apply( null, [2,3]); //a:2,b:3

// 使用 bind(...) 进行柯里化
var bar = foo.bind(null, 2);
bar(3); // a:2,b:3

这两种方法都需要传入一个参数当做 this 的绑定对象。

如果函数并不关心 this 的话,仍然需要传入一个占位符,这是 null 是一个不错的选择。

在 ES6 中,可以用 ... 操作符代替 apply(...)来 "展开" 数组。

foo(...[1,2]) 和 foo(1,2) 是一样的,这样可以避免不必要的 this 绑定,但是在ES6中没有柯里化相关语法,所有还是需要 bind(...)

总是使用 null 忽略 this 绑定可能产生一些副作用。

如果某个函数确实使用了 this(比如第三方库中某个函数),那默认绑定会把 this 绑定到全局对象(浏览器中是window),这将导致一些可能的后果(比如修改全局对象)。

更安全的 this

更安全的做法是传入一个特殊的对象,把 this 绑定到这个对象不会对你的程序产生副作用。

就想网络一样,我们可以创建一个"DMZ"(demilitarized zone) 对象 —— 它就是一个空的非委托对象。

如果在忽略 this 绑定时总是传一个 DMZ对象,那么就不要担心了,因为任何对 this的使用都会被限制在这个空对象中,不会对全局对象产生任何影响。

在JavaScript中创建一个空对象最简单的方法是 Object.create(null)。

Object.create(null) 和 {} 很像,但是并不会穿件 Object.prototype 这个委托,所以它比 {} "更空"

function foo(a,b){
	console.log("a:"+ a + ", b:" + b);
}

// DMZ空对象
var zero = Object.create(null);

// 把数组展开成参数
foo.apply(zero,[2,3]); // a:2,b:3

//使用 bind(...)进行柯里化
var bar = foo.bind(zero,2);
bar(3);//a:2,b:3

间接引用

需要注意你可能(有意或无意)创建一个函数的 "间接引用",在这种情况下,调用这个函数会应用默认绑定规则。

间接引用最容易在赋值时发生:

function foo(){
	console.log( this.a )
}
var a = 2;
var o = {a:3,foo:foo};
var p = {a:4};
o.foo(); //3
(p.foo = o.foo)(); // 2

赋值表达式 p.foo = o.foo 的返回值是目标函数的引用,因此调用位置是foo()而不是 p.foo() 或者 o.foo()。

根据之前的了解,这里会应用默认绑定。

注意:对于默认绑定来说,决定this绑定对象的并不是调用位置是否处于严格模式,而是函数体是否处于严格模式。

如果函数体处于严格模式,this会被绑定到 undefined,否则 this 会被绑定到全局对象。

软绑定

硬绑定这种方式可以把 this 强制绑定到执行的对象(除了使用 new 时),防止函数调用应用默认绑定规则。

问题在于硬绑定会大大降低函数的灵活性,使用硬绑定之后就无法使用隐式绑定或者显式绑定来修改 this。

如果可以给默认绑定制定一个全局对象和undefined以为的值,那就可以实现和硬绑定相同的效果,同事保留隐式绑定和显式绑定修改 this 的能力。

可以通过软绑定来实现我们想要的效果:

if(!Function.prototype.softBind){
	Function.prototype.softBind = function(obj){
        var fn = this;
        // 捕获所有 curried 参数
        var curried = [].slice.call(arguments,1);
        var bound = function(){
            return fn.apply(
            	(!this || this === (window || global)) ? obj : this,
            	curried.concat.apply(curried,arguments)
            );
        };
    	bound.prototype = Object.create( fn.prototype);
    	return bound;
    };
}

除了软绑定之外,softBind(...)的其它原理和ES5内置的 bind(...) 类似。

它会对指定的函数进行封装,首先检查调用时的 this,如果 this绑定到全局对象或者undefined,那就把制定的默认对象 obj 绑定到 this,否则不会修改 this。

此外这段代码还支持可选柯里化。

下面我们看看softBind 是否实现了软绑定功能:

function foo(){
	console.log("name: ", this.name);
}

var obj = {name: "obj"},
    obj2 = {name: "obj2"},
    obj3 = {name: "obj3"};

var fooOBJ = foo.softBind( obj );

fooOBJ(); // name: obj

obj2.foo = foo.softBind(obj)
obj2.foo(); // name: obj2 <--- 看!

fooOBJ.call(obj3); // name: obj3 <--- 看!

setTimeout(obj2.foo,10); //name: obj <--- 应用了软绑定

可以看到,软绑定版本的foo() 可以手动将 this 绑定到 obj2 或者 obj3上,但如果默应用认绑定,则会将 this 绑定到 obj。

this 语法

之前介绍的四条规则已经可以包含所有正常的函数。

但是ES6中新增了一张无法使用这些规则的特殊函数类型:箭头函数

箭头函数并不是使用function关键字定义的,而是使用被称为"胖箭头"的操作符 => 来定义的。

箭头函数不使用 this 的四种标准规则,而是根据外层(函数或者全局)作用域来决定 this。

我们来看看箭头函数的语法作用域:

function foo(){
	// 返回一个箭头函数
	return (a) => {
		// this 继承自 foo()
		console.log( this.a );
	};
}

var obj1 = {
	a:2
};

var obj2 = {
	a:3
};

var bar = foo.call(obj1);
bar.call( obj2 ); //2 ,不是3!

foo() 内部创建的箭头函数会捕获调用时 foo() 的 this。

由于foo() 的 this绑定贷款 obj1,bar(引用箭头函数)的this也会绑定到obj1,箭头函数的绑定无法被修改(new 也不行)。

箭头函数最常用于回调函数中,例如时间处理器或者定时器:

function foo(){
	setTimeout(()=>{
		// 这里的 this 在词法上继承自 foo
		console.log( this.a )
	},100);
}

var obj = {
	a:2
};
foo.call(obj);//2

箭头函数可以想bind(...)一样区别函数的this 被绑定到指定对象,此外,其重要性还体现在它用更常见的词法作用域取代了传统的this机制。

实际上,在ES6之前我们就已经在使用一张几乎和箭头函数完全一样的模式。

function foo(){
	var self = this; 
    setTimeout(function(){
        console.log(self.a);
    },100)
}

var obj = {
    a:2
};

foo.call(obj);//2

虽然self = this 和箭头函数看起来都可以取代bind(...),但实际上,它们想替代的是 this 机制。

总结

如果要判断一个运行中函数的 this绑定,就需要找到这个函数的直接调用位置。

找到之后就可以应用项目这四条规则来判断 this 的绑定对象。

  1. 由 new 调用?绑定到新创建的对象。
  2. 由call或者apply(或者bind)调用?绑定到指定的对象。
  3. 由上下文对象调用?绑定到那个上下文对象。
  4. 默认:在严格模式绑定到undefined,否则绑定到全局对象。

要注意,有些调用可能在吴彦祖使用默认绑定规则。如果想"更安全"地好了this绑定,可以使用一个DMZ对象,比如 zero = Object.create(null),以保护全局对象。

ES6的箭头函数并不会使用这四条标准规则,而是根据当前的词法作用域来决定this,具体来说,箭头函数会继承外层函数调用的this绑定(无论this绑定到什么)。