「这是我参与2022首次更文挑战的第3天,活动详情查看:2022首次更文挑战」。
前言
关于手写实现 JavaScript api 是一件非常有意思的事情,通过自己手写实现能够更好的掌握 JavaScript 这门语言。手写到了极点就是像 babel 预先支持了还在提案中的新的api。
call,apply,bind 的手写是很经典的题目,这三个方法是改变上下文的 this 来改变方法的操作目标。但是 this 怎么才会改变呢,话不多说先看代码,再解释原理。
实现
用过这三个方法的都会知道,他们之间的差异是一个递增的关系,所以只要实现 call,后面的两个只要基于 call,调整一下执行过程就可以了。
call
function call(fn, ctx, ...args) {
if (ctx == undefined) {
return fn(...args)
}
let res
const b = Symbol()
const prototype = Object.getPrototypeOf(ctx)
prototype[b] = fn
res = ctx[b](...args)
delete prototype[b]
return res
}
console.log(call(Array.prototype.slice, '123', 2)) // '3'
console.log(call(Object.prototype.toString, 1)) // [object Number]
console.log(call(Object.prototype.toString, Symbol())) // [object Symbol]
apply
function apply(fn, ctx, args) {
return call(fn, ctx, ...args)
}
// 例子
function ko(a, b) {
return this.a + a + b
}
console.log(apply(ko, { a: 2 }, [1, 2])) // 5
bind
function bind(fn, ctx) {
return (...args) => call(fn, ctx, ...args)
}
console.log(bind(ko, { a: '2' })(1, 2)) // '212'
原理
this 是怎么指向的
实现起来其实代码也不是很多,而且主要是 call 的实现。那么先解释一下 call 吧。这个要从 this 是怎么来的说起。this 是函数执行时所处的上下文环境,但是要做一个概念的明晰,不是函数在哪里调用所处的环境就是哪里,而是看函数是怎么读取内存中的函数。比如以下的代码。
function foo() {
bar() // 这个 bar 里面的 this 是 window
obj.bar() // 这里 bar 的 this 才是 obj
}
如果函数是读取了之后赋值给变量,那么调用这个函数的时候,是直接从内存中把这个函数取出来,函数没有绑定任何的上下文环境,所以采用默认的全局上下文 window。当 obj.bar 的时候,函数是从 obj 中存的函数的内存地址来读取这个函数的,所以函数处在 obj 之中,this 自然就是 obj。
这种叫做 this 的隐式指向。除此之外还要 new 指向,call,apply,bind等显式指向,如果光执行函数则是默认指向。
对象类型上下文时的 call 实现
因为要实现 call,apply,bind 那自然不能使用他们来实现他们。所以我们采用隐式指向的方法来实现。只要我们将函数赋值到我们想绑的上下文上,然后 ctx.fn 调用就会得到想要的 this。
但是这种方法只适合对象类型的上下文,比如 [], {}, function() {}。因为属性挂上去之后就一定能够调用。
基本数据类型为上下文时的 call 实现
但是我们在基本数据类型上没有办法赋值方法,这是因为包装类的存在。
什么是包装类呢?我们有时候执行 'foo'.replace('f', 'a') 能得到 'aoo'。
但是 . 是对象读取属性的语法,'foo' 明明是字符串, typeof 'foo' --> 'string', 'foo' instanceof String --> false。种种迹象表明了 'foo' 不是对象。
但是我们能读到 String 的原型上的方法,这是因为在 . 的时候,会自动帮我们包装一个实例对象,然后读到里面的方法进行调用。就类似于下面的代码。
'foo'.replace('f', 'a')
// 等价于
const temp = new String('foo')
temp.replace('f', 'a')
temp = null
因为包装类的存在所以我们采用 obj.bar 的想法就会落空,因为包装类是执行这一条语句时临时生成的,执行完就会回收。所以我们下次在 obj.bar 的时候就拿不到之前赋的值。
// 这两条语句会进行两次不同的包装
'foo'.replace('f', 'a')
'foo'.replace('f', 'a')
那基本数据类型可以作为上下文进行 call 绑定吗?
Object.prototype.toString.call(1) // '[object Number]'
Array.prototype.slice.call('123', 2) // '3'
看起来 call 是实现了绑定基本数据类型,那我们该用什么办法来实现自己的 call 呢?从上面一路推导下来,根据 this 指向的几种方法,我们得出一个基础的结论就是我们只能通过这种 obj.bar 隐式指向的方法来修改 this。
既然包装类是会改变的,那么我们就去寻找不变的东西,不变的东西就是对象。那么基本数据类型有什么对象呢?还真的有,那就是他们的构造函数的原型,包装类也是读原型上的方法嘛,那办法不就有了。只要我们在原型上进行赋值,就能通过 隐式原型链 实现 obj.bar。
所以在最开头代码实现里面,我们对 ctx 进行了读取原型的操作。Object.getPropertyOf(ctx)。然后将要执行的方法赋值到原型上。
而这样的方法对对象类型的上下文也适用,所以我们可以做同一个操作,这样就不用做传进来的上下文类型判断了。
这样就实现了基础的 call。然后 apply 和 bind 都能基于 call 实现。
call 的细节
最后聊一个细节,为什么在赋值的时候用了 symbol?
这是利用了 symbol 的特性,看到这可以想一想为什么,下面会揭晓答案。
答案是利用了 symbol 的唯一性,因为我们用这种隐式指向的方法的话,我们不知道他传进来的上下文会有什么属性,假如他传进来 { a: 1 },我们赋值 ctx.a = fn 的话就直接把别人的 a 给覆盖了。
但可能有人说,我们不赋值 a 就可以了啊,我们赋值别的,例如 __sndofsfma 不就行了。
只能说要这么整,也不是不行。你开心就好。但仍然有冲突的概率,如果我们有一个冲突概率为 0 的办法,为什么不选呢。
最后
短短的一个 call,apply,bind 的实现就已经涉及了这么多基础知识,用到了 包装类, this指向, 甚至 bind 还用到了闭包。不仅写的时候学到了不少,手写本身还是很有趣的。