实现call(),apply()和bind()函数

从工作效率来说,造轮子不是一种好的方式,但是从学习层面来说,造轮子就可以了解到更多知识。

凡是我不能创造的,都是因为我不了解它。 ———— Richard Feynman

因此,为了更好的学习call(),apply()和bind()三个JS基础内置函数,这里就来模拟实现一次。

Call 函数

首先来看一下call函数的例子:

1
2
3
4
5
6
7
8
9
10

function sayHello (message) {
console.log(message, this.name)
}

const obj = {
name: 'Sevens Chan'
}

sayHello.call(obj,'Hello')

运行结果是:Hello Sevens Chan

通过上面的例子,我们可以分析call函数具有以下特征:

  1. 原型函数的调用会改变它this的指向。例如:上面例子中的函数调用变成了obj.sayHello
  2. 无论我们传递任何参数给sayHello.call,都会以arg1,arg2,...的形式传递到原始的sayHello函数中去
  3. 实现的call函数不会污染目标函数或者传入上下文本身。

了解了基本概念,我们就可以一步一步来实现。先来实现第一个特征。

1
2
3
4
5
6
7
8
Function.prototype.myCall = function (context) {
context = context || window
context.func = this
return context.func()
}

// 继续上面得例子
sayHello.myCall(obj, 'Hello') // Hello Sevens

要谨记一个法则谁调用函数,this就指向谁, 因此可以看到,我们用了传入的对象用作了当前函数的执行上下文,然后通过这个context来调用目标的函数。

接下来来实现第二条规则,参数传入:

1
2
3
4
5
6
Function.prototype.myCall = function () {
let [context, ...args] = arguments
context = context || window || global
context.func = this
return context.func(...args)
}

我们在网上看到很多依然使用eval的形式的实现(当然,也有为了只使用ES3函数的前提约束的因素),而如果使用ES6的话,可以使用解构赋值,就可以很方便的把数组转换成多个参数的形式。

下面来测试下:

1
2
3
4
5
6
7
8
9
10
function sayHello(boyName, girlName) {
console.log(this.message, boyName, girlName)
}

const obj = {
message: 'Come on!'
}

sayHello.myCall(obj, 'Sevens', 'Pig')
// Come on! Sevens Pig

结果正确。
然后考虑一下第三点,如果传入的上下文本身就带有func属性的话,那么这个属性值就会被我们覆盖掉了,就满足不了第三点的要求,所以做以下的改造:

1
2
3
4
5
6
7
Function.prototype.myCall = function () {
let [context, ...args] = arguments
context = context || window || global
const func = Symbol()
context[func] = this
return context[func](...args)
}

使用独一无二的Symbol值来做属性key,就可以保证不覆盖掉传入上下文的任何属性,达到第三点的目的。
这样一个Call函数就完成了。

apply 函数

apply和call的主要区别在于传入参数的方式不同,因此可以稍微改一下:

1
2
3
4
5
6
7
Function.prototype.myApply = function () {
let [context, args] = arguments
context = context || window || global
const func = Symbol()
context[func] = this
return context[func](...args)
}

同样的结果正确

bind 函数

bind函数主要作用在于更改this的指向,看下MDN上对其的描述:
bind

可以看到 bind 的返回值是一个绑定了this指向的上下文的函数,并且包含了调用bind时候传入的参数。

  1. bind函数创建并且返回一个new function,叫做绑定函数。 这个绑定函数包装了原始的函数对象。
1
2
3
4
5
6
Function.prototype.myBind = function(context) {
const boundRealFunction = this
return function boundFunction () {
return boundRealFunction.apply(context)
}
}

这里的apply可以使用我们上面实现的函数,这里就不重新实现了。

然后就是参数:

1
2
3
4
5
6
7
8
Function.prototype.myBind = function() {
const [context, ...args] = arguments
const boundRealFunction = this
return function boundFunction () {
const boundArgs = arguments
return boundRealFunction.apply(context, [...args, ...boundArgs])
}
}

要留意的是当返回了绑定函数之后,同样有可能在调用的时候传入参数,按照规范,这里的参数将会合并在一起。

最后有一个特性需要实现:如果绑定函数通过new操作符来构造的话,那么bind除第一个(context)以外的参数值将会被忽略,如下面代码:

1
2
3
4
5
6
7
var a = 1
function func () {
console.log(this.a)
}
const testFunc = func.myBind({a:2})
testFunc() // 2
const test = new testFunc() // undefined

因此我们需要分辨出绑定函数是被new来调用还是函数调用的方式调用。
回顾前面写的一篇文章 new的时候实际做了什么 可以得知通过new创建的对象是会把新建对象的__proto__指向绑定函数的原型对象,而在我们的myBind实现中,它的原型对象应该就是一个function的原型对象。

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
Function.prototype.myBind = function() {
const [context, ...args] = arguments
const self = this
const funcProto = function () {}
const boundFunction = function () {
const boundArgs = arguments
return self.apply(
// this是当前作用域环境,用来判断是new创建还是直接调用
// new创建的话, this已经被指定到构造函数作用域,因此,this instanceof self会返回true。反之,this指向的是window,所以使用我们bind的context作为作用域
this instanceof self
? this
: context,
[...args, ...boundArgs]
)
}

// 把绑定函数原型的原型对象也指向被调用函数的原型
if (this.prototype) {
funcProto.prototype = this.prototype
}
// 绑定函数也加入到原型链中
boundFunction.prototype = new funcProto()

return boundFunction
}

最后一个特性比较难理解,需要一点时间消化。