JavaScript 回炉重造:this 的四种绑定规则

416 阅读5分钟

有关 this 的认知

this 是什么

this 是函数运行时,在函数体内部自动生成的一个对象,只能在函数体内部使用。且在不同场合使用的函数,其 this 的值也不同。总的来说,this 就是函数运行时所在的环境对象。

this 指向

this 既不指向函数自身也不指向函数的词法作用域。

this 的绑定

this 是在运行时进行绑定的,而不是在编写时,且它的绑定和函数声明的位置没有任何关系,只取决于函数的调用方式。同时,this 的上下文取决于函数调用时的各种条件。

调用位置

函数的调用位置决定 this 的绑定对象。因此,在理解 this 的绑定规则之前,我们需要先理解函数的调用位置(不是声明的位置): 在当前正在执行的函数的前一个调用中

而要找出调用位置,则需要分析调用栈。下面,我们一起来看一个简单的例子:

// 当前例子出自《你不知道的JavaScript》上卷——调用位置章节
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 的调用位置

下图展示的是上述代码在执行过程中的调用栈( Call Stack )

调用栈和调用位置.gif

1.1调用栈图1.1 - 调用栈

通过阅读上面的图文,想必大家对调用栈和调用位置已有所理解。下面,我们就一起来看看 this 的绑定规则。

this 绑定规则

this 的绑定规则有四条

  • 默认绑定
  • 隐式绑定
  • 显式绑定
  • new绑定

this 的绑定,发生在函数被调用时,它指向什么完全取决于函数在哪里被调用。在分析使用那种绑定规则时,一定要明确函数调用位置。下面就让我们一起来看看这四条规则吧。

默认绑定

案例代码

// 在全局作用域中定义的全局变量 a,会成为全局对象的一个同名属性
var a = 2; 
function foo() {
    console.log('this.a--->', this.a);
    console.log('this-->', this);
}

foo();

结果展示

默认绑定.png

上面这种函数调用,通常称为独立函数调用(也叫纯粹的函数调用),是函数的最通常用法,属于全局性调用。它使用了 this 的默认绑定,因此,函数中的 this 指向全局对象(注意,严格模式下无法使用默认绑定,this 指向 undefined)。

如何就认定 foo() 函数是应用了默认绑定的呢?注意观察它的调用位置。在上例代码中,foo() 直接使用不带任何修饰的函数引用进行调用的,它无法应用其他规则,所以它使用了默认绑定。

使用默认绑定规则,往往意味着无法应用其他规则。所以,我们可以把这条规则看作是无法应用其他规则时的默认规则

隐式绑定

当函数引用有上下文对象时,隐式绑定规则会把函数调用中的 this 绑定到这个上下文对象。所以,在判断一个被调用的函数是否应用隐式绑定规则时,就看其调用位置是否有上下文对象。

案例代码

let obj = {
    num: 2,
    getNum: function () {
      console.log('this.num--->', this.num);
      console.log('this--->', this);
    }
};

obj.getNum();

结果展示

截屏2021-09-14 21.46.57.png

隐式绑定注意事项:

  1. 对象属性引用链中只有最顶层或者说最后一层会影响调用位置。看下面这个例子,函数中的 this 会指向 obj2,而不是 obj1。

案例代码

let obj2 = {
    num: 10,
    getNum: function () {
      console.log('this.num--->', this.num);
      console.log('this--->', this);
    } 
};


let obj1 = {
    num: 1,
    obj2: obj2 
};

obj1.obj2.getNum();

*结果展示

截屏2021-09-14 22.10.23.png

  1. 隐式丢失

这个问题指的是:被隐式绑定的函数会丢失绑定对象。换句话说,就是它会应用默认绑定。注意,默认绑定会将 this 绑定到全局对象或 undefined 上(这取决于代码是否启用了严格模式)。

案例代码 一

function getNum() {
  console.log("this.a--->", this.a);
  console.log("this--->", this);
}

var a = "hello world"; // a 是全局对象的属性
var obj = {
    a: 2,
    getNum: getNum
};

var alias = obj.getNum;

alias();

此代码中的 alias 是 obj.getNum 的一个引用,但它引用的是 getNum 函数本身。因此,当调用 alias() 时,其实是在调用一个不带任何修饰的函数,所以就应用了默认绑定规则。

*结果展示

截屏2021-09-14 22.45.12.png

案例代码 二

function getNum() {
    console.log("this.a--->", this.a);
    console.log("this--->", this);
}

function done(fn) {
    // fn 实际上引用的是 getNum
    fn(); // <-- 调用位置
}

var a = "另一种隐式丢失"; // a 是全局对象的属性
var obj = {
    a: 2,
    getNum: getNum
};

done(obj.getNum); // "另一种隐式丢失"

参数传递其实就是一种隐式赋值,不会因为传入的参数是函数而变的特殊。所以此案例和上一个例子没多大差别。将函数当作参数传递的情况(即回调函数)非常常见,但我们无法控制回调函数的执行方式,因此也就没法控制会影响绑定的调用位置,稍不注意就会造成隐式丢失问题。

*结果展示

截屏2021-09-14 22.53.59.png

另外,将我们声明的函数传入语言内置的函数(例如,setTimeout),结果是一样的,没有区别。这个情况,就留给大家自己调试吧。

显式绑定

call 和 apply

同学们对 call() 和 apply() 应该不会陌生,它们是函数上的方法,JavaScript 提供的大部分函数和我们自己创建的函数都能够使用这两个方法。这两个方法的第一个参数都是一个对象,它们会把这个对象绑定到 this,接着在调用函数时指定这个 this。因为能直接指定 this 的绑定对象,所以我们称这种绑定为显式绑定。

案例代码

function getNum() {
    console.log("this.a--->", this.a);
    console.log("this--->", this);
}

var a = '显示绑定到——全局对象';
var obj = {
    a: "显示绑定到——指定对象",
};

// 从 this 绑定的角度来说,call() 和 apply() 是一样的,所以这里仅用 call 做测试
getNum.call(obj);
console.log("--------------------------------");
getNum.call();

注意,call() 或 apply() 的参数为 null(或空)时,默认调用全局对象。另外,若是你给它们传入一个原始值当作 this 的绑定对象,那么这个原始值会被转换成它的对象形式( 例如,new Number() )。这通常被称为“装箱”。

*结果展示

截屏2021-09-16 22.44.53.png

硬绑定

硬绑定是一种显式的强制绑定。它能解决显式绑定无法解决的丢失绑定问题。

案例代码

function getNum() {
    console.log("this.a--->", this.a);
    console.log("this--->", this);
}

function handle() {
    getNum.call(obj);
}

var obj = { a: 2 };

handle();

console.log("---------------------");

handle.call(window); // 硬绑定的 handle 不会再修改它的 this

console.log("---------------------");

setTimeout(handle, 100);

由于在 handle() 函数的内部手动调用了 getNum.call(obj),因此强制把 getNum的 this 绑定到了 obj 对象上。之后无论如何调用函数 handle,它都会在 obj 上调用 getNum。

*结果展示

截屏2021-09-17 13.25.05.png

通过上面所举的一个简单的硬绑定示例,也许你已注意到,硬绑定是包裹在一个函数内的。事实上,硬绑定的典型应用场景就是创建一个包裹函数

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

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

var obj = { a: 2 };
var handle = bind(getNum, obj);

handle(); // 2

实际上,在 ES5 中就提供了一个硬绑定的内置方法——Function.prototype.bind。bind 方法会返回一个硬编码的新函数,且它会将参数设置为 this 的上下文并调用原始函数。

案例代码

function getNum() {
    console.log("this.a--->", this.a);
    console.log("this--->", this);
}

var obj = { a: 2 };
var fn = getNum.bind(obj);

fn(); // 2
console.log("返回一个硬编码函数--->", getNum.bind);
console.log("fn--->", fn);

*结果展示

截屏2021-09-17 13.52.58.png

new绑定

在 JavaScript 中,当我们使用 new 来调用函数时,就会应用 new 绑定规则。而通过 new 操作符调用的函数,则被称为构造函数(实际上并不存在所谓的“构造函数”,只有对于函数的“构造调用”)。

当使用 new 来调用函数时,会自动执行以下操作(参考于《你不知道的JavaScript》上卷):

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

案例代码

function Foo(a) { 
    this.a = a;
}

var f = new Foo(2); 
console.log( f.a ); // 2

*结果展示

截屏2021-09-17 14.40.06.png 在此例代码中,当我们使用 new 操作符调用 Foo() 时,就会构造一个新对象并把它绑定到 Foo() 调用中的 this 上。

主要参考来源

  1. 《你不知道的JavaScript》上卷

  2. Javascript 的 this 用法——阮一峰