最近在阅读《你不知道的JavaScript》,再次补充了一下之前不熟悉的this绑定问题,在这里做一下阅读笔记。(很多晦涩的概念包括闭包、作用域链等都和js中的调用栈有关,所以也得另外找个时间系统学一下了)

1.调用位置

JS 中的词法作用域是静态的,需要关注的往往是函数的声明位置而不是调用位置 —— 例如闭包引用自由变量时,应该注意闭包函数的声明位置;而 this 却在某种程度上类似于动态作用域,this 到底绑定的是谁,要看函数的调用位置(或者说调用方法),只有在函数调用的时候 this 的指向才能被确定。

确定当前执行函数的调用位置,有两种方法:

1.1 分析调用栈

调用栈即:为了到达当前执行位置所调用的所有函数。当前执行函数的调用位置就在调用栈中该函数的前一个调用中。

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

如上代码,例如当前执行函数为bar,bar函数的调用位置即bar函数的前一个调用,分析调用栈baz -> bar可知,是baz。

1.2 设置断点或debugger

上面的方法将调用栈当作了函数调用链,这种方法比较麻烦,且容易出错,所以我们采取设置断点或debugger的方法寻找调用位置。我们在上面代码的foo函数中的第一行插入debugger;,那么运行代码时(当前执行函数是foo),调试器会在那个位置暂停,右侧的call stack展示了当前位置的函数调用列表,即调用栈。而调用位置就是栈中的第二个元素。

2.绑定规则

2.1 默认绑定

可以把默认绑定看作是无法应用其他规则时的默认规则,this指向全局对象。独立函数调用(如代码中的foo函数,它是直接使用不带任何修饰的函数引用进行调用的)应用的就是默认绑定规则。

function foo(){
    console.log(this.a);
}
var a = 2;
foo();   //2

但是,函数运行在严格模式时,this 的默认绑定将无法绑定全局对象,而是绑定到 undefined。

function foo() {    
    "use strict";   // 函数运行在严格模式下
    console.log(this.a);
}
var a = 2;
foo(); // TypeError: Cannot read property 'a' of undefined

同时,函数在严格模式下调用时,默认绑定不受影响。

function foo() { 
    console.log(this.a);
}
var a = 2;
(function(){ 
    "use strict";  //函数在严格模式下调用    
    foo(); // 2
})();

2.2 隐式绑定

当函数引用有上下文对象时(或者说被某个对象“包含”/“拥有”),隐式绑定规则会把函数中的this绑定到这个上下文对象。

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

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

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

2.2.1 隐式丢失

隐式绑定在一些情况下会丢失绑定对象,应用默认绑定,使this指向全局对象或者undefined。以下情况会发生隐式丢失:

  • 将绑定上下文对象的函数赋值给变量并调用
var obj = {
    a:2,
    foo: function(){
    	console.log(this.a)
	}
}
var a = 3
var bar = obj.foo
bar()    // 3

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

  • 传入回调函数
function bar(fn){
    fn();
}
var obj = {
    a:2,
    foo: function(){
    	console.log(this.a);
	}
}
var a = 3;
bar(obj.foo);

传参其实是隐式赋值,即把实参(这里是绑定上下文对象的函数的引用)赋值给形参变量,该变量也是直接引用了foo 函数本身,和上面的情况其实是一样的。

这也解释了为什么传参给 setTimeout 函数时会发生隐式丢失:

var obj = {
    a: 2,
    foo: function(){
    	console.log(this.a)
	}
}
var a = 3
setTimeout(obj.foo, 100) // 3

因为上面的代码实际上相当于:

(function setTimeout(fn,100){
    // 100......
    fn();
})(obj.foo);

PS:从另一个角度来理解就是,setTimeout 本身其实是通过 window 对象调用的,这会导致回调函数中的 this 指向全局 window 对象。

2.3 显式绑定

2.3.1 call()apply()

call() 或者 apply() 方法接受一个 thisArg,将函数的 this 绑定到该 thisArg。
thisArg 的取值有以下四种情况:

  • 不传,或者传null、undefined:函数中的 this 指向 window 对象

  • 传递另一个函数的函数名:函数中的 this 指向这个函数的引用

  • 传递字符串、数值或布尔类型等基本类型:函数中的 this 指向其对应的包装对象,如 String、Number、Boolean

  • 传递一个对象:函数中的 this 指向这个对象

function foo(){
    console.log(this.a)
}
var obj = {
    a: 2
}
foo.call(obj); // 2

2.3.2 硬绑定 bind()

但是这两种方法依然无法解决绑定丢失的问题,所以有了硬绑定。硬绑定的一个例子如下:

function foo() {
  console.log(this.a);
}
var obj = {
  a: 2
};
var bar = function(){
  foo.call(obj);
};
bar();  // 2
setTimeout(bar, 100);  // 2

bar.call(window);  //无效,硬绑定之后的this不可再更改

这里的 bar 函数就实现了一个硬绑定,它将 this 绑定的过程封装在了函数内部,之后不管怎么调用 bar 函数,this 的绑定都不会丢失。

硬绑定的典型应用场景是:

  1. 创建一个包裹函数,负责接收参数并返回值。
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
  1. 创建一个可以重复使用的辅助函数。
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()方法将返回一个完成硬绑定的新函数。

2.3.3 API调用的“上下文”

同样可以解决绑定丢失的问题。
JS许多内置函数提供了一个可选参数,被称之为“上下文”(context),其作用和 bind(..)一样,确保回调函数使用指定的this。这些函数实际上通过call(…)和apply(…)实现了显式绑定。

var obj = {
    id: "awesome"
}
var myArray = [1, 2, 3]
// 调用foo(..)时把this绑定到obj
myArray.forEach( function foo(el) {
	console.log( el, this.id );
}, obj );
// 1 awesome 2 awesome 3 awesome

2.4 new 绑定

这篇文章中,其实已经谈到了new的内部原理,在这里再做一下总结 —— 使用new来调用函数,或者说发生构造函数调用时,会自动执行下面的操作:

  • 1.创建一个新对象
  • 2.为该对象执行[[prototype]]链接
  • 3.将该对象绑定到构造函数的this
  • 4.如果函数没有显式返回对象,则new操作最终将返回步骤1中创建的新对象

基于这些步骤,我们就可以手写实现new了,具体过程依然可以参考上面链接的文章。

有时候会将硬绑定与new一起使用,目的是预先设置函数的一些参数,这样在使用new进行初始化时就可以只传入其余的参数(柯里化

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

3. this的判断

现在我们可以根据优先级来判断函数在某个调用位置应用的是哪条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()

4.绑定例外

4.1 被忽略的this

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

下面两种情况下会传入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 

总是传入null来忽略 this 绑定可能产生一些副作用 —— 如果某个函数确实使用了 this,那默认绑定规则会把 this 绑定到全局对象中。

更安全的做法:

传入一个空对象(而非 null),把 this 绑定到这个对象不会对你的程序产生任何副作用。

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

// 我们的空对象
var ø = Object.create(null);

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

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

4.2 间接引用

你可能会有意无意地创建一个函数的间接引用,尤其是在赋值的时候

// p.foo = o.foo的返回值是目标函数的引用,所以调用位置是foo()而不是p.foo()或者o.foo()
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

4.3 软绑定

  • 硬绑定可以把 this 强制绑定到指定的对象(new除外),防止函数调用应用默认绑定规则。但是会降低函数的灵活性,使用硬绑定之后就无法使用隐式绑定或者显式绑定来修改this。
  • 如果给默认绑定指定一个全局对象和 undefined 以外的值,那就可以实现和硬绑定相同的效果,同时保留隐式绑定或者显式绑定修改 this 的能力。
// 默认绑定规则,优先级排最后
// 如果this绑定到全局对象或者undefined,那就把指定的默认对象obj绑定到this,否则不会修改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;
    };
}

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

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

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

// 默认绑定,应用软绑定,软绑定把this绑定到默认对象obj
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

5 this词法

5.1 箭头函数

ES6新增了箭头函数,上述四条规则对这种函数是不生效的。

箭头函数不会创建自己的 this,它只会从自己的作用域链的上一层继承 this。

拿下面的代码举例,箭头函数在词法层面的上一层是foo(),所以它的 this 和foo()的 this 是一样的。由于foo()的 this 绑定到obj1,所以bar(引用箭头函数)的 this 也会绑定到obj1。需要注意的是,箭头函数的绑定无法被修改 —— 因为箭头函数没有自己的 this,所以是不能对它使用 callapplybind 的,new 也不行。

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

var obj1 = {
    a: 2
};

var obj2 = {
    a: 3
}
// 绑定foo()的this为obj1
var bar = foo.call( obj1 );
bar.call( obj2 ); // 2,不是3!

另外,一定不要弄错作用域链的分析,比如下面这段代码:

let group = {
  title: "Our Group",
  students: ["John", "Pete", "Alice"],

  showList() {
    this.students.forEach(
      student => alert(this.title + ': ' + student)
    );
  }
};

group.showList();

一定要记住 JS 采用的是词法作用域,词法作用域即静态作用域,它连同作用域链在函数创建的时候就确定了。

上面代码中,由于 forEach 只是一次动态的函数调用,因此它并不作为静态作用域链的一环,作用域链实际是 arrow function => showList => global,对于箭头函数,其父级作用域不是动态调用的 forEach,而是静态声明的 showList,因此箭头函数的 this 指向等于 shwoListthis 指向等于 group

那么 arrow function => showList => forEach => global 是什么呢?是调用栈。调用栈是包含 forEach 这一环的。

箭头函数常用于回调函数中,例如事件处理器或者定时器。

function foo() {      
    setTimeout(() => { 
          // 同样的,这里的 this 在词法上不是继承自 setTimeout,而是继承自foo
          console.log(this.a)     
     },100)
} 
 
var obj = {     
    a:2
}
foo.call(obj) // 2

5.2 self = this 与箭头函数

this 在通常情况下都是动态作用域的,而箭头函数很明显是静态(词法)作用域。实际上,在 ES6 之前,也有类似于箭头函数的模式 —— self = this,它可以实现词法作用域的效果:

function foo() {
    var self = this; // lexical capt ure of this
    setTimeout( function() {
        console.log( self.a ); // self只是继承了foo()函数的this绑定
    }, 100 );
}

var obj = {
    a: 2
};

foo.call(obj); // 2

5.3 代码风格统一

如果你经常编写this风格的代码,但是绝大部分时候都会使用self = this或者箭头函数来否定this机制,那你或许应当:

  1. 只使用词法作用域并完全抛弃错误 this 风格的代码;
  2. 完全采用 this 风格,在必要时使用 bind(…),尽量避免使用 self = this 和箭头函数。

当然,包含这两种代码风格的程序可以正常运行,但是在同一个函数或者同一个程序中混合使用这两种风格通常会使代码更难维护,并且可能也会更难编写。