深入理解JS(六) - this与月下三兄贵call、apply、bind

426 阅读19分钟

前言

在 JS 中,实行词法作用域,它是静态的,所以又被称为“静态作用域”。其核心就是:函数的作用域是在定义时就确定的,和调用位置没有关系。这种 “静态绑定” 的特性让作用域规则变得可预测,但 JS 中却有一个“特殊存在”——this它是动态的,打破了词法作用域的静态规则,其指向完全取决于函数的调用方式,而非定义位置。在开发中,this的动态指向总是让开发者很头疼,接下来我将带你深入理解this,掌握它的指向问题。

一. this 指向

this 是什么

  • 定义: this关键字是函数运行时自动生成的一个内部对象,只能在函数内部使用。从本质上来说,this 是一种引用机制,其作用是让函数能够灵活地访问和操作不同的对象。这使得函数可以在不同的上下文中被复用,并且能够动态地访问调用它的对象的属性和方法
    • 简单来说,this 就是该对象的快捷引用

    • 比如说有些德国人的名字很长啊,《少年维特之烦恼》的作者歌德的全名叫 “约翰・沃尔夫冈・冯・歌德”,但是我们一般没人用全名称呼他,都以 “歌德” 称呼他。

    • “约翰・沃尔夫冈・冯・歌德” => “歌德”,这就是 this 代表的快捷引用,并且复用起来相当便捷,毕竟说十次 “歌德” 肯定比起说十次 “约翰・沃尔夫冈・冯・歌德” 要轻松得多。

    • 代码示例: 下面的代码中,this 就是 person对象的快捷引用,使得person的内部函数 introduce()可以非常方便地使用其属性 nameage

      const person = {
          name: '张三',
          age: 18,
          introduce(){
              console.log(`大家好,我叫${this.name},今年${this.age}岁!`);
          }
      }
      
      person.introduce(); // 大家好,我叫张三,今年18岁!
      

指向规则

  • this指向规则: 不管在什么情况下,this永远由函数的调用方式决定,并且在函数执行过程中,this 一旦确定了,就不能再更改了。

  • this 已确定后,不可中途修改:

    var obj1 = {
        name: "张三"
    };
    function func1() {
        console.log(this);
    }
    // this 确认为全局对象,严格模式下为undefined
    func1(); // Window
    
    function func2() {
        this = obj1;
        console.log(this);
    }
    
    // 中途直接修改this指向之后,运行函数直接报错(this 为不可直接作为左值修改)
    func2(); // Uncaught SyntaxError: Invalid left-hand side in assignment (at 0.js:11:5)
    
  • this 与执行上下文:

    • 函数有各种调用方式,这个我们清楚,那么这个执行上下文有什么说法呢?这是因为this的指向实际上是执行上下文的产物(还记得执行上下文吗?在《深入理解JS(三) - 执行上下文、作用域链与闭包》一文中我们曾详细介绍过执行上下文(ES3版),不清楚的可以去补补课)。
    • 函数的调用方式决定this指向,这只是我们常看到的表面现象,就像按下按钮,机器就会执行操作一样;但其实真正决定this指向的底层机制是执行上下文,它就像机器内部的真正在发挥作用的各种机械构造
    • 当我们调用一个函数时,JS 引擎会进行如下操作:
        1. 创建一个新的执行上下文;
        1. 根据 调用方式 设置this的值
        1. 执行函数代码。
    • 在第2步操作中,我们能看到this会被设置指向,所以我们才说this的指向是调用执行上下文的产物
    • 在各个 JS 版本的执行上下文中,this 的作用都一样,那就是如上文中所说的快捷引用对象。ES3 版本的执行上下文中,我们称为“this 指向”,而 ES6 版本的执行上下文中,我们称为“this 绑定”。
      • 执行上下文示意图:

        2. 执行上下文示意图2.png

  • 下面我将为你介绍 this 在不同的函数调用方式下的指向情况以及该函数的执行上下文的情况。

1. 普通函数直接调用(默认绑定)

  • this指向: 当该函数作为普通函数直接调用(函数名())时,函数内的 this 指向全局对象(Node.js环境下为global,浏览器环境下为window),这属于“默认绑定”情况。

  • 执行上下文分析: 直接调用函数时,会创建一个该函数的执行上下文,非严格模式下,其 this 指向全局对象(根据环境而定,Node.js环境中为global,浏览器环境中为Window);严格模式下,其 this 指向 undefined

  • 执行上下文图示:

    图1 默认绑定.png

  • 示例代码:

    // 若要使变量成为全局对象中的属性(全局变量污染)
    // 此处声明的变量必须 使用var 或是 不使用变量修饰符
    
    // 若使用let、const声明变量(避免全局变量污染)
    // 即使在全局作用域中声明变量,仍不为全局对象的属性
    var name = "张三";
    
    // 普通函数内的直接调用函数
    function func1() {
        const name = "李四";
        // func1 this: Window;this.name:张三
        console.log("func1 this:", this, ";this.name:" + this.name);
        function func2() {
            // func2 this: Window;this.name:张三
            console.log("func2 this:", this, ";this.name:" + this.name);
        }
        // 在函数内直接调用
        func2();
    };
    // 非严格模式下,直接调用函数
    // this 指向全局对象(Node.js环境下为global,浏览器环境下为Window)
    func1();
    
    
    // 对象内的普通函数调用
    const obj1 = {
        name: "obj1",
        func3() {
            // func3 this: {name: 'obj1', func3: ƒ}
            console.log("func3 this:", this); // this 指向 对象obj1(隐式绑定)
    
            // func4 this: Window
            function func4() {
                console.log("func4 this:", this);
            }
            // 在对象内的函数中直接调用
            func4(); // 这个也算直接调用,其 this 指向全局对象Window
        }
    };
    
    obj1.func3();
    // 严格模式下,直接调用函数,this 指向undefined
    
  • 所以,无论是在全局作用域中,还是对象内部或函数内部,直接使用 “函数名()” 这种形式调用函数,其 this 就是会指向全局对象(严格模式下指向 undefined)。

2. 对象内部方法被调用(隐式绑定)

  • this指向: 当该函数作为一个对象内部的方法被调用时,函数内的 this 指向该对象,这属于“隐式绑定”情况。

  • 执行上下文分析: 调用 obj.self() 时,JS 引擎创建函数执行上下文,其中this被绑定到调用方法的对象obj(因为以对象方法形式调用的函数的执行上下文会将this指向调用者)。

  • 执行上下文图示:

    图2 隐式绑定.png

  • 示例代码:

    const obj = {
        name: '张三',
        // 该方法用来检验this指向
        self() {
            console.log(this);
        }
    };
    
    // 从输出结果可以看出
    // 作为对象方法被调用时,函数的this指向该对象
    obj.self(); // { name: '张三', self: [Function: self] }
    
补充:原型链与 this

说到对象怎么能不提到原型链(可前往《深入理解JS(五) - 原型与原型链》学习),在对象调用方法时,我们经常碰到 该对象调用的方法 为 继承自原型对象的方法 的情况,这时,这个方法其实实际上是原型对象上的方法,那么this应该指向哪里呢?是原型链上的原型对象,还是调用方法的原对象呢?

  • this指向: 无论方法是定义在对象自身还是其原型链上,this 始终指向调用该方法的对象动态绑定与定义位置无关!!!)。

  • 示例代码:

    // 定义原型对象
    const proto1 = {
        sayHi() {
            console.log("Hello from", this);
        }
    };
    
    // 创建对象并将 proto1 设置为其原型
    const obj = Object.create(proto1);
    obj.name = "obj1";
    
    // 调用原型链上的方法
    // 结果说明,this 指向调用该方法的对象
    obj.sayHi(); // Hello from { name: 'obj1' }
    

3. 构造函数调用(new 绑定)

  • this指向: 当函数作为构造函数通过 new 关键字调用时,其 this 指向新创建的对象实例,这属于通过 new 关键字进行绑定。

  • new 关键字工作流程:

    • 1. 新对象的创建: new 关键字会先创建一个新的空对象
    • 2. 原型的设置: 空对象的原型([[Prototype]]会被设置为 构造函数 的 prototype 属性。(如果你还不知道啥叫原型,啥是prototype属性,赶紧补补课《深入理解JS(五) - 原型与原型链》原型真的超超超重要!!!
    • 3. 构造函数的执行: 构造函数会以该空对象作为 this 指向的对象来执行,同时可以给构造函数传递参数。
    • 4. 返回值的处理:
      • 默认情况: 即为没有主动使用 return 关键字手动指定返回一个对象类型的值,此时默认返回 this 指向的对象
      • 手动返回情况: 即为主动使用了 return 关键字手动指定返回一个对象类型的值,此时返回的是手动指定的对象
      • 注意: 使用 return 返回的如果是包括 null 在内的基本数据类型,则会被判断返回操作无效,此时返回的仍是 this 指向的对象。
        • 毕竟构造函数的使命就是创建对象,你让人家返回一个基本数据类型,这很不像话,人家拒绝干这事也很正常。
  • 执行上下文分析: 构造函数被调用时,引擎创建构造函数执行上下文this 被绑定到新创建的实例对象,并隐式返回该实例(除非手动返回其他对象)。

  • 执行上下文图示:

    图3 new 绑定.png

  • 示例代码:

    function Person(name) {
        // 作为构造函数被调用,new 关键字的工作流程
    
        // 1. 创建了一个新的空对象实例
    
        // 2. 空对象的原型被设为 构造函数Person 的 prototype 属性
    
        // 3. 构造函数会以该空对象作为 this 指向,同时可以给构造函数传递参数
        // name就是传进来的参数
    
        // 此时 this 指向的就是这个空对象
        console.log(this); // Person {}
    
        // 为这个空对象添加属性name
        this.name = name;
        // 此时对象内有了属性name,this 指向的仍为该对象
        console.log(this); // Person { name: '张三' }
    
        // 4. 若在构造函数中,不主动设置一个返回对象,则默认返回 this 指向的这个对象
        // 若是主动设置了一个返回对象,则返回该对象
        // 注意:返回包括null在内的基本数据类型,返回无效,仍返回 this 指向的对象
    
        // const obj = {};
        // return obj; // 此时返回手动指定的对象 obj
    
        return 1; // 此时返回的仍为this指向的对象
    }
    const person1 = new Person('张三');
    console.log(person1); // Person { name: '张三' }
    

4. 箭头函数被调用(编译时绑定)

  • this指向: 当该函数为箭头函数时,其 this 指向继承自外层作用域的执行上下文(即定义时的外层作用域环境),这下又变回像词法作用域一样的 静态绑定 了。
    • 因为箭头函数是 ES6 的新特性,JS 设计师引入这个新特性的目的之一,就是对函数 this 指向为动态绑定这一乱象重拳出击,毕竟比起难以捉摸的动态绑定,还是静态绑定更深得人心
  • 执行上下文分析: 箭头函数自己的执行上下文和普通函数的执行上下文有区别,没有独立的 thisargumentssuper 绑定,它的 this 指向情况 继承自 定义该箭头函数时的外层作用域 的执行上下文
  • 执行上下文图示: 以示例3代码为例

图4 箭头函数.png

  • 示例代码:
// 示例 1:箭头函数在全局作用域中的 this
console.log("示例 1:");
const globalArrow = () => console.log(this);
globalArrow(); // 在浏览器中输出 Window 对象,在 Node.js 中输出空对象 {}

// 示例 2:箭头函数在对象方法中的 this
console.log("示例 2:");
var name = "张三";
const person = {
    name: "李四",
    greet: function () {
        // 普通函数,this 指向 person 对象
        console.log("greet普通函数 this:", this, ";this.name:", this.name);

        // 箭头函数,this 继承自 greet 函数的 this(即 person 对象,person对象调用greet函数)
        const arrow = () => console.log("arrow箭头函数 this:", this, ";this.name:", this.name);
        arrow();
    },
    badGreet: () => {
        // 箭头函数,其 this 继承自全局作用域
        console.log("badGreet箭头函数 this:", this, ";this.name:", this.name);
    }
};
person.greet();     // 普通函数 this: person;this.name: 李四
// arrow箭头函数 this: person;this.name: 李四
person.badGreet();  // badGreet箭头函数 this: Window;this.name: 张三

// 示例 3:箭头函数在构造函数中的 this
console.log("示例 3:");
function Car(color) {
    // 为实例添加属性,此时的this指向创建的对象实例
    this.color = color;

    // 将回调函数包裹起来,制造一层外层作用域
    // 外层作用域即badShowColor的函数作用域,其this指向对象实例
    this.badShowColor = function () {
        // 因为此时setTimeout里执行的回调函数为普通函数,所以相当于直接调用普通函数
        // 其this不继承外层作用域,直接指向全局对象
        setTimeout(function () {
            console.log("普通函数this:", this, ";this.color:", this.color); // 错误,this 不指向 Car 实例
        }, 100);
    };

    // 将回调函数包裹起来,制造一层外层作用域
    // 外层作用域即goodShowColor的函数作用域,其this指向对象实例
    this.goodShowColor = function () {
        // 因为此时setTimeout里执行的回调函数为箭头函数,所以其this继承自外层作用域
        // 外层作用域的this指向对象实例,所以箭头函数的this也指向对象实例
        setTimeout(() => {
            console.log("箭头函数this:", this, ";this.color:", this.color); // 正确,this 指向 Car 实例
        }, 100);
    };
}
const redCar = new Car("红色");
redCar.badShowColor();  // 普通函数this: Window;this.color: undefined
redCar.goodShowColor(); // 箭头函数this: Car;this.color: 红色
箭头函数为什么不创建自己的执行上下文
  • 《深入理解JS(五) - 原型与原型链》关于“箭头函数”一节中,我们介绍过了:箭头函数是 ES6 推出的简洁版函数,JS 设计师希望箭头函数成为一种轻量级、无状态的函数,专门用于简洁的表达式和词法作用域绑定,而非作为构造对象的蓝图。
  • 也就是说,箭头函数它只能用于函数表达式这一个用途它只是个工具,而且是功能简单的工具。工具要具体如何运用,交给其绑定的外层作用域。
  • 打个比方: 现在我们要做“切菜”这样一个简单的工作,但是没有切菜的工具,我相信大部分人都是直接去买把菜刀,然后自己拿菜刀切菜;而不是从无到有开发一款切菜机器人,然后对切菜机器人下命令,让它去切菜。
    • 这里面的菜刀就是箭头函数,拿菜刀切菜的人就是外层作用域的执行上下文,切菜机器人就是普通函数执行时会创建的函数执行上下文
    • 切出来的菜是个什么情况,全靠拿菜刀的人的刀工如何,所以箭头函数的 this 指向由外层作用域的执行上下文决定;而机器人就靠自己的算法了,所以普通函数有自己的函数执行上下文,它要靠自己。

补充:DOM事件

在HTML页面中,我们经常使用事件绑定来操作页面中的组件,this在其中发挥了很大的作用。

  • this 指向规则: DOM事件处理中的 this 指向取决于事件绑定方式
通过HTML标签属性绑定事件(内联事件)
  • this 指向: 内联事件中的函数相当于全局函数调用,其 this 指向全局对象window(非严格模式)或 undefined(严格模式)。

  • 示例代码:

    <button onclick="handleClick()">点击我</button>
    
    function handleClick() {
        // 内联事件中的函数相当于全局函数调用
        console.log(this); // 指向 window(非严格模式)或 undefined(严格模式)
    }
    
通过 addEventListener 绑定事件
  • this 指向:

    • 普通函数: 如果事件处理函数为普通函数,那么其this为动态绑定,绑定到触发事件的DOM元素
    • 对象方法: 如果事件处理函数为对象方法,则其本质上属于将该对象方法的函数引用传递给 addEventListener,从而达到和调用普通函数一样的效果,即 this 绑定到触发事件的DOM元素
    • 箭头函数: 如果事件处理函数为箭头函数,因为箭头函数没有自己的 this,会捕获定义时所在作用域的 this(通常是全局作用域的 window
  • 示例代码:

    <button id="myBtn1">addEventListener 普通函数</button>
    <button id="myBtn2">addEventListener 对象方法</button>
    <button id="myBtn3">addEventListener 箭头函数</button>
    
    // 普通函数
    const btn1 = document.getElementById("myBtn1");
    btn1.addEventListener("click", function () {
        // 指向触发事件的 DOM 元素(myBtn1)
        console.log(this); // <button id="myBtn1">addEventListener 普通函数</button>
    });
    
    // 对象方法
    const obj = {
        handleClick() {
            // 因为本质是将obj.handleClick函数的引用传递给 addEventListener
            // 而不是作为 obj 的方法调用,所以指向触发事件的 DOM 元素(myBtn2)
            console.log(this); // <button id="myBtn2">addEventListener 对象方法</button>
        }
    };
    
    const btn2 = document.getElementById("myBtn2");
    btn2.addEventListener("click", obj.handleClick);
    // 其本质为
    // btn2.addEventListener("click", function () {
    //     console.log(this);
    // });
    
    // 箭头函数
    const btn3 = document.getElementById("myBtn3");
    btn3.addEventListener("click", () => {
        // 指向定义箭头函数时的作用域中的 this(通常是 window)
        console.log(this); // Window
    });
    

二. call、apply、bind(显式绑定)

以上this的指向,都是正常的 JS 基本规则,可以说这些规则就相当于我们现实生活中的物理规律(比如说“苹果会往下掉”)一样。那么“月下三兄贵”callapplybind就是超出常理的存在,和 《JOJO的奇妙冒险》 里面的三位柱之男一样强大,可以扭曲规则,现在让我们学习一下“月下三兄贵”是怎么开挂的。

  • 作用: callapplybindFunction 对象自带的三个方法,也就是说所有的函数(箭头函数例外)都能调用它们三个改变函数执行时的this指向

箭头函数除外:

箭头函数是静态绑定,继承其定义所在上下文的this值。它虽然能够调用这三个方法,但是并不能发挥作用,也就是说不能使用这三个方法来改变this指向,方法的第一个参数(也就是用于绑定this的参数)会被忽略

  • 示例代码:

    // 定义一个箭头函数
    const greetArrow = () => {
        console.log(`你好,我是${this.name}`);
    };
    
    const person = { name: '张三' };
    
    // 使用 call、apply 方法,但 this 不会被改变
    greetArrow.call(person); // 你好,我是undefined(取决于全局 this)
    greetArrow.apply(person); // 你好,我是undefined(取决于全局 this)
    
    // 使用 bind 方法,this 绑定无效
    const greetArrowName = greetArrow.bind(person);
    greetArrowName(); // 你好,我是undefined
    

call()方法

call() 方法可以让函数在指定的 this 值环境下执行,同时还能以参数列表的形式给函数传递参数。

  • 机制: 立即执行函数,并动态修改函数执行时的this绑定。总结就是立即执行 + 动态绑定

  • 示例代码:

    function greet(message, punctuation) {
        console.log(`${message}${this.name}${punctuation}`);
    }
    
    const person = { name: '张三' };
    
    // 直接调用
    greet('你好', '!'); // 你好,undefined!
    // 使用call()方法
    // 第一个参数是 this 指向的对象,后面的参数是传递给函数的参数
    greet.call(person, '你好', '!'); // 你好,张三!
    

apply()方法

apply() 方法和 call() 类似,也能改变函数的 this 指向,不过它传递参数的方式是数组

  • 机制: 立即执行函数,并动态修改函数执行时的this绑定。总结就是:立即执行 + 动态绑定

  • 示例代码:

    function greet(message, punctuation) {
        console.log(`${message}, ${this.name}${punctuation}`);
    }
    
    const person = { name: '张三' };
    // 直接调用
    greet('你好啊', '!'); // 你好啊, undefined!
    // 第一个参数是 this 指向的对象,第二个参数是包含所有参数的数组
    greet.apply(person, ['你好啊', '!']); // 你好啊, 张三!
    

bind()方法

call()apply()方法不同,bind() 方法会创建一个新的函数,在调用时,这个新函数的 this 值会被永久绑定到 bind() 方法的第一个参数上,并且新函数除了继承了旧函数的参数列表结构,还可以预设一些参数(多余参数会被忽略)。

  • 机制: 返回的新函数(称为“绑定函数”),无论绑定函数如何被调用,this 值都不会改变(除非使用 new 调用)。总结就是:延迟执行 + 静态绑定

  • 示例代码:

    // 示例1:绑定this
    function greet(message, punctuation) {
        console.log(`${message}, ${this.name}${punctuation}`);
    }
    
    const person = { name: '张三' };
    // 直接调用
    greet('你好啊', '!'); // 你好啊, undefined!
    // 第一个参数是 this 指向的对象,第二个参数是包含所有参数的数组
    greet.apply(person, ['你好啊', '!']); // 你好啊, 张三!
    
    // 示例2:参数问题
    // 定义一个参数列表结构为三个参数的函数
    function originalAdd(a, b, c) {
        console.log('this:', this);
        console.log('传入参数为:', a, b, c);
        return a + b + c;
    }
    // 调用原函数
    // 结果
    // this: Window
    // 传入参数为: 1 2 3
    // 6
    console.log(originalAdd(1, 2, 3));
    
    // 调用bind()方法,将新函数this置为person,并预设前两个参数分别为1、2
    const add = originalAdd.bind(person, 1, 2);
    // 调用新函数
    // 结果
    // this: {name: '张三'}
    // 传入参数为: 1 2 3
    // 6
    console.log(add(3));
    

三. 绑定的优先级

在开发过程中,我们不可能只使用一种this绑定方式,所以当出现多种this绑定方式冲突时,我们要明白它们之间的优先级,才能够正确分辨出当前函数的this指向。

默认绑定 VS 隐式绑定

二者调用方式互斥(一个直接调用func1(),一个通过对象调用obj.func1()),一个函数不可能同时触发这两个 this 绑定方式,所以这种冲突情况完全不用担心。

  • 而且,一般名字里面带一个“默认”的,其优先级似乎大概率为最低,就像默认绑定的优先级是最低的
  • 优先级: 隐式绑定 > 默认绑定

隐式绑定 VS new 绑定

  • 优先级: new 绑定 > 隐式绑定
  • 示例代码:
// 定义一个对象,包含构造函数
const obj = {
    value: 'obj的value',
    // 使用 function 关键字定义构造函数(重要!)
    Constructor: function (value) {
        this.value = value;
        console.log(this);
    }
};
// 作为对象方法调用,this指向obj
obj.Constructor(10); // { value: 10, Constructor: [Function: Constructor] }

// 使用 new 调用构造函数,this指向对象实例
// 此时Constructor既为对象方法(隐式绑定),又是构造函数(new 绑定)
const instance = new obj.Constructor(10); // Constructor { value: 10 } 对象instance

console.log("instance 是否由 obj.Constructor 创建:", instance instanceof obj.Constructor); // true
// Constructor中的this并未指向obj,而是指向新创建的对象实例
console.log("instance.value:", instance.value); // instance.value: 10
// // 优先级:new 绑定 > 隐式绑定

new 绑定 VS 显式绑定

前面我们介绍显式绑定的bind方法就说了,this虽然永久绑定到 bind() 方法的第一个参数上,但是使用new关键字调用函数的时候还是会改变其this的指向。

  • 优先级: new 绑定 > 显式绑定

  • 示例代码:

    function func(thisArg) {
        this.value = thisArg;
        console.log(this);
    }
    const obj = {
        name: 'obj'
    };
    
    // 显式绑定this到obj,预置参数为 10
    const boundFunc = func.bind(obj, 10);
    // 此时this被绑定到了对象obj上
    boundFunc(); // { name: 'obj', value: 10; }
    
    // 此时this被改变了,绑定到了对象实例instance上
    const instance = new boundFunc(); // func { value: 10 }
    console.log(instance.value); // 10
    

显式绑定 VS 隐式绑定

  • 优先级: 显式绑定 > 隐式修改

  • 示例代码:

    const obj1 = { name: "obj1" };
    const obj2 = { name: "obj2" };
    
    function getThis() {
        return this.name;
    }
    
    // 先隐式绑定到obj1
    obj1.getThis = getThis;
    // 再通过call显式绑定到obj2(显式优先级更高)
    console.log(obj1.getThis.call(obj2)); // obj2
    

优先级汇总

优先级(从大到小):new 绑定 > 显式绑定 > 隐式绑定 > 默认绑定。

结语

至此,我们算是将this的各个方面初步认识了一下,但是this的复杂就复杂在情况多变上,我们只是初步了解了它的概念,真遇到一些复杂的应用场景,可能还是会被弄晕,所以我们不能妄自尊大,还是要谨慎考虑每一个this的指向情况
如果本文有错误和纰漏,欢迎在评论区指正,大家一起进步,感谢大家的支持🙏!!!