深入理解JavaScript中的this:从底层原理到实践应用

105 阅读13分钟

前言

在JavaScript的世界里,this关键字无疑是一个令人既爱又恨的存在。它灵活多变,常常让初学者感到困惑,即使是经验丰富的开发者也可能在特定场景下对其捉摸不定。然而,正是这种灵活性,赋予了JavaScript强大的表达能力。作为一名掘金博主,我深知大家对底层原理和详尽解析的渴望。因此,本文将带你深入剖析this的本质,从其在JavaScript引擎内部的运作机制,到各种绑定规则的详细解读,并通过丰富的代码示例,帮助你彻底掌握this的奥秘,让你在面对各种this指向问题时游刃有余。

理解this,不仅仅是记住几条规则,更重要的是理解其背后的设计哲学和执行上下文的关联。只有这样,你才能真正做到“知其然,知其所以然”。准备好了吗?让我们一起踏上这段探索this的旅程吧!

this的本质:执行上下文中的动态绑定

要理解this,我们首先要从JavaScript的执行上下文(Execution Context)说起。每当JavaScript代码执行时,都会创建一个执行上下文。这个上下文包含了当前代码运行所需的所有信息,其中就包括了this的值。

与许多其他语言不同,JavaScript中的this不是在函数定义时确定的,而是在函数调用时动态绑定的。这意味着同一个函数,在不同的调用方式下,this的值可能会完全不同。这种动态性是this复杂性的根源,也是其强大之处。

this实际上是执行上下文的一个属性,它指向当前正在执行代码的那个对象。这个“对象”可以是全局对象、某个特定的对象实例,甚至是undefinedthis的值由函数的调用方式决定,这正是我们接下来要详细探讨的几种绑定规则。

this的五种绑定规则

JavaScript中this的绑定规则主要有五种,它们按照优先级从低到高分别是:默认绑定、隐式绑定、显式绑定、new绑定和箭头函数绑定。理解这些规则是掌握this的关键。

1. 默认绑定 (Default Binding)

当函数在没有任何修饰的情况下被独立调用时,this会指向全局对象。在浏览器环境中,全局对象是window;在Node.js环境中,全局对象是global。然而,在严格模式('use strict')下,默认绑定会失效,this会被设置为undefined

示例分析 :

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>this指向</title>
</head>
<body>
    <script>
        "use strict"; // 开启严格模式
        var name='张三'
        function fn() {
            var name='李四';
            console.log(this.name);
        }
        fn(); // 在严格模式下,this为undefined,尝试访问undefined.name会报错
        // window.fn(); // 即使在严格模式下,通过window对象调用,this依然指向window
​
        // ... 其他代码
    </script>
</body>
</html>

在上述代码中,fn()是直接调用的,没有明确的调用者。在非严格模式下,this会指向window,因此this.name会输出全局变量name的值,即'张三'。但由于我们开启了严格模式,fn()this被设置为undefined,所以console.log(this.name)会抛出TypeError: Cannot read properties of undefined (reading 'name')

window.fn()这种调用方式,即使在严格模式下,this依然会指向window,因为fn被明确地作为window对象的一个方法来调用,这属于隐式绑定,优先级高于默认绑定。

底层原理:

当函数以默认绑定规则被调用时,JavaScript引擎会检查当前执行上下文的this值。如果函数不是作为对象的方法被调用,也不是通过callapplybindnew关键字调用,那么this就会被设置为全局对象。在严格模式下,为了避免意外的全局变量污染和提高代码安全性,this会被显式地设置为undefined,从而强制开发者更明确地处理this的指向。

2. 隐式绑定 (Implicit Binding)

当函数作为某个对象的方法被调用时,this会指向调用该方法的对象。这是最常见也是最直观的this绑定规则。

示例分析 :

let obj ={
    name: '王五',
    fn: function(){
        console.log(this.name);
    }
}
obj.fn(); // 输出 '王五'

在这个例子中,fn函数是obj对象的一个属性,并通过obj.fn()的方式调用。此时,obj就是fn的调用者,因此fn内部的this会指向objthis.name自然就是obj.name的值'王五'

隐式丢失 (Implicit Loss) 的陷阱:

隐式绑定有一个非常常见的陷阱,那就是“隐式丢失”。当一个被隐式绑定的函数,在脱离了其原始的调用对象后被调用时,它会退回到默认绑定规则,或者在严格模式下变为undefined

示例分析 :

const fn2 = obj.fn;
fn2(); // 在非严格模式下输出 '张三' (全局变量),在严格模式下报错

这里,我们将obj.fn赋值给了fn2变量。此时,fn2仅仅是对obj.fn函数的一个引用,它不再与obj对象有任何关联。当fn2()被独立调用时,它符合默认绑定规则,因此this会指向全局对象(非严格模式下window,严格模式下undefined)。这就是this的隐式丢失问题。

常见隐式丢失场景:

  • 赋值给变量: 如上例所示。
  • 作为回调函数: 当函数作为参数传递给另一个函数(如setTimeout、事件监听器等)时,它通常会丢失其原有的this绑定。

底层原理:

JavaScript引擎在执行obj.fn()时,会识别出fn是通过obj这个引用来调用的。在内部,它会将obj设置为fn函数执行上下文的this值。然而,当fn2 = obj.fn发生时,仅仅是复制了函数fn的引用,并没有复制其与obj的绑定关系。因此,当fn2()被调用时,引擎无法找到明确的调用对象,便会回退到默认绑定规则。

3. 显式绑定 (Explicit Binding)

为了解决隐式丢失等问题,JavaScript提供了call()apply()bind()这三个方法,允许我们明确地指定函数执行时的this值。这被称为显式绑定。

call()apply()

call()apply()的作用几乎相同,都是立即执行函数,并指定函数内部的this指向。它们的主要区别在于传递参数的方式:call()接受一系列独立的参数,而apply()接受一个参数数组。

语法:

  • func.call(thisArg, arg1, arg2, ...)
  • func.apply(thisArg, [argsArray])

示例分析 :

var a ={
    name:"赵六",
    func1: function(){
        console.log(this.name);
    },
    func2:function(){
        setTimeout(function (){
            // 假如this 可以被指定?
            this.func1();
        }.apply(a),1000) // 使用 apply 强制将 this 绑定到 a
    }
}
a.func2(); // 1秒后输出 '赵六'

func2方法中,setTimeout的回调函数是一个普通函数,如果直接调用,其this会指向全局对象(或undefined)。为了让回调函数中的this指向a对象,我们使用了.apply(a)。这样,即使回调函数被setTimeout独立调用,其内部的this也被强制绑定到了a,从而能够正确地调用a.func1()

bind()

bind()方法与call()apply()不同,它不会立即执行函数,而是返回一个新函数。这个新函数的this被永久绑定到bind()的第一个参数,并且无法再被call()apply()new改变。

语法:

  • func.bind(thisArg, arg1, arg2, ...)

示例分析 :

// button.jsfunction Button(id){
    this.element = document.querySelector(`#${id}`);
    this.bindEvent();
}
​
Button.prototype.bindEvent = function(){
    // this 丢失问题 // this Button
    this.element.addEventListener('click',this.setBgColor.bind(this))
}
​
Button.prototype.setBgColor = function(){
    this.element.style.backgroundColor = '#1abc9c';
    // this
}

以上addEventListener的回调函数this.setBgColor在事件触发时会被独立调用,此时其this会指向触发事件的DOM元素(即button元素),而不是Button实例。为了确保setBgColor中的this始终指向Button实例,我们使用了this.setBgColor.bind(this)bind(this)创建了一个新函数,这个新函数的this被永久绑定到了当前的Button实例,从而解决了this丢失的问题。

底层原理:

callapply在内部会立即调用目标函数,并将传入的thisArg作为函数执行上下文的this值。bind则更为复杂,它会创建一个新的函数(称为“绑定函数”),这个绑定函数会记住原始函数和它被绑定的this值。当绑定函数被调用时,它会强制将原始函数的this设置为之前绑定的值,而忽略任何其他绑定规则(如隐式绑定或默认绑定)。

4. new绑定 (Constructor Binding)

当使用new关键字调用一个函数时,这个函数就被当作构造函数来使用。new操作符会执行一系列操作,其中就包括确定this的指向。

new操作符的执行步骤:

  1. 创建一个全新的空对象: 这个新对象就是即将被构造的实例。
  2. 将这个新对象的[[Prototype]]链接到构造函数的prototype属性: 这样,新对象就可以访问构造函数原型链上的方法和属性。
  3. 将这个新对象绑定为函数调用中的this 构造函数内部的this会指向这个新创建的对象。
  4. 执行构造函数内部的代码: 此时,构造函数可以使用this来为新对象添加属性和方法。
  5. 如果构造函数没有显式返回其他对象,那么new表达式中的函数调用会自动返回这个新对象。 如果构造函数显式返回了一个对象,那么new表达式会返回这个显式返回的对象;如果返回的是非对象类型,则仍然返回新创建的对象。

示例分析 :

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>函数</title>
</head>
<body>
    <script>
        function Person(name, age){
            // 1. 生成一个新的对象{}
            // 2. this 指向新对象
            this.name = name;
            this.age = age;
            // 3. 对象的构造
        }
        Person.prototype.sayHi = function(){
            console.log(`你好,我是${this.name}`)
        }
        
        const haha = new Person('haha',18);
        console.log(haha.__proto__ === Person.prototype); // true
        haha.sayHi(); // 输出 '你好,我是haha'
    </script>
</body>
</html>

当执行const haha = new Person('haha',18);时:

  • 首先,一个空对象{}被创建。
  • haha__proto__被设置为Person.prototype
  • Person函数内部的this被绑定到这个新创建的空对象。
  • this.name = name;this.age = age;nameage属性添加到这个新对象上。
  • 由于Person函数没有显式返回其他对象,所以new表达式最终返回了这个新创建并填充了属性的对象,并赋值给haha

因此,haha.sayHi()能够正确地访问到haha对象上的name属性。

底层原理:

new操作符在JavaScript引擎层面是一个非常特殊的操作。它不仅仅是调用一个函数,更是一个创建对象并建立原型链的过程。在执行构造函数之前,引擎会预先创建一个对象,并将该对象的[[Prototype]]链接到构造函数的prototype属性上。然后,将这个新创建的对象作为构造函数内部的this值。这确保了构造函数能够正确地初始化新实例的属性。

5. 箭头函数绑定 (Arrow Function Binding)

箭头函数(Arrow Functions)是ES6引入的新特性,它在this的绑定上与传统函数有着根本的区别。箭头函数没有自己的this,它会捕获其所在(定义时)上下文的this值,作为自己的this。这意味着箭头函数的this在定义时就已经确定,并且不会被call()apply()bind()new改变。

示例分析 :

var a ={
    name:"赵六",
    func1: function(){
        console.log(this.name);
    },
    // ... 其他 func2 实现
    func2: function (){
        console.log(this); // 这里的 this 指向 a
        setTimeout(()=>{
            // 箭头函数没有自己的 this,它会捕获上层作用域的 this,即 func2 中的 this (也就是 a)
            this.func1();
        },1000)
    }
}
a.func2();

在这个例子中,func2是一个普通函数,当通过a.func2()调用时,func2内部的this指向a对象。setTimeout的回调函数是一个箭头函数() => { this.func1(); }。这个箭头函数在定义时,其外层作用域是func2函数,所以它会捕获func2this值,也就是a。因此,即使setTimeout会在全局环境下调用回调函数,箭头函数内部的this仍然指向a,从而能够正确地调用a.func1()

与传统函数的对比:

  • 传统函数: this在运行时动态绑定,取决于函数的调用方式。
  • 箭头函数: this在定义时词法绑定,继承自外层作用域的this

底层原理:

箭头函数的设计初衷就是为了解决传统函数中this指向的困扰,尤其是在回调函数中。在JavaScript引擎解析箭头函数时,它不会为箭头函数创建独立的执行上下文,而是直接继承其父级作用域的this。这意味着箭头函数内部没有arguments对象,也不能作为构造函数使用(即不能使用new关键字调用)。这种词法绑定机制使得箭头函数在处理回调函数时更加简洁和可预测。

this绑定规则的优先级

当一个函数可能同时符合多种this绑定规则时,它们之间存在一个明确的优先级顺序:

  1. new绑定:最高优先级。通过new关键字调用的函数,其this会指向新创建的对象。
  2. 显式绑定 (call, apply, bind) :次高优先级。通过callapplybind明确指定的this值会覆盖隐式绑定和默认绑定。需要注意的是,bind创建的绑定函数,其this是永久绑定的,即使再用callapply也无法改变。
  3. 隐式绑定:再次之。当函数作为对象的方法被调用时,this指向该对象。
  4. 默认绑定:最低优先级。独立调用的函数,this指向全局对象(非严格模式)或undefined(严格模式)。

箭头函数this绑定规则独立于上述四种,它不适用这些规则,而是根据词法作用域来确定this。可以认为箭头函数的this绑定优先级高于所有其他四种绑定,因为它根本不参与它们的竞争,而是直接从父级作用域继承。

实践中的this问题与解决方案

理解了this的绑定规则后,我们来看看在实际开发中,this常常会引发哪些问题,以及如何优雅地解决它们。

1. 回调函数中的this丢失

这是最常见的问题之一,尤其是在使用setTimeout、事件监听器、Ajax回调等异步操作时。

问题示例:

function Car(name) {
    this.name = name;
    this.speed = 0;
​
    this.accelerate = function() {
        setTimeout(function() {
            // 这里的 this 丢失了,指向 window 或 undefined
            this.speed += 10;
            console.log(`${this.name} current speed: ${this.speed}`);
        }, 1000);
    };
}
​
const myCar = new Car('BMW');
myCar.accelerate(); // 报错或输出 NaN,因为 this.name 和 this.speed 无法正确访问

解决方案:

  • 使用that = this (闭包): 在回调函数外部保存this的引用。

    function Car(name) {
        this.name = name;
        this.speed = 0;
        const that = this; // 保存 this 的引用
    ​
        this.accelerate = function() {
            setTimeout(function() {
                that.speed += 10;
                console.log(`${that.name} current speed: ${that.speed}`);
            }, 1000);
        };
    }
    
  • 使用bind() 强制绑定回调函数的this

    function Car(name) {
        this.name = name;
        this.speed = 0;
    ​
        this.accelerate = function() {
            setTimeout(function() {
                this.speed += 10;
                console.log(`${this.name} current speed: ${this.speed}`);
            }.bind(this), 1000); // 绑定 this 到 Car 实例
        };
    }
    
  • 使用箭头函数 (推荐): 箭头函数词法绑定this,无需额外处理。

    function Car(name) {
        this.name = name;
        this.speed = 0;
    ​
        this.accelerate = function() {
            setTimeout(() => { // 使用箭头函数
                this.speed += 10;
                console.log(`${this.name} current speed: ${this.speed}`);
            }, 1000);
        };
    }
    

2. 事件处理函数中的this

在DOM事件处理函数中,this通常指向触发事件的DOM元素。

示例:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <button id="btn">点击</button>
    <script>
        const btn = document.getElementById('btn');
        btn.addEventListener('click',function(){
            console.log(this); // 输出 <button id="btn">点击</button>
            console.log('点击了')
        })
    </script>
</body>
</html>

如果你的事件处理函数需要访问外部对象的this,同样可以使用bind()或箭头函数来解决。

示例 :

// button.js
function Button(id){
    this.element = document.querySelector(`#${id}`);
    this.bindEvent();
}
​
Button.prototype.bindEvent = function(){
    // 使用 bind 确保 setBgColor 中的 this 指向 Button 实例
    this.element.addEventListener('click',this.setBgColor.bind(this))
}
​
Button.prototype.setBgColor = function(){
    // 这里的 this 始终指向 Button 实例
    this.element.style.backgroundColor = '#1abc9c';
}
​
// 在 HTML 中使用
// <button id="myButton">Change Color</button>
// <script>
//    new Button('myButton');
// </script>

3. 严格模式对this的影响

严格模式('use strict')对this的默认绑定有显著影响。在严格模式下,独立调用的函数(默认绑定)中的this不再指向全局对象,而是undefined。这有助于避免意外的全局变量污染,并强制开发者更明确地处理this的指向。

非严格模式 vs 严格模式:

调用方式非严格模式下的this严格模式下的this
独立函数调用全局对象 (window/global)undefined
对象方法调用调用该方法的对象调用该方法的对象
call/apply/bind指定的thisArg指定的thisArg
new构造函数新创建的对象新创建的对象
箭头函数词法作用域的this词法作用域的this

可以看出,严格模式主要影响的是默认绑定。对于其他绑定规则,this的指向行为保持一致。

总结与最佳实践

this是JavaScript中一个核心且复杂的概念,但通过深入理解其五种绑定规则及其优先级,我们可以清晰地判断this的指向。掌握this不仅仅是记忆规则,更重要的是理解其动态绑定的本质和执行上下文的关联。

最佳实践:

  1. 优先使用箭头函数处理回调: 在需要保持this上下文不变的回调函数中,箭头函数是最佳选择,因为它提供了词法作用域的this,避免了传统函数的this丢失问题。
  2. 明确this的来源: 在编写函数时,始终思考这个函数将如何被调用,以及this在不同调用场景下的预期值。
  3. 善用bind()进行永久绑定: 当你需要一个函数的this永久指向某个特定对象,并且该函数会被多次调用或作为回调传递时,bind()是非常有用的。
  4. 理解new操作符的魔力: 当你设计构造函数时,要清楚new操作符如何创建新对象并绑定this
  5. 警惕隐式丢失: 当将对象方法赋值给变量或作为回调函数传递时,要特别注意this的隐式丢失问题,并采取相应的解决方案。
  6. 拥抱严格模式: 始终在代码中使用严格模式('use strict'),它能帮助你捕获一些常见的编码错误,包括this的意外绑定,从而编写更健壮的代码。

通过不断地实践和思考,你将能够驾驭this这个强大的工具,编写出更优雅、更可维护的JavaScript代码。希望本文能为你深入理解this提供有益的帮助!