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

2,172 阅读10分钟

前言

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

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

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

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

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

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

青铜段位

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)
    }
}

这个段位的代码,只实现了 call、apply、bind 调用函数的能力,而且 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 数组转为用逗号分隔的参数序列,再传给调用函数。

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

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

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

黄金段位

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

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

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

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

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

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

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

从上述可以得知当函数作为对象的方法调用时,this 指向该对象。那么只要把一个函数赋值给一个对象的属性,例如把函数 sayColor 赋值给 obj.fn,函数 sayColor就变成对象 obj 的一个方法,执行 obj.fn() 相当调用函数 sayColor ,此时函数 sayColor 的 this 指向对象 obj。如果再把函数 sayColor 赋值给 target.fn,执行 target.fn() 时,函数 sayColor 的 this 就变成指向对象 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 指向的能力

铂金段位

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

在调用 call、apply、bind 时,若其第一个参数不传值时,就把 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)
    }
}

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

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

砖石段位

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

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

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

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

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

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

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

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

那么在严格模式下 isStrict 为 true 。

又因为 call、apply、bind 是在 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 来解决。

  • 在非严格模式下,当参数 thisArg 为 null 或 undefined 没必要特意把 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。

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

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

总结

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

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

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