『面试的底气』—— 手写call、apply、bind

2,032 阅读10分钟

前言

在初级前端面试中,只会问 callapplybind 的概念和用法。在中高级前端的面试中,不会问这么低级的问题,一般会叫你手写 callapplybind ,这样即考察了你对 callapplybind 的概念和用法的掌握程度,也考察了你对 Javascript 的掌握程度。

其实 callapplybind 的内部实现逻辑大体一致,只是对于 callapplybind 之间不同的用法做了对应的逻辑处理,那么只要会手写其中一个方法,其余两个也就会了。

这里只提一下 callapplybind 三者之间的不同,至于其详细的概念和用法可以看这篇专栏

  • 第二个参数不同,callbind 接收一个参数列表,但 apply 不一样,接收一个包含多个参数的数组。

  • 执行返回不同,callapply 返回的是调用函数执行后的值,bind 返回的是函数需要再次调用。

本专栏将从青铜到王者来介绍怎么手写 callapplybind ,以及每个段位对应的能力。

青铜段位

Function.prototype.myCall = function(target,params){
    this(params);
}
Function.prototype.myApply = function(target,params){
    this(params);
}
Function.prototype.myBind = function(target,params){
    return function (){
        this.(params)
    }
}

这个段位的代码,只实现了 callapplybind 调用函数的能力,而且 call 从第二个参数开始可以接收很多参数,这里只接收了一个。还有 apply 第二个参数是个数组,怎么可以直接传给调用的函数。 这明显不合格,得多打排位。

白银段位

Function.prototype.myCall = function() {
    const params = [...arguments]
    const args = params.slice(1)
    this(...args)
}
Function.prototype.myApply = function() {
    const params = [...arguments]
    const thisArg = params[0];
    const args = params[1];
    args? thisArg.fn(...args) : thisArg.fn()
}
Function.prototype.myBind = function(){
    const params = [...arguments]
    const args = params.slice(1)
    const _this = this;
    return function (){
        _this(...args)
    }
}

利用 arguments 来处理函数实参的数量无法固定的场景。arguments 是个类数组,用 ES6 中扩展运算符...arguments 转成真正的数组赋值给常量 params。经过处理把 params 中属于传给调用函数的参数集合赋值给常量 args,因为 args 是个数组,故又使用 ES6 中扩展运算符 ...,把 args 数组转为用逗号分隔的参数序列,再传给调用函数。

但是还没实现 callapplybind 最重要的功能改变调用函数执行时的 this 指向。还是得继续打排位。

虽然代码逻辑有错误,但是在这里还是可以向面试官展示你的三个编程能力。

  • 对函数内部 arguments 对象的了解和应用。
  • ES6 扩展运算符的应用。
  • 如何处理函数的参数不固定的能力

黄金段位

怎么改变函数调用时的 this 指向呢?首先要了解 this 指向哪里了。

在 JavaScript 中函数调用的形式有普通函数、对象的方法、构造函数、箭头函数这几种。其中箭头函数是 ES6 定义,其 this 指向定义箭头函数的上下文,使用 ES5 定义 callapplybind 也无法改变其指向问题,这里先不管箭头函数的调用了。函数的每种调用的形式对应的 this 指向也不同。

  • 普通函数this指向全局对象,对于浏览器而言则是 window 对象。
var color = 'red';
function sayColor() {
   console.log(this.color)
}
sayColor();

在这个例子中,sayColor 函数调用时,this 指向 window,那么在 sayColor函数中 this.color 相当 window.colorvar color = 'red' 定义 window.colorred。故控制台会打印出 red

  • 对象的方法this 指向该对象,此时便可以通过 this 访问对象的其他成员变量或方法。
var obj = {
    color = 'green'
}
function sayColor() {
    console.log(this.color)
}
obj.sayColor();

在这个例子中,sayColor 是作为对象 obj 的方法调用,那么 this 就是对象 objthis.color 相当 obj.color,故 sayColor 函数执行 console.log(this.color),会在控制台会打印出 green

  • 构造函数:作为普通函数调用时,this 指向全局对象,也就是 window。用 new 来调用,this 指向构造函数实例化对象。

从上述可以得知当函数作为对象的方法调用时,this 指向该对象。那么只要把一个函数赋值给一个对象的属性,例如把函数 sayColor 赋值给 obj.fn,函数 sayColor就变成对象 obj 的一个方法,执行 obj.fn() 相当调用函数 sayColor ,此时函数 sayColorthis 指向对象 obj。如果再把函数 sayColor 赋值给 target.fn,执行 target.fn() 时,函数 sayColorthis 就变成指向对象 target,这样就能起到改变 this 的作用。所以代码可以这么实现。

Function.prototype.myCall = function() {
    const params = [...arguments]
    const thisArg = params[0];
    thisArg.fn = this;
    const args = params.slice(1)
    thisArg.fn(...args)
}
Function.prototype.myApply = function() {
    const params = [...arguments]
    const thisArg = params[0];
    thisArg.fn = this;
    const args = params[1];
    args? thisArg.fn(...args) : thisArg.fn()
}
Function.prototype.myBind = function() {
    const params = [...arguments]
    const thisArg = params[0];
    thisArg.fn = this;
    const args = params.slice(1)
    return function() {
        thisArg.fn(...args)
    }
}

在这里可以向面试官展示你的三个编程能力。

  • this 概念的了解
  • 对函数调用形式及调用时 this 指向的了解
  • 如何改变函数调用时 this 指向的能力

铂金段位

上面段位代码虽然实现了 callapplybind 的基本功能,但是未考虑不传参数的场景。

在调用 callapplybind 时,若其第一个参数不传值时,就把 this 要指向全局对象。用 (function(){return this})() 来获取当前环境的全局对象。

Function.prototype.myCall = function() {
    const params = [...arguments]
    let thisArg = params[0];
    if(!thisArg){
        thisArg =(function(){return this})()
    }
    thisArg.fn = this;
    const args = params.slice(1)
    thisArg.fn(...args)
}
Function.prototype.myApply = function() {
    const params = [...arguments]
    let thisArg = params[0];
    if(!thisArg){
        thisArg =(function(){return this})()
    }
    thisArg.fn = this;
    const args = params[1];
    args? thisArg.fn(...args) : thisArg.fn()
}
Function.prototype.myBind = function() {
    const params = [...arguments]
    let thisArg = params[0];
    if(!thisArg){
        thisArg =(function(){return this})()
    }
    thisArg.fn = this;
    const args = params.slice(1)
    return function() {
        thisArg.fn(...args)
    }
}

在这里可以向面试官展示你的两个编程能力。

  • 考虑问题的全面性
  • 如何根据当前环境来获取全局对象的能力

砖石段位

铂金段位中的代码,看起来已经完全实现了 callapplybind 方法功能。但是在这个段位应该深入去了解了 callapplybind 的用法,具体可以去 MDN 上查看。

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

在 MDN 中介绍了参数 thisArg 的定义

在 function 函数运行时使用的 this 值。请注意,this可能不是该方法看到的实际值:如果这个函数处于非严格模式下,则指定为 null 或 undefined 时会自动替换为指向全局对象,原始值会被包装。

从上述的定义中。可以得知,callapplybind 内部中有以下一些逻辑:

  • 当参数 thisArg 值为 nullundefined时,在非严格模式下,才会把 thisArg 指向全局对象。
  • 当参数 thisArg 是原始值时,在非严格模式下,才会去调用包装函数包装参数 thisArg
  • 在严格模式下,若参数 thisArg 值为 nullundefined,则直接把指定的参数列表 arg...传入调用函数执行。

严格模式下,有个很明显的特征就是 thisundefined。故可以用以下逻辑来判断是不是严格模式。

let isStrict = (function(){return this === undefined}())

那么在严格模式下 isStricttrue

又因为 callapplybind 是在 ES6之前定义的。故其原始值只有 Null、Undefined、Boolean、Number、String 这五种。

此外只有 Boolean、Number、String 这三种原始值有包装函数,分别是 new Boolean()new Boolean()

那么具体实现代码如下:

Function.prototype.myCall = function() {
    const params = [...arguments];
    const args = params.slice(1);
    let thisArg = params[0];
    let isStrict = (function() {
        return this === undefined
    }())
    if (!isStrict) {
        if (thisArg === undefined || thisArg === null) {
            thisArg = (function() {  return this })()
        } else {
            const thisArgType = typeof thisArg
            if (thisArgType === 'number') {
                thisArg = new Number(thisArg)
            } else if (thisArgType === 'string') {
                thisArg = new String(thisArg)
            } else if (thisArgType === 'boolean') {
                thisArg = new Boolean(thisArg)
            }
        }
    }
    if (thisArg === undefined || thisArg === null) {
        return this(...args)
    }
    thisArg.fn = this;
    thisArg.fn(...args)
}
Function.prototype.myApply = function() {
    const params = [...arguments];
    const args = params[1];
    let thisArg = params[0];
    let isStrict = (function() {
        return this === undefined
    }())
    if (!isStrict) {
        if (thisArg === undefined || thisArg === null) {
            thisArg = (function() { return this })()
        } else {
            const thisArgType = typeof thisArg
            if (thisArgType === 'number') {
                thisArg = new Number(thisArg)
            } else if (thisArgType === 'string') {
                thisArg = new String(thisArg)
            } else if (thisArgType === 'boolean') {
                thisArg = new Boolean(thisArg)
            }
        }
    }
    if (thisArg === undefined || thisArg === null) {
        return this(...args)
    }
    thisArg.fn = this;
    args? thisArg.fn(...args) : thisArg.fn()
}
Function.prototype.myBind = function() {
    const params = [...arguments];
    const args = params.slice(1);
    let thisArg = params[0];
    let isStrict = (function() {
        return this === undefined
    }())
    if (!isStrict) {
        if (thisArg === undefined || thisArg === null) {
            thisArg = (function() { return this })()
        } else {
            const thisArgType = typeof thisArg
            if (thisArgType === 'number') {
                thisArg = new Number(thisArg)
            } else if (thisArgType === 'string') {
                thisArg = new String(thisArg)
            } else if (thisArgType === 'boolean') {
                thisArg = new Boolean(thisArg)
            }
        }
    }
    if (thisArg === undefined || thisArg === null) {
        const _this = this;
        return function() {
            _this(...args)
        }
    }
    thisArg.fn = this;
    return function() {
        thisArg.fn(...args)
    }
}

在这里可以向面试官展示你的三个编程能力。

  • 严格模式和非严格模式的判别能力
  • 了解原始值及其定义时间
  • 如何包装函数的能力

星耀段位

上述段位的代码,已经很完整了,但是还要有两个可以优化的地方。

  • 如果参数 thisArg 原本有 fn 这个属性,那执行 thisArg.fn = this; 是不是把属性 fn 原先的值覆盖。在 ES6 中可以用 Symbol 来解决。

  • 在非严格模式下,当参数 thisArgnullundefined 没必要特意把 thisArg 执行全局对象,因为此时被调用函数当作普通函数的形式调用,return this(...args),那么被调用函数的 this 自然指向全局对象。

Function.prototype.myCall = function() {
    // 通过arugments对象,我们能拿到所有实参
    const params = [...arguments];
    const args = params.slice(1);
    let thisArg = params[0];
    // 判断是不是严格模式
    let isStrict = (function() {
        return this === undefined
    }())
    if (!isStrict) {
    	// 如果是其他原始值,需要通过构造函数包装成对象
        const thisArgType = typeof thisArg
        if (thisArgType === 'number') {
            thisArg = new Number(thisArg)
        } else if (thisArgType === 'string') {
            thisArg = new String(thisArg)
        } else if (thisArgType === 'boolean') {
            thisArg = new Boolean(thisArg)
        }
    }
    // 第一参数值为undefined或null,被调用函数按普通函数形式调用
    if (thisArg === undefined || thisArg === null) {
        return this(...args)
    }
    // 创建一个全局唯一属性 fn
    const fn = Symbol(thisArg)
    // 改变被调用函数的 this 指向到 thisArg 上 
    thisArg[fn] = this;
    // 返回目标函数执行的结果
    thisArg[fn](...args)
}
Function.prototype.myApply = function() {
    const params = [...arguments];
    const args = params[1];
    let thisArg = params[0];
    let isStrict = (function() {
        return this === undefined
    }())
    if (!isStrict) {
        const thisArgType = typeof thisArg
        if (thisArgType === 'number') {
            thisArg = new Number(thisArg)
        } else if (thisArgType === 'string') {
            thisArg = new String(thisArg)
        } else if (thisArgType === 'boolean') {
            thisArg = new Boolean(thisArg)
        }
    }
    if (thisArg === undefined || thisArg === null) {
        return args ? this(...args) : this()
    }
    const fn = Symbol(thisArg)
    thisArg[fn] = this;
    args ? thisArg[fn](...args) : thisArg[fn]()

}
Function.prototype.myBind = function() {
    const params = [...arguments];
    const args = params.slice(1);
    let thisArg = params[0];
    let isStrict = (function() {
        return this === undefined
    }())
    if (!isStrict) {
        const thisArgType = typeof thisArg
        if (thisArgType === 'number') {
            thisArg = new Number(thisArg)
        } else if (thisArgType === 'string') {
            thisArg = new String(thisArg)
        } else if (thisArgType === 'boolean') {
            thisArg = new Boolean(thisArg)
        }
    }
    if (thisArg === undefined || thisArg === null) {
        const _this = this;
        return function() {
            _this(...args)
        }
    }
    const fn = Symbol(thisArg)
    thisArg[fn]= this;
    return function() {
        thisArg[fn](...args)
    }
}

在这里可以向面试官展示你的两个编程能力。

  • 代码逻辑的严谨性
  • 了解 ES6 中 Symbol 的概念和应用

王者段位

在上述段位代码中,对参数的处理用到 ES6 的扩展运算符 ...,若参数 thisArg 上属性 fn 已定义后防止被覆盖用到 ES6 的 Symbol。

但是 callapplybind 都是在 ES5 中定义的,那么不要用 ES6 的方法,怎么实现呢。

这里就不做介绍,有兴趣可以思考以下,在评论中,留下答案,王者属于你。

总结

综上所述,面试官叫你手写callapplybind,其实是要考察你各方面的能力。例如

  • 白银段位
    • 对函数内部 arguments 对象的了解和应用。
    • ES6 扩展运算符的应用。
    • 如何处理函数的参数不固定的能力
  • 黄金段位
    • this 概念的了解
    • 对函数调用形式及调用时 this 指向的了解
    • 如何改变函数调用时 this 指向的能力
  • 铂金段位
    • 考虑问题的全面性
    • 如何根据当前环境来获取全局对象的能力
  • 砖石段位
    • 严格模式和非严格模式的判别能力
    • 了解原始值及其定义时间
    • 如何包装函数的能力
  • 星耀段位
    • 代码逻辑的严谨性
    • 了解 ES6 中 Symbol 的概念和应用

所以不要去死记硬背一些手写代码的面试题,最好自己动手写一下,看看自己达到那个段位了。