说清楚JavaScript中bind的实现

285 阅读7分钟

原文链接<说清楚JavaScript中bind的实现>

网上有很多bind模拟实现的文章, 但是偏偏在作为构造函数的实现方法上说得都不太清楚, 这篇笔记循序渐进从零开始, 尝试说清楚bind的原理和实现.


先简单分析bind的用法: bind会返回一个函数, 而并不会像applycall改变this的绑定后直接执行.

绑定后的函数可以作为构造函数用new运算符生成原函数的实例.

简单实现

先看个最简单的实现方法.

最简单的实现

Function.prototype.easyBind = function(context) {
    // 假如是 foo 这个方法调用了 easyBind, 那么这时候 this 就是 foo;
    // 用闭包保留这时候的 this, 放在变量 _foo 里;
    var _foo = this;

    return function() {
        // 当调用 easyBind 后的方法, 实际上这时候是 foo.apply(context);
        _foo.apply(context);
    }
}

最简单的版本只是利用闭包记录原函数, 在调用时再'拿出来'.

可传参的版本

最简单的版本没有传参的方法, 要实现传参用ES6的扩展运算符说明起来非常简单.

ES6扩展运算符实现方式

Function.prototype.es6Bind = function(context, ...args1){
    const _foo = this;
    return function(...args2) {
        _foo.call(context, ...args1, ...args2);
    }
}

再看看用兼容的实现方法.

可传参的传统实现方式

Function.prototype.tdBind = function(context){
    var _foo = this;
    // 因为第一个参数是 context, 所以要跳过, 直接取第二个以后的参数.
    var args1 = Array.prototype.slice.call(arguments, 1);
    return function(){
        // 利用 slice 会返回新数组, 把 arguments 从类数组变成新数组;
        // 相当于 ES6 的 [...arguments];
        var args2 = Array.prototype.slice.call(arguments);
        // 合并 args1 和 args2;
        var args = args1.concat(args2);
        _foo.apply(context, args);
    }
}

ES6扩展运算符的方法在处理参数时候非常好理解, 但是为什么要用Array.prototype.slice.call(arguments)而不直接使用arguments.slice()?

这是因为arguments是一个类数组对象, 并不是真正的数组, 没有数组独有的slice方法.

当一个对象拥有length属性和序列索引, 这个对象即可看作是类数组对象, 举个🌰:

// 普通数组
var array = ['a', 'b', 'c'];

// 类数组
var arrayLike = {
    0: 'a',
    1: 'b',
    2: 'c',
    length: 3
}

因此如果想要操作argument这个类数组就得借用Array.prototype.slice.call()方法.

作为构造函数的情况

绑定后的函数也是函数, 既然是函数, 就可以作为构造函数并使用new操作符创建对象.

先看看MDN对bind作为构造函数使用的定义:

绑定函数自动适应于使用 new 操作符去构造一个由目标函数创建的新实例。当一个绑定函数是用来构建一个值的,原来提供的 this 就会被忽略。不过提供的参数列表仍然会插入到构造函数调用时的参数列表之前。 - Function.prototype.bind()

举个🌰:

var bar = {
    name: 'bar'
};

function foo(first, second){
    console.log('this.name', this.name);
    console.log('first', first);
    console.log('second', second);
}
var bound = foo.bind(bar, '1st');

var obj = new bound('2nd');
/*
输出:
this.name undefined
first 1st
second 2nd
*/

从上面例子可以看出, objthis已经不是bar, 这时候的this应该是obj本身. 但是参数还是照传的.

我们可以用instanceof运算符来判断是否是作为构造函数运行. 为什么呢? 下面来仔细分析下:

先看看MDN里对instanceof的定义:

instanceof 运算符用于检测构造函数的 prototype 属性是否出现在某个实例对象的原型链上。

再看看使用instanceof检测实例对象原型链的🌰.

// 构造函数
const A = function() {},
      B = function() {},
      C = function() {},
      D = function() {};

B.prototype = A.prototype;
C.prototype = new B();
var E = new C();

console.log(E instanceof C);
// true
// 因为 E.__proto__ === C.prototype
console.log(E instanceof B);
// true
// C.prototype.__proto__ === B.prototype
console.log(E instanceof A);
// true
// C.prototype.__proto__ === B.prototype === A.prototype
console.log(D instanceof A);
// false,因为 E.__proto__ 不在A的原型链上

如果不熟悉或者忘记了原型链, 参考下面的定义:

  1. js分为函数对象和普通对象,每个对象都有__proto__属性,但是只有函数对象才有prototype属性 1.1 属性__proto__是一个对象,它有两个属性,constructor和__proto__; 1.2 原型对象prototype有一个默认的constructor属性,用于记录实例是由哪个构造函数创建;
  2. Object、Function都是js内置的函数, 类似的还有我们常用到的Array、RegExp、Date、Boolean、Number、String
  3. A.prototype.constructor == A 准则1:原型对象(即A.prototype)的constructor指向构造函数本身
  4. E.proto == A.prototype 准则2:实例(即E)的__proto__和原型对象指向同一个地方

我们用ES6扩展运算符的实现方式作为🌰.

作为普通函数执行的情况

运行bind后的函数bound(), 此时相当于运行下面函数, 相当于调用了window.bound(), 下面函数的this指向window.

function(...args2) {
    _foo.call(context, ...args1, ...args2);
}

作为构造函数执行的情况

当执行到上面例子里的new bound('2nd'), 此时新对象obj的构造函数为:

function(...args2) {
    _foo.call(context, ...args1, ...args2);
};

new运算符会生成一个新实例对象(假如新实例为obj, 构造函数为fn), 然后把fn.prototype的值赋给obj.__proto__. 最后把新实例obj作为构造函数fnthis.

那么此时构造函数里的this指向调用者, 即obj对象实例本身.

可作为构造函数的实现

利用bound函数的this可以分辨作为普通函数执行和作为构造函数执行的情况.

Function.prototype.es6Bind = function(context, ...args1) {
    const _foo = this; // _foo由于闭包, 始终指向原函数.
    const bound = function(...args2) {
        // 当作为构造函数时,this 指向实例,_foo 指向原函数,
        //   此时结果为 true,当结果为 true 的时候,this 指向实例。
 
        // 当作为普通函数时,this 指向 window,_foo 指向原函数,
        //   此时结果为 false,当结果为 false 的时候,this 指向绑定的 context。
        _foo.call(
            this instanceof _foo 
            ? this 
            : context,
            ...args1, ...args2
        );
    };
    // bound作为构造函数需要拥有原函数的prototype属性, 
    // 生成的实例才能继承原函数上定义的属性和方法.
    bound.prototype = this.prototype;
    return bound;
}

这种实现方法有个缺点, 因为bound.prototype = this.prototype是浅拷贝, 所以当修改bound.prototype的属性方法时, 原函数foo的属性方法也会被改变.

举个🌰:

function A() {
    this.name = 1;
}
function B() {};
console.log(A.prototype.name);
// 1
B.prototype = A.prototype;
B.prototype.name = 2;
console.log(A.prototype.name);
// 2

我们可以构造一个空函数, 让原函数原型赋值给空函数的prototype, 生成的实例属性方法完全继承自原函数, 但是不会修改到原函数的prototype.

举个🌰:

// 构造一个空函数fNOP;
function fNOP() {};

function bound() {}
function _foo() {};

_foo.prototype.name = '_foo';

fNOP.prototype = _foo.prototype;

const fnop = new fNOP();
// 可见 fnop.__proto__ === fNOP.prototype === _foo.prototype;

bound.prototype = fnop;
// 因此假如有属性name, 由于 bound.prototype.name === fnop.name, 所以就算修改 bound.prototype.name, 改变的只是fnop实例的属性.

bound.prototype.name = 'bound'
console.log(bound.prototype.name);
// bound

console.log(_foo.prototype.name);
// _foo

还有一个需要改进的地方, 代码运行到bound.prototype = this.prototype;时, 如果原函数是一个箭头函数, 那么this.prototype此时为undefined.

最终实现

Function.prototype.myBind = function(context, ...args1) {
    // 如果不是函数, 直接报错;
    if (typeof this !== 'function') {
      throw new TypeError('Function.prototype.bind - what is trying to be bound is not callable');
    }

    // _foo由于闭包, 始终指向原函数, 即上面例子的foo.
    const _foo = this;

    // 构造一个空函数用于中转.
    const fNOP = function(){};

    const bound = function(...args2) {
        // 当作为构造函数时,this 指向实例,_foo 指向原函数,
        //   此时结果为 true,当结果为 true 的时候,this 指向实例。

        // 当作为普通函数时,this 指向 window,_foo 指向原函数,
        //   此时结果为 false,当结果为 false 的时候,this 指向绑定的 context。
        _foo.call(this instanceof _foo ? this : context, ...args1, ...args2);
    };

    // 原函数为箭头函数时, this.prototype 为 undefined.
    if (this.prototype) {
      // 这里用fNOP的prototype记录原函数的prototype.
      fNOP.prototype = this.prototype;
    }

    // bound作为构造函数需要拥有原函数的prototype属性, 
    //   生成的实例才能继承原函数上定义的属性和方法.
    // fNOP的实例继承原函数的所有属性方法, 并使bound.prototype指向fNOP的实例, 
    //   这样可以使bound.prototype的修改影响不到原函数的属性方法.
    bound.prototype = new fNOP();
    return bound;
}

MDN的bind实现

最后对比一下MDN的Polyfill实现方式:

if (!Function.prototype.bind) {
  Function.prototype.bind = function(oThis) {
    if (typeof this !== 'function') {
      // closest thing possible to the ECMAScript 5
      // internal IsCallable function
      throw new TypeError('Function.prototype.bind - what is trying to be bound is not callable');
    }

    var aArgs   = Array.prototype.slice.call(arguments, 1),
        fToBind = this,
        fNOP    = function() {},
        fBound  = function() {
          // this instanceof fBound === true时,说明返回的fBound被当做new的构造函数调用
          return fToBind.apply(this instanceof fBound
                 ? this
                 : oThis,
                 // 获取调用时(fBound)的传参.bind 返回的函数入参往往是这么传递的
                 aArgs.concat(Array.prototype.slice.call(arguments)));
        };

    // 维护原型关系
    if (this.prototype) {
      // 当执行Function.prototype.bind()时, this为Function.prototype 
      // this.prototype(即Function.prototype.prototype)为undefined
      fNOP.prototype = this.prototype; 
    }
    // 下行的代码使fBound.prototype是fNOP的实例,因此
    // 返回的fBound若作为new的构造函数,new生成的新对象作为this传入fBound,新对象的__proto__就是fNOP的实例
    fBound.prototype = new fNOP();

    return fBound;
  };
}

不同的是MDN采用的是this instanceof fBound判断是否是作为构造函数, 其实 fBound.prototypefNOP.prototype的原型链上, 而fNOP.prototype === this.prototype.

只要在相同的原型链上, instanceof就能访问到, 效果都一样.