This website requires JavaScript.
ROSY
GRAY

觉宇宙之无穷,识盈虚之有数。

——王勃/《滕王阁序》

BING
转载

JavaScript 深入系列之 bind 方法的模拟实现(yuanyuanbyte)

共 11,502 字,需阅读 29 分钟2021/11/23 下午48 次阅读

前言

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

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

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

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

这篇文章分为两部分:

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

一、实现 bind 方法

1. 改变 this 指向

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

举个 🌰:

              
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
var name = 'Jack'; var Yve = { name: 'Yvette' }; function person() { console.log(this.name); } person(); // Jack var bindYve = person.bind(Yve); bindYve(); // Yvette

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

              
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
// 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 中传入一部分参数,在执行返回的函数的时候,再传入另一部分参数。

举个 🌰:

              
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
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 方法:

              
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
// 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. 返回值

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

              
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
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,而不是返回值。

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

              
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
// 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 值会被忽略,但传入的参数仍然生效。

举个 🌰:

              
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
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 来判断返回函数的原型是否在实例的原型链上。

举个 🌰 大家就明白啦:

              
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
var func = function (){ console.log(this instanceof func); } // 作为普通函数调用 func(); // false // 作为构造函数调用 new func(); // true

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

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

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

              
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
// 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. 继承函数的原型

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

举个 🌰:

              
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
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

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

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

这个问题怎么解决呢?

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

先看如何用代码来实现:

              
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
// 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; }

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

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

3. 维护原型关系

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

为什么?

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

              
  • 1
fBound.prototype = this.prototype;

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

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

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

              
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
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(); // 一键三连

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

              
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
... var bindYve = person.bind2(Yve, 22, 'enginner'); // 返回函数的原型新增一个方法 bindYve.prototype.clickLike = function(){ console.log('下次一定'); } bindYve.prototype.clickLike(); // 下次一定 // 绑定函数的原型也有了这个方法 person.prototype.clickLike(); // 下次一定

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

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

              
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
// 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; }

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

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

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

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

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

比如:

              
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
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 应知应会系列

原文链接

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

自由转载 - 署名 - 非商业性使用https://blog.rosygray.com/article/23
0 / 0 条看法
访客身份
在下有一拙见,不知...
期待你的捷足先登