重写 Polyfill 之 call、apply、bind

2,574 阅读5分钟

开篇

关于callapply的用处,最多的是改变this的指向。同时也是高频的面试题,所以这个话题可不容忽视。

那就拿MDN上的栗子来说明,来看看callapply的源码到底是怎么实现的。

call的栗子🌰:

function Product(name, price) {
  this.name = name;
  this.price = price;
}

function Food(name, price) {
  Product.call(this, name, price);
  this.category = 'food';
}

console.log(new Food('cheese', 5).name);

通常在看callbind的写法的时候,我通常把“调用方”称为“甲方”,“执行方”称为“乙方”

callpolyfill

Function.prototype.call = function (thisArg) { 
    // thisArg是调用call的时候参数中的第一个参数,也就是Food的实例对象
    
    // 先判断当前的甲方是不是一个函数(this就是Product,判断Product是不是一个函数)
    if (typeof this !== 'function') {
        throw new TypeError('当前调用call方法的不是函数!')
    }
    
    // 保存甲方给的参数(args就是 ['cheese', 5])
    const args = [...arguments].slice(1)
    
    // 确定乙方的类型,因为可以传null和undefined(thisArg就是Food的实例对象)
    thisArg = thisArg || window
    
    // 将甲方的内容保存为乙方的一个属性,为了保证不与乙方中的key键名重复
    const fn = Symbol('fn')
    
    // 这个时候的thisArg就是
    // {
    //    category: 'food',
    //    Symbol(fn): function() { this.name = name, this.price = price }    
    // }
    thisArg[fn] = this
    
    // 执行保存的函数,这个时候作用域就是在乙方的对象的作用域下执行,改变的this的指向
    const result = thisArg[fn](...args)
    
    // 执行完删除刚才新增的属性值
    delete thisArg[fn]
    
    // 返回执行结果
    return result
}

那由于callapply的区别就在于传参的方式不同:

fnA.call(objB, 'args1', 'args2', 'args3')
fnA.apply(objB, ['args1', 'args2', 'args3'])
  • call 是参数形式是散列的形式
  • apply的参数是一个数组

所以我们也能想到在applypolyfill中,区别就在于获取参数的部分。


apply的栗子🌰:

const numbers = [5, 6, 2, 3, 7];

const max = Math.max.apply(null, numbers);

console.log(max); // 7

const min = Math.min.apply(null, numbers);

console.log(min); // 2

applypolyfill

Function.prototype.call = function (thisArg) { 
    if (typeof this !== 'function') {
        throw new TypeError('当前调用apply方法的不是函数!')
    }
    
    // 此处与call有区别
    const args = arguments[1]
    
    thisArg = thisArg || window
    
    const fn = Symbol('fn')

    thisArg[fn] = this
    
    const result = thisArg[fn](...args)
    
    delete thisArg[fn]
    
    return result
}

但是我们回过头看MDN上给的apply的例子,其实是很巧妙的将某些本来需要写成遍历数组变量的任务中使用内建的函数,即使我们我们也可以用点点点的方式,hahaha~O(∩_∩)O,多一种技能嘛~


bindpolyfill

关于bind,我们先不从栗子直接下手,先来看一下MDN上的解释:

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

有几个关键点:

  • 创建一个新的函数
  • bind被调用时,新函数的thisbind的第一个参数指定

由此可以知道,bind方法返回的是一个函数,所以先搭起基本架子:

if (!Function.prototype.bind) {
    Function.prototype.bind = function (oThis) {
        // 同样先判断甲方是不是一个函数
        if (typeof this !== 'function') {
            throw new TypeError('当前调用bind方法的不是函数!')
        }
        // 第一步
        var fBound = function () {}
        
        return fBound
    }
}

我们不妨把函数fBound叫做绑定函数,那么绑定里具体是做什么呢?

我们知道最后把绑定返回出去就是要调用的,调用的目的就是要改变this的指向。

所以绑定函数中做的就是改变this指向:

if (!Function.prototype.bind) {
    Function.prototype.bind = function (oThis) {
        // 同样先判断甲方是不是一个函数
        if (typeof this !== 'function') {
            throw new TypeError('当前调用bind方法的不是函数!')
        }
        
        // 获取参数
        var args = [...arguments].slice(1)
        // 第二步
        var fBound = function () {
            this.apply(oThis, args)
        }
        
        return fBound
    }
}

那考虑到绑定函数执行的时候还会有参数传进来,所以要把原来的参数和新传进来的参数拼接起来,继续改造绑定函数:

if (!Function.prototype.bind) {
    Function.prototype.bind = function (oThis) {
        // 同样先判断甲方是不是一个函数
        if (typeof this !== 'function') {
            throw new TypeError('当前调用bind方法的不是函数!')
        }
        
        // 获取参数
        var args = Array.prototype.slice.call(arguments, 1)
        // 第三步
        var fToBind = this
        var fBound = function () {
            return fToBind.apply(oThis, args.concat(Array.prototype.slice.call(arguments)))
        }
        
        return fBound
    }
}

现在,我们已经成功的修改了this的指向,用一个简单的例子测一下:

const bar = function() {
  console.log(this.name, arguments);
};
bar.prototype.name = 'bar';
const foo = {
  name: 'foo'
};
const bound = bar.bind(foo, 22, 33, 44);
bound(55); // foo, [22, 33, 44, 55]

那我们知道函数可以被直接调用,函数也可以放在关键字 new 后面成为一个构造函数,上面我们是完成了直接调用的情况,下面我们再继续考虑放new`后面的情况

首先修改绑定函数中的逻辑, 通过判断当前this是否是属于绑定函数的实例来动态判断乙方

修改如下:

if (!Function.prototype.bind) {
    Function.prototype.bind = function (oThis) {
        // 同样先判断甲方是不是一个函数
        if (typeof this !== 'function') {
            throw new TypeError('当前调用bind方法的不是函数!')
        }
        
        // 获取参数
        var args = Array.prototype.slice.call(arguments, 1)
        var fToBind = this
        // 第四步
        var fBound = function () {
            return fToBind.apply(this instanceof fBound
            ? this
            : oThis, args.concat(Array.prototype.slice.call(arguments)))
        }
        
        return fBound
    }
}

其次是要维护好原型链:

if (!Function.prototype.bind) {
    Function.prototype.bind = function (oThis) {
        // 同样先判断甲方是不是一个函数
        if (typeof this !== 'function') {
            throw new TypeError('当前调用bind方法的不是函数!')
        }
        
        // 获取参数
        var args = Array.prototype.slice.call(arguments, 1)
        var fToBind = this
        var fBound = function () {
            return fToBind.apply(this instanceof fBound
            ? this
            : oThis, args.concat(Array.prototype.slice.call(arguments)))
        }
        
        // 第五步
        var fNOP = function() {}
        
        if (this.prototype) {
            fNOP.prototype = this.prototype;
        }
        // 下行的代码使fBound.prototype是fNOP的实例,因此返回的fBound若作为new的构造函数
        // new生成的新对象作为this传入fBound,新对象的__proto__就是fNOP的实例
        fBound.prototype = new fNOP();
        
        return fBound
    }
}

至此,我们就完成了bind方法的基础实现,同样还是测试一下:

const bar = function() {
  console.log(this.name, arguments);
};
bar.prototype.name = 'bar';
const foo = {
  name: 'foo'
};
const bound = bar.bind(foo, 22, 33, 44);
bound(55); // foo, [22, 33, 44, 55]
new bound(55, 66); // bar, [22, 33, 44, 55, 66]

当然中间内容还涉及到一些其他知识点,比如面试高频问题:new的实现原理等等,查缺补漏,走在革命的道路上。

结尾插话

哪有什么岁月静好,只不过是有人在替你负重前行