探秘 JavaScript this 指向:从零手写 call、apply、bind 方法全攻略

144 阅读4分钟

浅显的理解 this 指向

在 JavaScript 中,this 的指向是一个较为复杂但关键的概念,它与函数的执行上下文密切相关,决定了函数内部 this 所引用的对象。通过以下示例代码可以观察不同调用方式下 this 的指向情况:

function self() {
    console.log('self-this', this);
}

const selfObj = {
    self: self,
    name: "selfObj"
}

selfObj.deepSelf = {
    self: self,
    name: "deepSelf"
}

self(); // 输出: self-this Window 
selfObj.self(); // self-this {name: 'selfObj', deepSelf: {…}, self: ƒ}
selfObj.deepSelf.self(); // self-this {name: 'deepSelf', self: ƒ}

对上面示例代码输出描述:

  1. 在独立函数调用时,非严格模式下 this 默认绑定到全局对象(浏览器中为 window,Node.js 中为 global),在严格模式下会绑定为 undefined;
  2. 函数作为对象的方法被调用时,this 指向调用该方法的对象
  3. 在对象嵌套结构中作为内层对象的方法调用时,this 指向对应的内层对象

从上述代码运行结果可知,普通函数的 this 指向取决于其执行时的上下文。基于此,若要改变函数的 this 指向,我们可以采用让目标对象新增属性并赋值为当前函数,再通过该对象去调用函数的方式来实现,这也为我们手写 callapply 和 bind 方法提供了思路。

由于这三个方法是供函数调用以改变 this 指向的,所以我们需要将它们挂载到 Function.prototype 上,这样所有的函数都能够使用这些方法。 以下是查看在自定义方法内如何获取当前函数的示例代码:

Function.prototype.likeCall=function(){
    console.log("likeCall-this", this);
}

function selfCall() {}

selfCall.likeCall(); // likeCall-this ƒ selfCall() {}

在具体函数调用 likeCall 方法时,likeCall 方法内部的 this 就是当前函数

手写 call 的实现

call 方法允许我们显式地指定函数内部 this 的指向,并传入相应的参数来调用函数,参数是以逗号分隔逐个传递的(参数平铺的形式)。以下是 call 方法的手写实现代码及详细解析:

Function.prototype.likeCall = function (ctx, ...args) {
    // 处理 ctx 为 null 或 undefined 的情况,将 ctx 指向全局对象(利用 globalThis 关键字,它会根据当前运行环境自动获取全局对象,在浏览器中是 window,在 Node.js 中是 global)
    // Object()处理 ctx 原始类型时返回改原始类型的包装对象
    ctx = ctx === null || ctx === undefined ? globalThis : Object(ctx);
    
    // 使用 Symbol 创建一个唯一的属性名,避免与目标对象原有的属性名冲突
    const key = Symbol();
    
    // 通过 Object.defineProperty 定义该属性,将函数(即当前的 this)赋值给该属性,同时设置为不可枚举,这样在外部直接打印 ctx 对象时,该属性不会显示出来,避免造成干扰
    Object.defineProperty(ctx, key, { value: this, enumerable: false });
    
    try {
        // 通过新增的属性来调用函数,并传入参数,获取函数的执行结果
        const result = ctx[key](...(args || []));
        return result;
    } finally {
        // 无论函数执行是否成功,最后都要删除新增的属性,保证目标对象的原始状态不受影响
        delete ctx[key];
    }
}

手写 aplly 的实现

apply 方法与 call 方法类似,都用于改变函数的 this 指向并调用函数,但参数传递方式有所不同,apply 方法将参数整合为一个数组进行统一传递”。以下是 apply 方法的手写实现代码及解析:

Function.prototype.likeApply = function (ctx, args) {
    ctx = ctx === null || ctx === undefined ? globalThis : Object(ctx);
    const key = Symbol();
    Object.defineProperty(ctx, key, { value: this, enumerable: false });
    try {
        // 调用函数,注意这里需要将参数数组(类数组)展开传递给函数(通过...args 语法)
        const result = ctx[key](...(args || []));
        return result;
    } finally {
        // 调用完成后删除新增的属性
        delete ctx[key];
    }
}

手写 bind 实现

bind 方法与 callapply 的最大不同在于它不会立即调用函数,而是返回一个新的函数,这个新函数在被调用时,其内部的 this 指向已经被绑定到指定的对象上,并且可以继续传入新的参数。 此外,还有一个重要的特性,当使用 new 关键字来调用 bind 返回的新函数时,它的行为会发生改变,此时会返回原始函数(最初调用 bind 的那个函数)的实例化对象,这一机制符合 JavaScript 中构造函数结合 new 操作符创建实例的语义规则 以下是 bind 方法的手写实现代码及解析:

Function.prototype.likeBind = function (ctx, ...args) {
    const self = this;
    return function (...args2) {
        if(new.target){
            return new self(...(args || []), ...(args2 || []));
        }
        ctx = ctx === null || ctx === undefined ? globalThis : Object(ctx);
        const key = Symbol();
        Object.defineProperty(ctx, key, { value: self, enumerable: false });
        try {
            // 调用原始函数,合并传入的两组参数(第一次调用 bind 时传入的参数和后续新函数调用时传入的参数)
            const result = ctx[key](...(args || []), ...(args2 || []));
            return result;
        } finally {
            // 调用完成后删除新增的属性
            delete ctx[key];
        }
    }
}

感谢阅读,敬请斧正!