面试常见题:手写call/apply/bind

266 阅读7分钟

0、前言

看到一道手写call的题目,搜了一下,发现有很多相关的文章,但多有瑕疵。遂个人整理了一下,会尽量保证功能完整,如有纰漏,望指出。

不想看思(fei)路(hua)的之间跳到2.3小节后面。

1、认识call/apply/bind

首要任务,就是了解call(), apply(), bind()的功能,了解它们分别干了什么事。相信很多人都清楚,这里做个简单说明。

  • 它们三个都是Function原型上的方法,即call()的真身是Function.prototype.call(), 其它类似。任何一个函数(不考虑箭头函数)都是Function实例,你可以通过function f() {}.constructor === Function判断。所以,不难解释每个函数都有call()/apply()/bind()功能。
  • 这三个函数的作用在于函数this的强绑定。call(thisObj, ...args)/apply(thisObj, args)传递一个参数指定运行时的this,并立即执行函数,不同之处在于call()列出原函数的参数列表,而apply()用一个列表元素作为原函数的参数。bind(thisObj)返回一个this被静态绑定的新函数,旧函数不受影响,新函数在运行时总使用绑定的对象作为this

如果对这几个函数不熟悉,可以参考相关的MDN文档

2、一步一步实现call()

作为一个典型例子,让我们一步一步实现并完善与call()等效的myCall()方法,然后迁移到apply()bind()

2.1、让所有方法都能使用mycall()

我们在使用call()的时候,使用方式类似:

func.call(thisObj, arg1, arg2);

对任意函数,上面调用都成立的原因是call()定义在Function.prototype上,所以,仿照这个方式,我们可以写出mycall()的定义:

Function.prototype.myCall = function(){
    /*实现我们的myCall()*/
}
//现在,对任何函数func都能进行myCall()调用
func.myCall()

2.2、关键:绑定this

实现call()的关键在于它的功能:this绑定

myCall()也应该具有这个功能,第一个参数决定了this绑定的对象,使用thisObj表示,方便函数内部使用。

我们知道:在一般函数中,this取决于最后调用函数的对象,即函数调用的.前面的对象。

所以,在myCall()内部,我们需要实现一种转化,把原来的func.myCall(thisObj);转化为thisObj.func();的调用形式,这样,.前面是thisObj, 即实现了我们想要的绑定。

现在,有两个问题:

  1. thisObj.func()说明func需要成为thisObj的方法。
  2. 在函数内部需要获取调用myCall()的函数。在这个例子中, 它是func, 但你要知道,这只是个形式上的函数名称而已,在实际上,它可能是任何一个函数。

第一个问题很简单,我们在thisObj上增加一个属性,让它指向func即可。

第二个问题是这道题的突破点。不在于难,而是很隐秘。在运行时,任何一个函数都可以调用myCall(),所以,我们必须找到一个可以获取到具体函数的方法,也就是**.前面的**函数。

加粗的“.前面” 能让你想到谁?this!!!

tihs总是指向函数调用时.前面的对象,JS中,函数也是对象!

故,对任意函数func, 进行func.myCall()的调用,在myCall()内部,都有this === func

现在,问题都解决了。让我们尝试整理一下目前工作,实现this绑定:

/*myCall() 1.0*/
Function.prototype.myCall = function(thisObj){
    thisObj.func = this;   //添加一个属性指向发起调用的函数
    thisObj.func();       //使用thisObj调用原函数,实现this绑定
}

做个简单的测试:

const obj = {
    name: "czpcalm"
};
function logName() {
    console.log(this.name);
}
logName.myCall(obj);   //czpcalm

It works!

2.3、完善myCall()

任何人都能看得出来,目前的myCall()是如此脆弱, 有两个明显的问题:

  1. 不能调用有参数的函数。
  2. 对有返回值的函数不能返回相同的结果。

这两个问题十分简单,我直接给出解决以上问题的2.0版本:

/*myCall() 2.0*/
Function.prototype.myCall = function(thisObj, ...args){
    thisObj.func = this;   //添加一个属性指向发起调用的函数
    return thisObj.func(...args);       //使用thisObj调用原函数,实现this绑定
}

现在,它们对有参数的函数调用和返回值都正常了。

但是,它任然存在问题。

一个明显的地方是,经过此次调用,thisObj多了一个func属性,这导致了前后对象的不一致性。我们需要在调用结束后使用delete thisObj.func消除副作用。

等等,如果thisObj本来就有func属性呢?它会在一开始被我们覆盖,随后我们删除func属性,原来的属性也随之消失,这也是一种不一致性,它发生在我们使用的属性名func(或者其它任何名称)与thisObj自身属性重合的情况,虽然概率不大,但完全可能,我们无法预测。好在,这种JS对这种情况已经给出了解决方案,使用Symbol属性

myCall() 3.0版本,我们避免这两个可能对thisObj造成副作用的情况:

/*myCall() 3.0*/
Function.prototype.myCall = function (thisObj, ...args) {
    const func = Symbol();  //使用Symbol防止覆盖原有属性
    thisObj[func] = this; //添加一个属性指向发起调用的函数
    const result = thisObj[func](...args); //使用thisObj调用原函数,实现this绑定,并保存结果
    delete thisObj[func];  //删除属性
    return result;   //返回原函数的调用结果
}

这样,从功能上来看,它总是能返回正确的结果,从外部看来,也不会对对象产生任何影响。

回头来看,它是否存在健壮性的问题?我们说是的,它假设thisObj是一个对象,这种情况一定成立吗?考虑这种调用:

func.myCall(null,arg1,args2);   //报错:Cannot set property 'func' of null
func.myCall(2, arg1,arg2);     //报错:thisObj[func] is not a function

在我们试图在null原始值中添加属性时,是不会成功的。所以我们必须对thisObj做出合理性判断,提高容错处理。

这个处理很多文章都有分歧,为了与call()保持一致,我们参考call()的解决方案:

如果这个函数处于非严格模式下,则指定为 nullundefined 时会自动替换为指向全局对象,原始值会被包装。(MDN)

最后,得到如下的4.0最终版本:

/*myCall() 4.0*/
Function.prototype.myCall = function (thisObj, ...args) {
    if (thisObj == null) {
        thisObj = globalThis; //null或者undefined为全局对象
    } else if (typeof thisObj !== "object" || typeof thisObj !== "function") {
        thisObj = Object(thisObj); //基本类型使用Object包装
    }
    const func = Symbol();
    thisObj[func] = this; //添加一个属性指向发起调用的函数
    const result = thisObj[func](...args); //使用thisObj调用原函数,实现this绑定,并保存结果
    delete thisObj[func];  //删除属性
    return result;   //返回原函数的调用结果
}

3、实现apply()

有了上面的myCall(),我们可以很快得到myApply()的做法:

Function.prototype.myApply = function (thisObj, args) {   //通过数组或类数组传递
    if (thisObj == null) {
        thisObj = globalThis; 
    } else if (typeof thisObj !== "object" || typeof thisObj !== "function") {
        thisObj = Object(thisObj); 
    }
    args = args||[];      //处理参数为null或undefined
    args = Array.from(args);   //处理类数组对象,它们不能使用...
    const func = Symbol();
    thisObj[func] = this; 
    const result = thisObj[func](...args); 
    delete thisObj[func];  
    return result;  
}

上面的需要注意的点都在注释中标出了,其它行与myCall()无异。

4、实现bind()

bind(thisArg[, arg1[, arg2[, ...]]])比之前的函数要复杂,让我们来看看它的功能:

bind() 方法创建一个新的函数,在 bind() 被调用时,这个新函数的 this 被指定为 bind() 的第一个参数,而其余参数将作为新函数的参数,供调用时使用

同样的,它有几个需要注意的点:

  1. 参数列表第一个为绑定的this对象,后面为执行时的一部分函数(偏函数知识)。
  2. thisArgnullundefined时,使用运行时的this
  3. thisArg为基本值时,使用包装对象。
  4. 新函数支持new,且优先级更高,这个时候thisArg被忽略。

基于以上,实现如下:

Function.prototype.myBind = function (thisArg, ...args) {
    const func = this;
    let newFunc = function (...secondArgs) {
        let context = null;
        if (this instanceof newFunc) context = this; //判断是否用new调用
        else context = thisArg || this; //处理thisArg为null或undefined
        return func.call(context, ...args, ...secondArgs);
    }
    if (newFunc.prototype) { //排除箭头函数没有prototype
        newFunc.prototype = Object.create(func.prototype); //使原型链完整,保证new正确
    }
    return newFunc;
}