可能是最详细的apply、call、bind手写实现教学
最近在看一些手写实现的文章。
只要是涉及到手写实现的,一定会写到call、apply、bind。
call和apply原理比较简单,所以没什么问题。但是bind我看很多人都只是贴了代码,但并没有解释清楚如何写,为什么这么写以及思路。
于是今天来写一篇和this有关的这三个api的文章。
ps: 该文章内容主要以API面试为主,大牛可以略过。
call和apply
Function.prototype.call
语法
fun.call(thisArg[,arg1[,arg2[, ...]]])
参数
thisArg
在fun运行时指定的this
值。
arg1, arg2, ...
给到fun
的参数列表。
返回值
使用调用者提供的this
值和参数调用该函数的返回值。若该方法没有返回值,则返回undefined
。
Function.prototype.apply
语法
fun.apply(thisArg, [argsArray])
参数
thisArg
在fun运行时指定的this
值。
[argsArray]
给到fun
的参数数组。
返回值
使用调用者提供的this
值和参数调用该函数的返回值。若该方法没有返回值,则返回undefined
。
apply、call的区别
apply和call所做的事情都相同,那就是改变函数内部的this指向并调用它。
唯一的区别在于调用时所传递给被调用函数的参数的书写形式。
call传递的参数以逗号分隔;apply传递的参数为数组形式。
手写实现apply
apply手写实现很简单,思路如下:
- 检查调用
apply
的对象是否为函数 - 将函数作为传入的
context
对象的一个属性,调用该函数 - 不要忘了调用之后删除该属性
代码如下:
Function.prototype.apply = function (context, args) {
// 检查调用```apply```的对象是否为函数
if (typeof this !== 'function') {
throw new TypeError('not a function')
}
// 将函数作为传入的```context```对象的一个属性,调用该函数
const fn = Symbol()
context[fn] = this
context[fn](...args)
// 不要忘了调用之后删除该属性
delete context[fn]
}
详解:
手写实现该API的核心知识点是关于this的指向确认。这一知识点可以在我的另一篇文章如何判断this指向中了解到。
apply的语法为fun.apply(thisArg, [argsArray])
,我们知道如果有诸如obj.func
形式的函数调用,那么这里func
内部的this
就是指向obj
的。所以我们这里书写的this
,其实就是将来以func.bind()
形式使用bind
时那个被调用的函数func
。所以第一步如何写就解决了。
第二步,利用第一步的那个关于this的知识点,我们将被调用函数this
作为传入对象的属性进行调用,就能让被调用函数内部的this
指向该对象。如此一来我们就完成了该API最大的功能,改变被调用函数内的this
指向。
我在这里使用到了新的Symbol
数据类型,主要是避免在把函数赋值给context
对象的时候,因为属性名冲突而覆盖掉原有属性。至于为什么使用Symbol
作为属性名不会发生冲突,可以看看阮一峰大大的解释ECMAscript-Symbol。
第三步,删掉该属性,避免对传入对象造成污染。
手写实现call
call和apply唯一区别就是传给被调用函数的参数写法不同,这里只贴个代码,不多写废话了。
代码如下:
Function.prototype.call = function (context, ...args) {
// 检查调用```apply```的对象是否为函数
if (typeof this !== 'function') {
throw new TypeError('not a function')
}
// 将函数作为传入的```context```对象的一个属性,调用该函数
const fn = Symbol()
context[fn] = this
context[fn](...args)
// 不要忘了调用之后删除该属性
delete context[fn]
}
没错,比apply多了三个点。
new操作符
在手写实现bind之前,我们必须先掌握另一个关键字的——new
操作符的手写实现。
当然,手写实现new
操作符也是常见面试题之一。
让我们先来看看MDN-new上对new操作符的介绍。
new
运算符创建一个用户定义的对象类型的实例或具有构造函数的内置对象的实例。new 关键字会进行如下的操作: 1. 创建一个空的简单JavaScript对象(即{}); 2. 链接该对象(即设置该对象的构造函数)到另一个对象 ; 3. 将步骤1新创建的对象作为this的上下文 ; 4. 如果该函数没有返回对象,则返回this。
MDN的描述有些拗口,我来解释下:
- 创建一个空的简单对象{};
- 将这个空对象的构造函数指定为
new
操作符操作的函数(即定义中说的另一个对象)。其实就是原型链绑定,相关知识可以看我的另一篇文章原型与继承; - 将操作符操作的函数的this指向步骤1创建的空对象;
- 运行该函数,如果该函数没有返回对象,则返回this。
看下代码:
function myNew (fn, ...args) {
// 第一步,创建一个空的简单JavaScript对象(即{});
let obj = {}
// 第二步,原型链绑定
fn.prototype !== null && (obj.__proto__ = fn.prototype)
// 第三步,改变this指向并运行该函数
let ret = fn.call(obj, ...args)
// 第四步,如果该函数没有返回对象,则返回this
// 别忘了 typeof null 也返回 'object' 的bug
if ((typeof ret === 'object' || typeof ret === 'function') && ret !== null) {
return ret
}
return obj
}
bind
好了,让我们来看看bind
Function.prototype.bind
语法
function.bind(thisArg[,arg1[,arg2[, ...]]])
参数
thisArg
在fun运行时指定的this
值。
arg1, arg2, ...
给到fun
的参数列表。
返回值
返回一个原函数的拷贝,并拥有指定的this
值和初始参数。
手写实现bind
bind和call、apply能力一样,都是改变某个函数内部的this指向
不同的是bind并不是立即调用该函数,而是返回一个原函数的拷贝
下面我们来一步一步实现一个bind
第一步
首先,看看bind做了些什么:bind返回一个改变了this
指向的函数,该函数是原函数的拷贝,并且可以带入部分初始参数
Function.prototype.bind = function (context, ...outerArgs) {
return (...innerArgs) => {
this.call(context, ...outerArgs, ...innerArgs)
}
}
实现很简单,我们返回一个函数,里面使用call
更改this
指向就好了
第二步
其实事情并没有这么简单,由于bind会返回一个函数,理所当然的可以对其使用new操作符
如果你对bind
返回的函数使用new
操作符,会发现有些问题
首先你会遇到报错
TypeError: thovinoEat is not a constructor
这是因为上面我用了箭头函数,new
操作符无法改变this
指向了
修改一下:
Function.prototype.bind = function (context, ...outerArgs) {
let that = this;
return function (...innerArgs) {
that.call(context, ...outerArgs, ...innerArgs)
}
}
接着我们来测试一下
// 声明一个上下文
let thovino = {
name: 'thovino'
}
// 声明一个构造函数
let eat = function (food) {
this.food = food
console.log(`${this.name} eat ${this.food}`)
}
eat.prototype.sayFuncName = function () {
console.log('func name : eat')
}
// bind一下
let thovinoEat = eat.bind(thovino)
let instance = new thovinoEat('orange') // thovino eat orange
console.log('instance:', instance) // {}
运行一下,你会发现好像有些问题。生成的实例居然是个空对象!
不要着急,一步一步分析一下为什么
还记得new
干了哪些事情吗?
在new
操作符执行时,我们的thovinoEat
函数可以看作是这样:
function thovinoEat (...innerArgs) {
eat.call(thovino, ...outerArgs, ...innerArgs)
}
在new操作符进行到第三步的操作thovinoEat.call(obj, ...args)
时,这里的obj
是new操作符自己创建的那个简单空对象{}
,但它其实并没有替换掉thovinoEat
函数内部的那个上下文对象thovino
。这已经超出了call
的能力范围,因为这个时候要替换的已经不是thovinoEat
函数内部的this
指向,而应该是thovino
对象。
换句话说,我们希望的是new
操作符将eat
内的this
指向操作符自己创建的那个空对象。但是实际上指向了thovino
,new
操作符的第三步动作并没有成功!
清楚这一点之后,我们就知道应该如何进行修改了:
Function.prototype.bind = function (context, ...outerArgs) {
let that = this;
function ret (...innerArgs) {
if (this instanceof ret) {
// new操作符执行时
// 这里的this在new操作符第三步操作时,会指向new自身创建的那个简单空对象{}
that.call(this, ...outerArgs, ...innerArgs)
} else {
// 普通bind
that.call(context, ...outerArgs, ...innerArgs)
}
}
return ret
}
第三步
用回第二步的测试代码,发现还有最后一个小问题没有解决,那就是eat.prototype.sayFuncName
函数没有继承到。
要解决这个问题非常简单,只需要将返回的函数链接上被调用函数的原型就可以实现方法继承了:
Function.prototype.bind = function (context, ...outerArgs) {
let that = this;
function ret (...innerArgs) {
if (this instanceof ret) {
// new操作符执行时
// 这里的this在new操作符第三步操作时,会指向new自身创建的那个简单空对象{}
that.call(this, ...outerArgs, ...innerArgs)
} else {
// 普通bind
that.call(context, ...outerArgs, ...innerArgs)
}
}
ret.prototype = this.prototype
return ret
}
这里我写的非常原始,是因为这样保留了实现步骤,让大家更好的看懂bind
的实现原理。
至此,整个bind
实现完成,剩下的就是如何精简代码,将代码写的更优雅一些。
如果有任何疑问或错误,欢迎留言进行提问或给予修正意见。
如果喜欢或对你有所帮助,欢迎Star我的博客,对作者是一种鼓励和推进。
也欢迎关注我的掘金,浏览更多优质文章。