JS 中改变 this 的指向有三个方法
- call
- apply
- bind
call 和 apply 相对来说使用较多, 唯一区别就是 call 的参数是单个的, 而 apply 的参数必须是数组, 不过这两个函数在本文里不多介绍, 有兴趣的小伙伴去看 MDN
面试常问
bind 这个函数说实话我用的不多, 尤其 ES6 出来之后用 this 的机会也少了很多, 不过今天偶然看到一篇文章来讲 bind 的实现, 然后也搜索了相关源码, 发现这个实现 bind 的过程真的有很多的知识点可以学习, 不愧是面试常问的高频题
但是大部分的文章讲到 new 实现的时候很不清楚, 有些文章直接就跳过这个 bug, 思前想后我还是要发表我的理解, 希望可以帮助到你
bind的定义及使用
在实现 Bind 函数之前我们首先得来了解它的定义以及应用场景
mdn 上这样定义:
bind()方法创建一个新的函数,在bind()被调用时,这个新函数的this被指定为bind()的第一个参数,而其余参数将作为新函数的参数,供调用时使用。
看看他是怎么使用的
let myName = function(){
console.log(this);
}
myName() // window
myName.bind({})() // {}
我们声明一个函数, 然后把它的 this 打印出来看看
第一次是打印为 window, 如果你掌握了 this, 那么这里不难理解, 如果一个函数调用时没有上下文, 那么在非严格模式下 this 的指向就会成为 window, 在严格模式下 this 的指向为 global
第二次打印为 {}, 这是因为我们用 bind 函数为 myName 绑定了一个上下文 {}, 所以在调用的时候里面的 this 就指向了 {}, 可能有些小伙伴不清楚为什么我在 bind 函数后又加了一个括号, 那么我分开来写一下
let myName = function(){
console.log(this);
}
myName()
let myNameBind = myName.bind({})
myNameBind()
其实就是省去了定义一个变量的作用, 所以下面代码我就简写直接在 bind 的后面调用函数了
使用扩展运算符实现 bind 函数
我们在 Function 的原型上实现 myBind 函数, 这样所有的函数都可以使用
Function.prototype.myBind = function(context, ...args1){
const that = this
return function(...args2){
return that.apply(context,[...args1,...args2])
}
}
function myName(){
console.log(this.name)
}
myName.myBind({name:'meng'})() // meng
myBind 函数第一个参数就是上下文, 通俗的来讲就是函数委托的 this 指向, 那么剩下的参数我们也不确定, 因为 bind 使用在不同的函数上的, 所以他们的参数个数不能确定, 于是我们就用 arguments 数组来表示参数个数, 如果你不知道这个是什么, 去看 mdn
当然参数的名字不一定非得叫 arguments, 参数的名字只是一个代号, 我就取了 args1
原函数(myName)的 this 存到了 that 变量里, 形成一个闭包防止 this 丢失
之后就可以 return 一个函数, 这个函数就可以把所有的参数传进去并且用原函数(myName)的 this 指向来调用自己, 使用 apply 方法来委托给 context
这样就可以完成大部分的 bind 操作, 接下来我们用 ES5 来重写 bind 函数
转换为 ES5 语法(为了兼容)
Function.prototype.myBind = function(){
var args1 = Array.prototype.slice.call(arguments) // slice 实现拷贝
var context = args1.splice(0,1)[0] // splice 会改变原数组
var that = this
return function(){
var args2 = Array.prototype.slice.call(arguments)
return that.apply(context,args1.concat(args2))
}
}
function myName(){
console.log(this.name)
}
myName.myBind({name:'meng'})() // meng
改成 ES5 的写法我当时就很奇怪, 为什么要用 slice 方法, 为什么只用 splice 方法就而不用取出除了第一个参数的另外参数, 这样就直接把 context 也带到参数里了
splice 方法可以改变原数组, 在赋值给 context 的时候 args1 就已经改变了, 所以下面就不需要在去操作, 直接 concat
有了这些解释其实转 ES5 就是换了些 API 而已, 只要明白他到底怎么运作的, 怎么写都行
new 出来的 bug
在 myBind 函数里还有一个 bug, 就是在运用 new 来创建实例时, 还是引用上次 youName 传入的上下文, 我们改一下测试用例
// 沿用上面的 myBind 函数
function youName(name){
this.name = name
}
let obj = {}
//上下文 功能 done
let callback = youName.myBind(obj)
callback('jack')
console.log(obj.name) //'jack'
let newName = new callback('xiang')
console.log(obj.name) // xiang
console.log(newName.name) // undefined
按理说执行 new 操作的时候会产生一个新的实例, 那么就应该生成一个新的对象, 然后把 name 改成 xiang, 但是测试结果却是 obj 的 name 改了, newName 打印出 undefined
所以我们需要在 myBind 函数里面判断操作是否为 new 操作
在修改之前先看看 new 的时候会发生什么
const child = new Father()
child.__proto__ = Father.prototype
在 new 的时候 Father 把原型链赋值给 child, 所以 child 才算是 Father 构造出来的一个实例
修改 myBind:
Function.prototype.myBind = function(){
var args1 = Array.prototype.slice.call(arguments) // slice 实现拷贝
var context = args1.splice(0,1)[0] // splice 会改变原数组
var that = this
var returnFunc = function(){
var args2 = Array.prototype.slice.call(arguments)
// 修改开始
return that.apply(this instanceof that? this : context,args1.concat(args2))
// 修改结束
}
// 修改开始
returnFunc.prototype = this.prototype
// 修改结束
return returnFunc
}
修改了两处:
- 判断当前的 this 是否与调用 bind 的函数 this 相同, 如果相同, 那么就才 apply 函数直接把 this 赋给第一个参数
- 把调用 bind 的函数原型赋给返回函数上
这里也会有点绕, 不过没关系, 我在举一个例子
烤肉拌饭的老板因为顾客少于是就把外卖员辞退了, 他就自己负责去接单送外卖, 当有外卖订单来时 (new), 客户以为的是外卖员送 ( bind 返回的函数), 但实际是老板 (youName) 去送, 所以在送的过程中 (myBind 中判断 this 是否指向 youName 的 this) , 如果是老板自己送, 那么就直接给老板钱, 否则 (this 不指向 youName 的 this) 那么就给外卖员钱
在 new 的过程中, 看似 new 的是 bind 的返回函数, 但是实际是 youName, 因为 bind 只是起到了一个中间人的作用, 实际起作用的还是原函数 youName
因为 new 的是 callback , 但实际起作用的是 youName, 所以要把 youName 的原型赋值给 callback 的原型, 这样就出现了赋值原型的代码
进一步改进
关于原型赋值, 其实是很不妥的行为, 因为原型是对象, 赋值会导致地址一样, 会导致引用重复
这样会有隐患, 所以我们用一个空函数来实现继承就好了
最后还要加上判断, 调用 myBind 是否为函数, 如果不为函数则 return
最终代码:
Function.prototype.myBind = function(){
// 修改开始
if (typeof this !== 'function') return
// 修改结束
var args1 = Array.prototype.slice.call(arguments) // slice 实现拷贝
var context = args1.splice(0,1)[0] // splice 会改变原数组
var that = this
// 修改开始
var extendFunc = function(){}
// 修改结束
var returnFunc = function(){
var args2 = Array.prototype.slice.call(arguments)
return that.apply(this instanceof that? this : context,args1.concat(args2))
}
// 修改开始
if(this.prototype){
extendFunc.prototype = this.prototype
}
returnFunc.prototype = new extendFunc()
// 修改结束
return returnFunc
}
到这一个完整的 bind 函数就完成了, 来总结一下都用到了哪些知识点
- apply
- call
- Array.prototype.slice
- Array.prototype.splice
- Array.prototype.concat
- 原型链和继承
- this
- arguments
- 扩展运算符
以上九个知识点我都附上了 mdn 地址, 如果你哪一个知识点不熟悉, 可以去看看, 这些知识点都是 JS 基础, 但是也是最重要的几个知识点, 比如说 this, 原型, 继承, 这三个已经算是 JS 中的核心内容了, 没想到一个 bind 函数会这么复杂吧, ^_^