Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

JavaScript 深入系列之 bind 方法的模拟实现 #136

Open
yuanyuanbyte opened this issue Oct 25, 2022 · 0 comments
Open

JavaScript 深入系列之 bind 方法的模拟实现 #136

yuanyuanbyte opened this issue Oct 25, 2022 · 0 comments

Comments

@yuanyuanbyte
Copy link
Owner

yuanyuanbyte commented Oct 25, 2022

本系列的主题是 JavaScript 深入系列,每期讲解一个技术要点。如果你还不了解各系列内容,文末点击查看全部文章,点我跳转到文末

如果觉得本系列不错,欢迎 Star,你的支持是我创作分享的最大动力。

前言

bind 的实现其实非常考验对原型链的理解。

bind 和 apply,call 是 JS 修改 this 指向的三把利器 🔱。

对于 apply,call 来说,bind 的区别在于会返回一个修改了 this 指向的新函数,并不会立即执行。

但看似简单的内容,实则包含了 JS 的两大核心内容:原型链和构造函数 (new) 。

这篇文章分为两部分:

  • 一部分讲如何实现一个基础版本的 bind 方法,带大家做好热身运动;
  • 另一部分进入主题,详细讲解如何通过原型链来实现一个让人眼前一亮的 bind 方法 ✨。

一、实现 bind 方法

1. 改变 this 指向

简单说,bind 方法会返回一个改变了 this 指向的新方法。

举个 🌰:

var name = 'Jack';
var Yve = {
    name: 'Yvette'
};
function person() {
    console.log(this.name);
}
person(); // Jack
var bindYve = person.bind(Yve);
bindYve(); // Yvette

根据这个特点,我们来实现第一版的 bind 方法:

// v1.0:返回一个改变了 this 指向的方法

Function.prototype.bind2 = function (context) {
    // 首先要获取调用bind的函数,也就是绑定函数,用this可以获取
    var self = this; // 用self绑定this,因为下面函数中的this指向已经改变(存放当前函数的this)
    return function () {
        // 用apply来改变this指向(apply的实现并不复杂,文末放有链接可以查看)
        self.apply(context);
    }
}

2. 函数柯里化

bind 方法的另一个特点是支持柯里化:函数的参数可以分多次传入,即可以在 bind 中传入一部分参数,在执行返回的函数的时候,再传入另一部分参数。

举个 🌰:

var name = 'Jack';
var Yve = {
    name: 'Yvette'
};
function person(age, job, gender) {
    console.log(this.name, age, job, gender);
}
person(22, 'engineer', 'female');
// Jack 22 engineer female
var bindYve = person.bind(Yve, 22, 'engineer');
bindYve('female');
// Yvette 22 engineer female

根据这个特点,我们来实现第二版的 bind 方法:

// v2.0:支持函数柯里化,分段接收参数

Function.prototype.bind2 = function (context) {
    // 首先要获取调用bind的函数,也就是绑定函数,用this可以获取
    var self = this; // 用self绑定this,因为下面函数中的this指向已经改变(存放当前函数的this)
    var args = [...arguments].slice(1); // 用slice方法取第二个到最后一个参数(获取除了this指向对象以外的参数)
    return function () {
        // 这里的arguments是指bind返回的函数传入的参数
        var restArgs = [...arguments];
        // 用apply来改变this指向,拼接bind方法传入的参数和bind方法返回的函数传入的参数,统一在最后通过apply执行。
        self.apply(context, args.concat(restArgs));
    }
}

3. 返回值

别忘啦,函数是可以有返回值的,举个 🌰:

var name = 'Jack';
var Yve = {
    name: 'Yvette'
};
function person(age, job, gender) {
    return {
        name: this.name,
        age,
        job,
        gender
    }
}
var jack = person(22, 'engineer', 'female');
console.log(jack);
// {name: 'Jack', age: 22, job: 'engineer', gender: 'female'}
var bindYve = person.bind(Yve, 22, 'engineer');
var Yvette = bindYve('female');
console.log(Yvette);
// {name: 'Yvette', age: 22, job: 'engineer', gender: 'female'}

var bindYve2 = person.bind2(Yve, 22, 'engineer');
var Yvette2 = bindYve2('female');
console.log(Yvette2);
// undefined

而我们实现的 bind 方法在返回的函数中并没有把结果返回,所以得到的结果是 undefined,而不是返回值。

这个比较简单,补充一下:

// v2.1:拿到返回值

Function.prototype.bind2 = function (context) {
    var self = this;
    var args = [...arguments].slice(1);
    return function () {
        var restArgs = [...arguments];
        // 返回执行结果
        return self.apply(context, args.concat(restArgs));
    }
}

做完前面这些热身运动,下面我们进入今天的主题 🎃

二、使用原型链完整构建 bind 方法

1. 作为构造函数调用

bind 方法还有一个重要的的特点,绑定函数也可以使用 new 运算符构造,也就是说还可以将 bind 返回的函数作为构造函数。提供的 this 值会被忽略,但传入的参数仍然生效。

举个 🌰:

var name = 'Jack';
var Yve = {
    name: 'Yvette'
};
function person(age, job, gender) {
    console.log(this.name, age, job, gender);
}
var bindYve = person.bind(Yve, 22, 'engineer');
var obj = new bindYve('female');
// undefined 22 'engineer' 'female'

我们在全局和 Yve 中都声明了 name 值,但最后 this.name 的结果依然是 undefind,说明 bind 方法绑定的 this 失效了,原因在于返回函数 bindYve 被作为构造函数调用了,了解 new 关键字原理的童鞋就会知道,此时的 this 已经指向了实例 obj。

这一块和 new 的模拟实现结合在一起理解,更容易掌握两者的原理,做到融会贯通。关于 new 的原理可以参考:JavaScript 深入系列之 new 操作符的模拟实现

我们知道了作为构造函数调用时,this 指向实例,原先通过 bind 绑定的 this 失效。很显然前面实现的 bind 始终会通过 self.apply(context) 将 this 指向 context,不符合这一特点。

所以,在返回函数作为构造函数调用时,就不用修改 this 指向了,直接 self.apply(this)即可。因为作为构造函数调用时,this 就是指向实例,所以这里不需要做其他操作。

结论有了,那如何知道返回函数是被作为构造函数调用的呢?

我们可以用 instanceof 来判断返回函数的原型是否在实例的原型链上。

举个 🌰 大家就明白啦:

var func = function (){
    console.log(this instanceof func);
} 

// 作为普通函数调用
func(); 
// false

// 作为构造函数调用
new func(); 
// true

不同的调用方法,函数的 this 指向不同,利用这个特点即可得知返回函数是否作为构造函数调用:

  • 作为普通函数调用时,this 指向 window,结果为 false;
  • 作为构造函数调用时,this 指向实例,实例的 __proto__ 属性指向构造函数的 prototype,结果为 true。

了解这个原理后,实现就简单了:

// v3.0:实现作为构造函数调用时this指向失效的效果

Function.prototype.bind2 = function (context) {
    var self = this;
    var args = [...arguments].slice(1);

    var fBound = function () {
        var restArgs = [...arguments];
        // 作为普通函数调用时,this 指向 window,结果为 false;
        // 作为构造函数调用时,this 指向实例,实例的 `__proto__` 属性指向构造函数的 prototype,结果为 true
        return self.apply(this instanceof fBound ? this : context, args.concat(restArgs));
    }
    return fBound;
}

这里其实考察了原型链的知识,关于原型链的内容可以参考:JavaScript 深入系列之从原型到原型链

2. 继承函数的原型

作为构造函数调用时,实例还会继承函数的原型。

举个 🌰:

var name = 'Jack';
var Yve = {
    name: 'Yvette'
};
function person(age, job, gender) {
    this.work = '福报'; // 实例属性
    console.log(this.name, age, job, gender);
}
person.prototype.clockIn = function () {
    console.log(996);
}
var bindYve = person.bind(Yve, 22, 'engineer');
var obj = new bindYve('female');
obj.work; // 福报
obj.clockIn(); // 996

但上一版的实现中并没有做到原型的继承:

...
var bindYve2 = person.bind2(Yve, 22, 'engineer');
var obj2 = new bindYve2('female');
obj2.work; // 福报
obj2.clockIn() // obj2.clockIn is not a function

这个问题怎么解决呢?

我们可以修改返回函数的原型,使返回函数的原型指向绑定函数的原型(这样实例就可以继承函数的原型),然后在返回函数中用 instanceof 来判断绑定函数的原型是否在实例的原型链上。 因为实例的构造函数是返回函数,而返回函数的原型又指向了绑定函数的原型,所以绑定函数的原型肯定在实例的原型链上 (我画了一个图,来帮大家理解这段内容 🙋‍♀️)

先看如何用代码来实现:

// v4.0:继承函数的原型

Function.prototype.bind2 = function (context) {
    // 首先要获取调用 bind 的函数,也就是绑定函数,用 this 可以获取
    var self = this; // 存放当前函数的 this
    var args = [...arguments].slice(1); // 获取除了 this 指向对象以外的参数

    var fBound = function () {
        // 这里的 arguments 是指 bind 返回的函数传入的参数
        var restArgs = [...arguments];
        /**
         * 用 instanceof 来判断绑定函数 self 的原型是否在实例的原型链上:
         * 1. 使用 new 运算符作为构造函数调用时,this 指向实例
         *    因为我们在下面通过`fBound.prototype = this.prototype;`修改了 fBound 的原型为绑定函数的原型,所以此时结果为 true,this 指向实例。
         * 2. 正常作为普通函数调用时,this 指向 window,此时结果为 false,this 指向绑定的 context;
         */
        return self.apply(this instanceof self ? this : context, args.concat(restArgs));
    }
    // 修改返回函数的 prototype 为绑定函数的 prototype,实例就可以继承函数的原型中的值
    fBound.prototype = this.prototype;
    return fBound;
}

结合前面的例子画了一张图来帮大家梳理一下:

img-1

从上图可以得知使用 new 运算符作为构造函数调用时,绑定函数 person 的原型在实例 obj 的原型链上。使用 this instanceof self 来检测绑定函数 self(例子中的 person 方法)的原型是否在实例 obj 的原型链上,就可以知道返回函数是否被作为构造函数调用了 👌。

3. 维护原型关系

到这里,我们已经实现了返回函数作为构造函数调用时的效果,大家也明白了为什么要用这种方法来解决这个问题,很棒!但还不够完美 🙅‍♀️

为什么?

因为这样的实现存在一个问题,我们修改返回函数的原型为绑定函数的原型,再配合 instanceof 来判断返回函数是否作为构造函数调用,思路是合理的,但直接让返回函数的原型指向绑定函数的原型就太粗暴了 🥺

fBound.prototype = this.prototype;

了解堆栈的童鞋就会知道,这样的写法其实只是做了一个简单的对象引用,即把返回函数的原型指向了绑定函数原型对象的引用,我画了一个存储结构的示意图,帮大家理解一下 😉

img-2

两个原型指向同一个对象,任何的操作都会相互影响。

比如实例在原型上新增方法或者修改属性,绑定函数的原型也会跟着改变,举个 🌰:

var name = 'Jack';
var Yve = {
    name: 'Yvette'
};

function person(age, job, gender) {
    console.log(this.name, age, job, gender);
}
var bindYve = person.bind2(Yve, 22, 'enginner');
var obj = new bindYve('female');

// 实例在原型上新增一个方法
obj.__proto__.clickLike = function(){
    console.log('一键三连');
}
obj.clickLike(); // 一键三连

// 绑定函数的原型也有了这个方法
person.prototype.clickLike(); // 一键三连

或者直接操作返回函数的原型,也是同样效果:

...
var bindYve = person.bind2(Yve, 22, 'enginner');

// 返回函数的原型新增一个方法
bindYve.prototype.clickLike = function(){
    console.log('下次一定');
}
bindYve.prototype.clickLike(); // 下次一定

// 绑定函数的原型也有了这个方法
person.prototype.clickLike(); // 下次一定

解决这个问题我们可以用一个空函数作为中间变量,通过这个中间变量来维护原型关系,从而让 fBound.prototypeperson.prototype 不再指向同一个原型对象。

来看代码实现(最终版本):

// v5.0:最终版本

Function.prototype.bind2 = function (context) {
    var self = this;
    var args = [...arguments].slice(1);

    var fBound = function () {
        var restArgs = [...arguments];
        return self.apply(this instanceof self ? this : context, args.concat(restArgs));
    }
    // 用一个空函数 fn 作为中间变量
    var fn = function() {};
    // 使中间变量 fn 的 prototype 指向绑定函数的 prototype
    fn.prototype = this.prototype;
    // 再使返回函数的 prototype 指向 fn 的实例,通过中间变量 fn 来维护原型关系
    fBound.prototype = new fn();
    return fBound;
}

画了一张图帮大家梳理这段代码:

img-3

从上图可以发现,中间变量 fn 的实例 维护了返回函数 fBound 和 绑定函数 person 的原型关系,使我们可以继续使用 instanceof 来判断返回函数是否作为构造函数调用;同时,也“隔离”了返回函数原型和绑定函数原型,返回函数的原型指向了 fn 的实例,所以再怎么操作返回函数的 prototype 或者返回函数实例的 __proto__ 属性都碰不着绑定函数的 prototype,解决了 fBound.prototypeperson.prototype 指向同一个原型对象的问题。

到这里,我们已经知道了如何实现一个漂亮的 bind 方法,非常棒!

干杯

实现 bind 方法的内容已经讲完,但关于原型链的内容还有一些没有讲,我们把这些补上 🙆‍♀️

前文讲到 fn 的实例“隔离”了返回函数原型和绑定函数原型,但其实这只是“半隔离”,我们还是可以通过 fBound.prototype.__proto__ 或者 obj.__proto__.__proto__ 来修改绑定函数的原型,这个情况大家需要了解。

比如:

var name = 'Jack';
var Yve = {
    name: 'Yvette'
};

function person(age, job, gender) {
    console.log(this.name, age, job, gender);
}
var bindYve = person.bind2(Yve, 22, 'enginner');
var obj = new bindYve('female');

// 实例原型新增一个方法
obj.__proto__.clickLike = function(){
    console.log('下次一定');
}
obj.clickLike(); 
// 下次一定

// 绑定函数的原型不再被影响
person.prototype.clickLike(); 
// person.prototype.clickLike is not a function

// 但通过原型链依然可以修改绑定函数的原型
bindYve.prototype.__proto__.a = function(){console.log(11111)};
person.prototype.a(); 
// 11111
obj.__proto__.__proto__.b = function(){console.log(22222)};
person.prototype.b(); 
// 22222

这也是 JavaScript 作为一种基于原型的语言的特点。

另外,从前面的图中可以很清楚的发现 fn 的原型也在返回函数 fBound 的原型链上,所以也可以用 this instanceof fn 来判断返回函数是否作为构造函数调用。但相较而言,还是 this instanceof self 更加直观。

到此,关于原型链的一些内容也讲完啦,希望对大家有所帮助 😊

比心

查看原文

查看全部文章

博文系列目录

  • JavaScript 深入系列
  • JavaScript 专题系列
  • JavaScript 基础系列
  • 网络系列
  • 浏览器系列
  • Webpack 系列
  • Vue 系列
  • 性能优化与网络安全系列
  • HTML 应知应会系列
  • CSS 应知应会系列

交流

各系列文章汇总:https://github.com/yuanyuanbyte/Blog

我是圆圆,一名深耕于前端开发的攻城狮。

weixin

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

1 participant