理解this的指向

Author: Guang

我们在JavaScript执行上下文中提到,在JavaScript中,this值指的是当前执行上下文的上下文对象,每个执行上下文都会绑定一个上下文对象。执行上下文是运行时创建的,可见this的值也是运行时绑定的,函数的调用方式决定了this的值。JavaScript规定,一旦this值被绑定,不能在运行时被更改。松散模式和严格模式下this绑定有不同表现,松散模式下this值必须是一个对象,在严格模式下this值才可能被绑定为基础数据类型值。

什么决定了this绑定的动态性

在JavaScript中,一个函数也是一个对象。作为对象,函数能够作为值进行传递,可以赋值给另一个对象的属性,可以直接定义,也可以定义为另一个对象的属性。当一个函数是一个对象的属性的时候,我们也称它为对象的方法。对象的方法和函数都是一类对象(它们都是构造函数Function创建的对象)。由于函数的动态性,this作为执行上下文对象也具备跟随函数调用方式变化而变化的动态性。简单来说,函数中this的值不取决于函数在哪里定义,取决于函数在哪里执行。同样的JavaScript在松散模式和严格模式下不同的运行表现,也决定了this绑定的动态性。在松散模式下为了保证this的值总是一个对象,在某些情况下this的值会发生静默转换。

在全局环境下,JavaScript引擎维护一个特殊的并唯一的this值——globalThis,在不同的运行环境下,globalThis有不同的别名。

请看如下代码在浏览器环境中运行的结果:

// 在网页浏览器中
function printThisFunc() {
    console.log(this);
}

const obj1 = {
    name: 'obj1',
    func: printThisFunc,
    printThisMethod: function () {
        console.log(this);
    }
};

const printThisMethod = obj1.printThisMethod;

printThisFunc(); // 输出: window 对象
obj1.func(); // 输出: obj1 对象
obj1.printThisMethod();  // 输出: obj1 对象
printThisMethod(); // 输出: window 对象

在示例代码中,我们定义了两个函数对象,分别是全局函数printThisFuncobj1对象的方法printThisMethod。当printThisFunc在全局调用时,输出全局this对象window,当将它赋值给obj1.func并通过obj1.func()调用时,输出的this值是obj1对象;当直接调用obj1.printThisMethod对象时,输出的this值是obj1对象,而当把obj1.printThisMethod先取出到全局环境再进行调用时,输出全局this对象window

扩展globalThis提供了一个标准的方式来获取不同环境下的全局this对象(也就是全局对象自身)。它确保可以在有无窗口的各种环境下正常工作。在 Web 中,可以通过windowself或者frames取到全局对象,但是在 Web Workers 中,只有self可以。在Node.js中,它们都无法获取,必须使用global

this绑定的静默转换

在松散模式下,JavaScript引擎会总是确保this值是一个对象,在绑定this遇到非对象值时,会发生如下转换:

  • 如果一个函数被调用时this被设置为undefinednullthis会被替换为globalThis
  • 如果函数被调用时this被设置为一个原始值,this会被替换为原始值的包装对象。

松散模式下,函数上下文中进行this绑定时,不论是默认绑定、隐式绑定、显示绑定,还是new绑定,这个静默转换总会发生。需要注意的是,在构造函数调用中,这种静默转换总会发生,哪怕是在严格模式中。

示例代码如下:

function getThis() {
    return this;
}

const one = 1;

console.log(one instanceof Number); // 输出:false
console.log(getThis.call(null)); // 输出 window 对象
console.log(getThis.call(1) instanceof Number); // 输出:true
console.log(getThis.call(1).valueOf()); // 输出:1

这种自动转化为对象的过程不仅是一种性能上的损耗,同时在浏览器中暴露出全局对象也会成为安全隐患,因为全局对象提供了访问那些所谓安全的JavaScript环境必须限制的功能的途径。

禁用this绑定默认转换

在严格模式下通过this传递给一个函数的值不会被强制转换为一个对象。所以对于一个开启严格模式的函数,指定的this不再被封装为对象,而且如果没有指定this的话它值是undefined

"use strict";
function getThis() {
  return this;
}
console.log(getThis()); // 输出:undefined
console.log(getThis.call(1) === 1); // 输出:true
console.log(getThis.apply(null)); // 输出:null
console.log(getThis.call(undefined)); // 输出:undefined
console.log(getThis.bind(true)() === true); // 输出:true

函数上下文中this的绑定

函数的调用情况不同,this绑定不同。函数上下文中this绑定的规则分为四种,分别是默认绑定、隐式绑定、显示绑定、new调用绑定。

  1. 默认绑定:函数被独立调用时绑定。
  2. 隐式绑定:函数通过对象调用时绑定。
  3. 显示绑定:通过call()apply()bind()方法指定函数运行上下文的绑定。
  4. new绑定:函数作为构造函数时的绑定。

箭头函数作为一种特殊的函数,它的函数作用域中没有基于箭头函数自身的this,也不会产生this绑定。当上述四类this绑定情况发生冲突时,JavaScript表现的绑定的优先级如下:

new绑定 > 显示绑定 > 隐式绑定 > 默认绑定

下面我们分别介绍函数调用中this绑定的具体情况。

1. 独立函数调用

不论函数定义在哪里,对它进行独立调用时会进行默认绑定,非严格模式下this指向window,严格模式下this指向undefined。符合静默转换规则。

function getThis() {
    return this;
}

function getStrickThis() {
    "use strict";
    return this;
}

const obj = {
    getThisMethod: function () {
        return this;
    }
}

const getThisMethod = obj.getThisMethod;

console.log(getThis()); // 输出 window 对象
console.log(getThisMethod()); // 输出 window 对象
console.log(getStrickThis()); // 输出:undefined

2. 作为对象方法调用

不论函数定义在哪里,函数作为对象方法调用的候,会进行隐式绑定,将当前对象作为上下文对象,this指向当前对象。

function getThis() {
    return this;
}

const obj1 = {
    name: 'obj1',
    getThisFunc: getThis,
    getThisMethod: function() {
        return this;
    }
};

const obj2 = {
    name: 'obj2',
    getThisFunc: getThis
};
console.log(obj1.getThisFunc()); // 输出 obj1 对象
console.log(obj1.getThisMethod()); // 输出 obj1 对象
console.log(obj2.getThisFunc()); // 输出 obj2 对象

3. 使用call()apply()bind()调用函数

使用call()apply()调用函数时可以为指定函数执行上下文中this的绑定值。使用bind()可以生成一个新函数,并且指定生成的函数执行上下文中this的绑定值,并且指定的this的绑定值不能再次使用bind()进行覆盖。这类this绑定为显示绑定。其中,bind()指定的this绑定不仅不可被bind()自身覆盖,也不可被call()apply()调用覆盖。当然,call()apply()bind()的作用并不仅仅是为了改变函数运行时的上下文对象。

示例代码如下:

function getThis() {
    return this;
}

const obj1 = {
    name: 'obj1',
    getThisMethod: function() {
        return this;
    }
};

const obj2 = {
    name: 'obj2'
};

console.log(getThis.call(obj1)); // 输出 obj1 对象
console.log(getThis.call(obj2)); // 输出 obj2 对象
console.log(getThis.apply(obj1)); // 输出 obj1 对象

// 显示绑定优先级大于隐式绑定
console.log(obj1.getThisMethod.call(obj2)); // 输出 obj2 对象

console.log(getThis.bind(obj1)()); // 输出 obj1 对象
// 重复绑定无效
console.log(getThis.bind(obj1).bind(obj2)()); // 输出 obj1 对象
obj1.getThisMethod.bind(obj2).call(obj1); // 输出 obj2 对象

call()apply()的不同点在于为函数指定传参的形式不同,开发人员可以根据需要选用任一方法指定this绑定。

4. 回调函数调用

当一个函数作为回调函数传递时,this的值取决于如何调用回调,这由API的实现者决定。当你定义一个函数作为回调函数传递给其他调用者调用时,这点要特别注意,函数中的this不是你能确定的,如果需要你定义的回调函数在确定的上下文对象上执行,你可以使用bind()进行硬绑定。

在JavaScript社区中,回调函数通常以undefined作为this的值被调用(直接调用,而不附加到任何对象上),这意味着如果函数是在非严格模式,this的值会是全局对象(globalThis)。这在迭代数组方法、Promise()构造函数等例子中都是适用的。迭代数组方法示例如下:

function logThis() {
    console.log(this);
}

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

[1, 2, 3].forEach(logThis); // 输出三次 window 对象
[1, 2, 3].forEach(logStrickThis); // 输出三次 undefined

5. 箭头函数调用

箭头函数内没有独立的this绑定,箭头函数中的this对象保留了闭合词法上下文的this值。换句话说,箭头函数在其周围的作用域上创建一个this值的闭包,这意味着箭头函数的行为就像它们是“自动绑定”的。无论如何调用,this都绑定到函数创建时的值。

const obj = {
    name: 'obj',
    getThisGetter: function() {
        const getThis = () => this;
        return getThis;
    },
};

const getThis = obj.getThisGetter();
const _this = getThis();
console.log(_this); // 输出 obj 对象

此外,当使用call()apply()bind()调用箭头函数时,显示指定的this值不生效。

6. 构造函数调用

当一个函数被用作构造函数(使用new关键字)时,无论构造函数是在哪个对象上被访问的,其this都会被绑定到正在构造的新对象上。除非构造函数返回另一个非原始值,不然this的值会成为new表达式的值。这种绑定被称为new绑定。它的优先级最高且不可被覆盖。

function getThis() {
    return this;
}

function Person(name) {
    this.name = name;
}

const context = { contextTag: 'context' };

const getThisWithContext = getThis.bind(context);
const PersonCopy = Person.bind();
const person = new PersonCopy('Guang');

console.log(getThisWithContext()); // 输出 context 对象
console.log(person); // 输出 person 对象,person 对象仅包含 name 属性,name 属性值为"Guang"

7. super关键字调用函数

super关键字用于访问对象字面量或类的原型([[Prototype]])上的属性,或调用父类的构造函数。ECMAScript 6推出类(class)声明,同时推出了一系列类继承相关的关键字,super就是其一。当一个函数以super.method()的形式被调用时,method函数内的thissuper.method()调用周围的this值相同,通常不等于super所指向的对象。这是因为super.method和上面的对象成员访问不同——它是有关类继承机制中的特殊语法,有不同的绑定规则。

类上下文中this的绑定

目前,对于JavaScript引擎来说,类(class)的本质还是函数作为构造函数,所以类上下文中的this绑定很多内容属于new绑定。同时,类的功能越来越丰富,特别是类的静态初始化块的出现,让我们有必要将类上下文分成两个上下文去理解:实例上下文和静态上下文。两个上下文分别维护两个this绑定。

  • 实例上下文:包括构造函数、方法、字段初始化器。
  • 静态上下文:包括静态方法、静态字段初始化器、静态初始化块。

JavaScript规定类的调用只能使用new调用,实例上下文中的this和普通函数作为构造函数调用时的绑定行为一样:this值是正在创建的新实例。类方法的行为像对象字面量中的方法——this值是方法被访问的对象。如果方法没有转移到另一个对象,this通常是类的一个实例。实例字段是在this被设置为正在构造的实例的情况下被初始化的,所以,字段初始化器中this绑定到当前构造的实例,字段初始化器中箭头函数中的this绑定到当前实例。

静态方法和静态字段时类本身的属性,它们需要通过类来访问,此时,this绑定就是类(或子类)自身的值。静态初始化块也是在this绑定为当前类自身的情况下进行求值的。静态字段是在this被设置为当前类的情况下被初始化的,所以,静态字段初始化器中this绑定到当前类自身,静态字段初始化器中箭头函数中的this绑定到当前类自身。

验证代码:

class C {
  instanceField = this;
  static staticField = this;
}

const c = new C();
console.log(c.instanceField === c); // 输出:true
console.log(C.staticField === C); // 输出:true

派生类构造函数

派生类与基类共用一个实例上下文的this绑定,同时和基类有着各自的静态上下文this绑定。派生类和基类共用的this绑定是由基类创建的,派生类构造函数没有初始的this绑定。派生类构造函数中,调用super()会调用基类的构造函数,基类在构造函数中创建一个this绑定,基本上和this = new Base()的效果类似(Base是基类)。这也是为什么派生类构造函数中super()语句有下面两条规则:

  1. 在调用super()之前引用this将抛出错误。
  2. 派生类在调用super()之前不能有返回,除非构造函数返回一个对象(这样this值就会被覆盖)或者类根本没有构造函数。

验证代码如下:

class Base {}
class Good extends Base {}
class AlsoGood extends Base {
  constructor() {
    return { a: 5 };
  }
}
class Bad extends Base {
  constructor() {}
}

new Good();
new AlsoGood();
new Bad(); // 报错: Must call super constructor in derived class before accessing 'this' or returning from derived constructor

全局上下文中的this的绑定

在全局执行上下文中(在任何函数或类之外;可能在全局范围内定义的块或箭头函数内部),this值取决于脚本运行的执行上下文。像回调函数一样,this值由运行时环境(调用者)确定。

代码示例如下:

// 在网页浏览器中,window 对象也是全局对象:
console.log(this === window); // 输出:true

this.b = "MDN";
console.log(window.b); // 输出:"MDN"
console.log(b); // 输出:"MDN"

在脚本的顶层,无论是否在严格模式下,this会指向globalThis。如果源代码作为模块加载(对于 HTML,这意味着在 <script> 标签中添加 type="module"),在顶层,this总是undefined

如果源代码使用eval()执行,this与直接调用eval()的闭合上下文相同,或者与间接调用evalglobalThis(就像它在单独的全局脚本中运行一样)相同。代码示例如下:

function test() {
  // 直接调用 eval
  console.log(eval("this") === this);
  // 间接调用 eval,非严格模式
  console.log(eval?.("this") === globalThis);
  // 间接调用 eval,严格模式
  console.log(eval?.("'use strict'; this") === globalThis);
}

test.call({ name: "obj" }); // 输出 3 个 "true"

注意:某些源代码虽然看起来像全局作用域,但在执行时实际上被包装在一个函数中。例如,Node.js CommonJS 模块被包装在一个函数中,并且this值设置为module.exports。事件处理器属性执行时,this设置为它们附加到的元素。

两个特殊情况

1. 对象字面量上的this

对象字面量不创建this作用域——只有在对象内定义的函数(方法)才会这样做。在对象字面量中使用this会从周围的作用域继承值。

示例代码如下:

const obj1 = {
    name: 'obj1',
    _self: this
};

function getObj(name) {
    const obj = {
        name: name,
        _self: this
    };

    return obj;
}

const obj2 = getObj.bind(obj1)('obj2');

console.log(obj1._self); // 输出 window 对象
console.log(obj2._self); // 输出 obj1 对象

2. getter或setter中的this

在getter和setter中,this是基于访问属性的对象,而不是定义属性的对象。用作getter或setter的函数会将其this绑定到正在设置或获取属性的对象。

代码示例:

function sum() {
  return this.a + this.b + this.c;
}

const obj = {
  a: 1,
  b: 2,
  c: 3,
  get average() {
    return (this.a + this.b + this.c) / 3;
  },
};

Object.defineProperty(obj, "sum", {
  get: sum,
  enumerable: true,
  configurable: true,
});

console.log( obj.sum); // 输出:6
console.log(obj.average); // 输出:2

this动态性的好处

首先,作为上下文对象,this的实现是面向对象编程范式的基本要素,this绑定的存在提高了JavaScript语言的表达能力。其次,this绑定的灵活,将函数或方法与其定义者解耦,让函数更加容易复用或被对象共享,能够支持跨上下文的函数或方法的共享。再者,函数在作为回调函数进行调用时,函数的调用环境可以很方便地增强回调函数的表达,使回调函数的表达更直观。比如DOM 事件处理函数中this自动指向触发元素。另一方面,JavaScript原型机制也依赖于this动态绑定的特性。

this的值不是拥有此函数作为自己属性的对象,而是用于调用此函数的对象。这是JavaScript原型机制良好工作地基础。看如下代码:

const nameKeeperPrototype = {
    sayName: function() {
        return 'My name is: ' + this.name;
    }
};

const person = {
    name: 'Guang'
};

Object.setPrototypeOf(person, nameKeeperPrototype);

console.log(person.sayName()); // 输出 "My name is: Guang"

sayName()虽然定义在nameKeeperPrototype对象上,但是被person对象调用时,this指向person对象。

this动态性带来的问题

this设计为动态的虽然具有许多优点,但也带来了一些潜在的缺点和挑战。首先,this的动态性增加了理解和调试的困难程度,尤其是在大型应用中,this的指向可能不易追踪,开发者需要注意在不同上下文中如何使用this。其次,在一些异步操作或回调函数中,this的指向可能会丢失,导致程序出现难以追踪的错误。在Promiseasync/await语法出现之前,回调地狱是每个前端开发人员的噩梦。还比如,在事件处理或异步回调中,this可能不再指向预期的对象。再者,this默认绑定到全局的行为增加了全局环境被污染的可能性,开发人员很容易在无意间把值更新到了全局对象上。另一方面,this的动态性不利于单元测试,编写单元测试时,可能需要更复杂的模拟和设置。特别是在测试事件处理程序、回调函数等异步操作时,this的值很难预先确定,可能需要使用如call()apply()bind()等方法显式设置this的值,这增加了测试的复杂性。

下面代码演示了回调函数丢失this

function Timer() {
    this.time = 0;
    setInterval(function() {
        this.time++;  // 这里的 `this` 不再指向 Timer 实例
        console.log(this.time);  // 可能输出 NaN 或 undefined
    }, 1000);
}

const timer = new Timer();

为了避免this动态性带来的这些问题,开发人员需要大量的使用箭头函数,或者利用call()apply()bind()显示绑定this的值,这不仅增加了项目的维护复杂度,也给程序性能带来了不好的影响。

发表留言

历史留言

--空--