听风是风

学或不学,知识都在那里,只增不减。

导航

js 手动实现bind方法,超详细思路分析!

壹 ❀ 引

js 实现call和apply方法 一文中,我们详细分析并模拟实现了call/apply方法,由于篇幅问题,关于bind方法实现只能另起一篇。

在模拟bind之前,我们先了解bind的概念,这里引入MDN解释:

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

说的通俗一点,bindapply/call一样都能改变函数this指向,但bind并不会立即执行函数,而是返回一个绑定了this的新函数,你需要再次调用此函数才能达到最终执行。

我们来看一个简单的例子:

var obj = {
    z: 1
};
var obj1 = {
    z: 2
};

function fn(x, y) {
    console.log(x + y + this.z);
};
// call与apply
fn.call(obj, 2, 3); //6
fn.apply(obj, [2, 3]); //6

var bound = fn.bind(obj, 2);
bound(3); //6
//尝试修改bind返回函数的this
bound.call(obj1, 3); //6

可以到bind并不是立即执行,而是返回一个新函数,且新函数的this无法再次被修改,我们总结bind的特点:

  • 可以修改函数this指向。
  • bind返回一个绑定了this的新函数boundFcuntion,例子中我们用bound表示。
  • 支持函数柯里化,我们在返回bound函数时已传递了部分参数2,在调用时bound补全了剩余参数。
  • boundFunction的this无法再被修改,使用call、apply也不行。

考虑到有同学对于柯里化的陌生,这里简单解释,所谓函数柯里化其实就是在函数调用时只传递一部分参数进行调用,函数会返回一个新函数去处理剩下的参数,一个经典简单的例子:

//函数柯里化
function fn(x, y) {
    return function (y) {
        console.log(x + y);
    };
};
var fn_ = fn(1);
fn_(1); //2

fn(1)(1) //2

不难发现函数柯里化使用了闭包,在执行内层函数时,它使用了外层函数的局部形参x,从而构成了闭包,扯远了点。

我们来尝试实现bind方法,先从简单的改变this和返回函数开始。

贰 ❀ 实现bind

之前已经有了模拟call/apply的经验,这里直接给出版本一:

Function.prototype.bind_ = function (obj) {
    var fn = this;
    return function () {
        fn.apply(obj);
    };
};

var obj = {
    z: 1
};

function fn() {
    console.log(this.z);
};

var bound = fn.bind_(obj);
bound(); //1

唯一需要留意的就是var fn = this这一行,如果不提前保存,在执行bound时内部this会指向window。

版本一以满足了this修改与函数返回,马上有同学就想到了,版本一不支持函数传参,那么我们进行简单修改让其支持传参:

Function.prototype.bind_ = function (obj) {
    //第0位是this,所以得从第一位开始裁剪
    var args = Array.prototype.slice.call(arguments, 1);
    var fn = this;
    return function () {
        fn.apply(obj, args);
    };
};

完美了吗?并不完美,别忘了我们前面说bind支持函数柯里化,在调用bind时可以先传递部分参数,在调用返回的bound时可以补全剩余参数,所以还得进一步处理,来看看bind_第二版:

Function.prototype.bind_ = function (obj) {
    //第0位是this,所以得从第一位开始裁剪
    var args = Array.prototype.slice.call(arguments, 1);
    var fn = this;
    return function () {
        //二次调用我们也抓取arguments对象
        var params = Array.prototype.slice.call(arguments);
        //注意concat的顺序
        fn.apply(obj, args.concat(params));
    };
};

var obj = {
    z: 1
};

function fn(x, y) {
    console.log(x + y + this.z);
};

var bound = fn.bind_(obj, 1);
bound(2); //4

看,改变this,返回函数,函数柯里化均已实现。这段代码需要注意的是args.concat(params)的顺序,args在前,因为只有这样才能让先传递的参数和fn的形参按顺序对应。

至少走到这一步都挺顺序,需要注意的是,bind方法还有一个少见的特性,这里引用MDN的描述

绑定函数也可以使用 new 运算符构造,它会表现为目标函数已经被构建完毕了似的。提供的 this 值会被忽略,但前置参数仍会提供给模拟函数。

说通俗点,通过bind返回的boundFunction函数也能通过new运算符构造,只是在构造过程中,boundFunction已经确定的this会被忽略,且返回的实例还是会继承构造函数的构造器属性与原型属性,并且能正常接收参数。

有点绕口,我们来看个简单的例子:

var z = 0;
var obj = {
    z: 1
};

function fn(x, y) {
    this.name = '听风是风';
    console.log(this.z);
    console.log(x);
    console.log(y);
};
fn.prototype.age = 26;

var bound = fn.bind(obj, 2);
var person = new bound(3);//undefined 2 3

console.log(person.name);//听风是风
console.log(person.age);//26

在此例子中,我们先是将函数fnthis指向了对象obj,从而得到了bound函数。紧接着使用new操作符构造了bound函数,得到了实例person。不难发现,除了先前绑定好的this丢失了(后面会解释原因),构造器属性this.name,以及原型属性fn.prototype.age都有顺利继承,除此之外,两个形参也成功传递进了函数。

难点来了,至少在ES6之前,JavaScript并没有class类的概念,所谓构造函数其实只是对于类的模拟;而这就造成了一个问题,所有的构造函数除了可以使用new构造调用以外,它还能被普通调用,比如上面例子中的bound我们也可以普通调用:

bound(3); //1 2 3

有同学在这可能就有疑惑,bound()等同于window.bound(),此时this不是应该指向window从而输出0吗?我们在前面说bind属于硬绑定,一次绑定终生受益,上面的调用本质上等同于:

window.fn.bind(obj, 2);

函数fn存在this默认绑定window与显示绑定bind,而显示绑定优先级高于默认绑定,所以this还是指向obj

当构造函数被new构造调用时,本质上构造函数中会创建一个实例对象,函数内部的this指向此实例,当执行到console.log(this.z)这一行时,this上并未被赋予属性z,所以输出undefined,这也解释了为什么bound函数被new构造时会丢失原本绑定的this。

是不是觉得ES5构造函数特别混乱,不同调用方式函数内部this指向还不同,也正因如此在ES6中隆重推出了class类,凡是通过class创建的类均只能使用new调用,普通调用一律报错处理:

class Fn {
    constructor(name, age) {
        this.name = name;
        this.age = age;
    };
    sayName() {
        console.log(this.name);
    };
};
//只能new构造调用
const person = new Fn('听风是风', 26);
person.sayName(); //听风是风
const person1 = Fn(); //Class constructor Fn cannot be invoked without 'new'

扯远了,让我们回到上面的例子,说了这么多无非是为了强调一点,我们在模拟bind方法时,返回的bound函数在调用时得考虑new调用与普通调用,毕竟两者this指向不同。

再说直白一点,如果是new调用,bound函数中的this指向实例自身,而如果是普通调用this指向obj,怎么区分呢?

不难,我们知道(强行让你们知道)构造函数实例的constructor属性永远指向构造函数本身(这句话其实有歧义,具体我会在原型的文章中解释),比如:

function Fn(){};
var o = new Fn();
console.log(o.constructor === Fn);//true

而构造函数在运行时,函数内部this指向实例,所以this的constructor也指向构造函数:

function Fn() {
    console.log(this.constructor === Fn); //true
};
var o = new Fn();
console.log(o.constructor === Fn); //true

所以我就用constructor属性来判断当前bound方法调用方式,毕竟只要是new调用,this.constructor === Fn一定为true。

让我们简单改写bind_方法,为bound方法新增this判断以及原型继承:

Function.prototype.bind_ = function (obj) {
    var args = Array.prototype.slice.call(arguments, 1);
    var fn = this;
    var bound = function () {
        var params = Array.prototype.slice.call(arguments);
        //通过constructor判断调用方式,为true this指向实例,否则为obj
        fn.apply(this.constructor === fn ? this : obj, args.concat(params));
    };
    //原型链继承
    bound.prototype = fn.prototype;
    return bound;
};

有同学就问了,难道不应该是this.constructor===bound吗?并不是,虽然new的是bound方法,本质上执行的还是fn,毕竟bound自身并没有构造器属性,这点关系还是需要理清。

其次还有个缺陷。虽然构造函数产生的实例都是独立的存在,实例继承而来的构造器属性随便你怎么修改都不会影响构造函数本身:

function Fn() {
    this.name = '听风是风';
    this.sayAge = function () {
        console.log(this.age);
    };
};
Fn.prototype.age = 26;

var o = new Fn();
o.sayAge(); //26
//我们改变实例继承的构造器属性,并不会影响构造函数本身
o.name = 'echo';
var o1 = new Fn();
console.log(o1.name) //听风是风

但是如果我们直接修改实例原型,这就会对构造函数Fn产生影响,来看个例子:

function Fn() {
    this.name = '听风是风';
    this.sayAge = function () {
        console.log(this.age);
    };
};
Fn.prototype.age = 26;

var o = new Fn();
o.sayAge(); //26
//修改实例的原型属性,这会影响构造函数本身
o.__proto__.age = 18;
var o1 = new Fn();
console.log(o1.age) //18

不难理解,构造器属性(this.name,this.sayAge)在创建实例时,我们可以抽象的理解成实例深拷贝了一份,这是属于实例自身的属性,后面再改都与构造函数不相关。而实例要用prototype属性时都是顺着原型链往上找,构造函数有便借给实例用了,一共就这一份,谁要是改了那就都得变。

我们可以输出实例o,观察它的属性,可以看到age属性确实是绑原型__proto__上(注意,prototype是函数特有,普通对象只有__proto__,两者指向相同)。

怎么做才保险呢,这里就可以借助一个空白函数作为中介,直接看个例子:

function Fn() {
    this.name = '听风是风';
    this.sayAge = function () {
        console.log(this.age);
    };
};
Fn.prototype.age = 26;
// 创建一个空白函数Fn1,单纯的拷贝Fn的prototype
var Fn1 = function () {};
Fn1.prototype = Fn.prototype;
// 这里的Fn2对应我们的bound方法,将其原型指向Fn1创建的实例
var Fn2 = function () {};
Fn2.prototype = new Fn1();
var o = new Fn2();
console.log(o.age); //26
//尝试修改
o.__proto__.age = 18;
var o1 = new Fn();
console.log(o1.age);//26

说到底,我们就是借用空白函数,让Fn2的实例多了一层__proto__,达到修改原型不会影响Fn原型的目的,当然你如果通过__proto__.__proto__还是一样能修改,差不多就是这个意思:

o.__proto__.__proto__.age = 18;
var o1 = new Fn();
console.log(o1.age);//18

所以综上,我们再次修改bind_方法,拿出第四版:

Function.prototype.bind_ = function (obj) {
    var args = Array.prototype.slice.call(arguments, 1);
    var fn = this;
    //创建中介函数
    var fn_ = function () {};
    var bound = function () {
        var params = Array.prototype.slice.call(arguments);
        //通过constructor判断调用方式,为true this指向实例,否则为obj
        fn.apply(this.constructor === fn ? this : obj, args.concat(params));
        console.log(this);
    };
    fn_.prototype = fn.prototype;
    bound.prototype = new fn_();
    return bound;
};

最后,bind方法如果被非函数调用时会抛出错误,所以我们要在第一次执行bind_时做一次调用判断,加个条件判断,我们来一个完整的最终版:

Function.prototype.bind_ = function (obj) {
    if (typeof this !== "function") {
        throw new Error("Function.prototype.bind - what is trying to be bound is not callable");
    };
    var args = Array.prototype.slice.call(arguments, 1);
    var fn = this;
    //创建中介函数
    var fn_ = function () {};
    var bound = function () {
        var params = Array.prototype.slice.call(arguments);
        //通过constructor判断调用方式,为true this指向实例,否则为obj
        fn.apply(this.constructor === fn ? this : obj, args.concat(params));
        console.log(this);
    };
    fn_.prototype = fn.prototype;
    bound.prototype = new fn_();
    return bound;
};

var z = 0;
var obj = {
    z: 1
};

function fn(x, y) {
    this.name = '听风是风';
    console.log(this.z);
    console.log(x);
    console.log(y);
};
fn.prototype.age = 26;

var bound = fn.bind_(obj, 2);
var person = new bound(3); //undefined 2 3

console.log(person.name); //听风是风
console.log(person.age); //26
person.__proto__.age = 18;
var person = new fn();
console.log(person.age); //26

看着有些长,不过我们顺着思路一步步走过来其实不难理解。

好啦,关于bind方法的模拟实现就说到这里了,万万没想到这篇实现居然用了我五个小时时间...

另外,如果大家对于new一个构造函数发生了什么存在疑惑,可以阅读博主这篇文章:

new一个对象的过程,实现一个简单的new方法

若对于上文中修改实例原型会影响原构造函数存在疑虑,可以阅读博主这篇文章:

精读JavaScript模式(八),JS类式继承

那么就写到这里了,巨累,准备睡觉。

叁 ❀ 参考

深度解析bind原理、使用场景及模拟实现

MDN Function.prototype.bind()

最详尽的 JS 原型与原型链终极详解,没有「可能是」。(一)

JavaScript深入之bind的模拟实现

posted on 2020-01-10 23:25  听风是风  阅读(5309)  评论(17编辑  收藏