序言
不知其理,何以談用,在這里簡單記錄一下個人對call、apply、bind的理解,並根據理解思路實現一下。
眾所周知 call、apply、bind 的作用都是‘改變’作用域,但是網上對這這‘改變’說得含糊其辭,並未做詳細說明,‘改變’是直接替換作用域?誰替換誰?怎么產生效果?這些問題如果不理解清楚,就算看過手寫實現,估計也記不長久,基於此,這里做簡單記錄,以免時間過長遺忘,方便回顧。
call
call() 方法使用一個指定的 this 值和單獨給出的一個或多個參數來調用一個函數
這就是 MDN 上給出的描述,看得一臉懵逼的我只能自己一步一步去剖析,一波分析下來其實發現 call 的作用無非就是當執行一個方法的時候希望能夠使用另一個對象來作為作用域對象而已,簡單來說就是當我執行 A 方法的時候,希望通過傳入參數的形式將一個對象 B 傳進去,用以將 A 方法的作用域對象替換為對象 B
知道了這個那么就一個進一步解析如何去實現,如下有一段簡單的代碼:
function consoleLog () {
console.log('輸出:' + this.value)
}
let tempObj = {
value: '渣渣逆天'
}
tempObj.fn = consoleLog
tempObj.fn()
// 輸出:'渣渣逆天'
咋一看這是什么鬼,好像和我們要提的東西毫無關聯,但仔細看會發現如果我們直接執行 consoleLog 方法,作用域就是window 對象,但是當我們綁定到另一個對象上時,作用域就被替換了,這和 call 的中心思想何其相似,當執行一個方法的時候希望將其方法的作用域對象替換為另一個對象
再一看 call 的使用方式:
function Product(name, price) {
this.name = name
this.price = price
}
function Food(name, price) {
// 調用Product的call方法並將當前作用域對象this傳入替換掉Product內部的this作用域對象
Product.call(this, name, price)
this.category = 'food'
}
let tempFood = new Food('cheese', 5)
console.log('輸出:' + tempFood.name, tempFood.price, tempFood.category)
// 輸出:cheese 5 food
這是一個簡單使用 call 來實現的繼承操作,在上面我們可以看到 Food 這個構造函數它自身並沒有定義 name 和 price 這兩個屬性,重點就在 Product.call(this, name, price) 這一行代碼上面,調用 Product 的 call (調用 call 方法會調用一遍自身)方法並將當前作用域對象 this 傳入替換掉 Product 內部的 this 作用域對象,都知道當對象作為參數的時候都是地址傳遞,對任何一個引用修改都會修改到源對象,所以這里最終 new 出來的對象就有了 name 和 price 屬性
把上面了解一波后 call 的大體功能及設計思路應該都有了一定的了解
接下來看一下具體的實現:
Function.prototype.imitateCall = function (context) {
// 賦值作用域參數,如果沒有則默認為 window,即訪問全局作用域對象
context = context || window
// 綁定調用函數(.call之前的方法即this,前面提到過調用call方法會調用一遍自身,所以這里要存下來)
context.invokFn = this
// 截取作用域對象參數后面的參數
let args = [...arguments].slice(1)
// 執行調用函數,記錄拿取返回值
let result = context.invokFn(...args)
// 銷毀調用函數,以免作用域污染
Reflect.deleteProperty(context, 'invokFn')
return result
}
都是實踐是檢驗真理的唯一標准,那么就拉出來溜溜看
function Product(name, price) {
this.name = name
this.price = price
}
function Food(name, price) {
Product.imitateCall(this, name, price)
this.category = 'food'
}
let tempFood = new Food('cheese', 5)
console.log('輸出:' + tempFood.name, tempFood.price, tempFood.category)
// 輸出:cheese 5 food
perfect,十分完美
apply
使用過的人應該都知道,apply 和 call 的功能完全一致,區別唯有使用上的一絲絲差別
Function.prototype.call = function(context, args1, args2, args3 ...)
Function.prototype.apply = function(context, [args1, args2, args3 ...])
很明顯了吧,唯參數形式不同而已,依此只需要稍微改動 imitateCall 方法即可模擬出我們的 imitateApply 方法
Function.prototype.imitateApply = function (context) {
// 賦值作用域參數,如果沒有則默認為 window,即訪問全局作用域對象
context = context || window
// 綁定調用函數(.call之前的方法即this,前面提到過調用call方法會調用一遍自身,所以這里要存下來)
context.invokFn = this
// 執行調用函數,需要對是否有參數做判斷,記錄拿取返回值
let result
if (arguments[1]) {
result = context.invokFn(...arguments[1])
} else {
result = context.invokFn()
}
// 銷毀調用函數,以免作用域污染
Reflect.deleteProperty(context, 'invokFn')
return result
}
bind
bind() 方法創建一個新的函數,在 bind() 被調用時,這個新函數的 this 被bind的第一個參數指定,其余的參數將作為新函數的參數供調用時使用
這同樣是 MDN 上給出的解釋,意思應該已經很明顯了,和 call 方法類似,調用是都是將內部的 this 作用域對象替換為第一個參數,不過需要注意開始和結尾,調用 bind 方法時會創建一個新的函數返回待調用
先看個例子
const people = {
names: '渣渣逆天',
getName: function () {
return this.names
}
}
const temp = people.getName
console.log('輸出:' + temp())
// 輸出:undefined
在慣性思維下我想大多數看到這個問題的人都會得出 輸出:'渣渣逆天'
的答案,仔細看會發現,雖然這里的 temp 方法和 getName 方法指向同一個對地址(即同一段代碼塊),但是這里的 temp 的調用者是 window,然而在 window 對象上找 names 屬性就會發現是 undefined
那看看綁定 bind 后的效果
const people = {
names: '渣渣逆天',
getName: function () {
return this.names
}
}
const temp = people.getName,
context = temp.bind(people)
console.log('輸出:' + context())
// 輸出:渣渣逆天
這里就完美詮釋了來自 MDN 的解釋,需要注意的是這里新建並被返回的方法當被執行時,綁定 bind 的原方法將被調用,並將原方法內部作用域對象替換為綁定 bind 時傳入的第一個參數,即然如此,應該能聯想到 bind 的實現離不開 call 或 apply
有了上面的分析,那么接着來看看如何實現
Function.prototype.imitateBind = function (context) {
// 獲取綁定時的傳參
let args = [...arguments].slice(1),
// 定義中轉構造函數,用於通過原型連接綁定后的函數和調用bind的函數
F = function () {},
// 記錄調用函數,生成閉包,用於返回函數被調用時執行
self = this,
// 定義返回(綁定)函數
bound = function () {
// 合並參數,綁定時和調用時分別傳入的
let finalArgs = [...args, ...arguments]
// 改變作用域,注:aplly/call是立即執行函數,即綁定會直接調用
// 這里之所以要使用instanceof做判斷,是要區分是不是new xxx()調用的bind方法
return self.call((this instanceof F ? this : context), ...finalArgs)
}
// 將調用函數的原型賦值到中轉函數的原型上
F.prototype = self.prototype
// 通過原型的方式繼承調用函數的原型
bound.prototype = new F()
return bound
}
這是《JavaScript Web Application》一書中對 bind() 的實現:通過設置一個中轉構造函數 F,使綁定后的函數與調用 bind() 的函數處於同一原型鏈上,用 new 操作符調用綁定后的函數,返回的對象也能正常使用 instanceof,因此這是最嚴謹的 bind() 實現
注:綁定函數內部具體選擇使用 call 還是 apply 來實現並沒有明文規定,個人猜想是 ES6 的展開運算符未普及時,已有人實現該方法,ES5 下使用 apply 來實現確實簡潔一些,然后網上一些人抄來抄去的不加以思考就直接使用 apply 了,個人感覺 getInfo(name, age)
模式比 getInfo([name, age])
更常規,就算參數格式不確定也可以使用不定參來進行獲取,所以這里改用 call 實現
遛遛效果如何
const people = {
names: '渣渣逆天',
getName: function (surname) {
return surname + this.names
}
}
const temp = people.getName,
context = temp.imitateBind(people)
console.log('輸出:' + context('屌絲'))
// 輸出:屌絲渣渣逆天
總結
既然 call/apply 和 bind 的功能如此相似,那什么時候該使用 call、apply,什么時候使用 bind 呢?其實這個也沒有明確的規定,一通百通而已,只要知其理,相互轉化何其簡單,主要的區別無非就是 call/apply 綁定后是立即執行,而 bind 綁定后是返回引用待調用
就像這樣
const people = {
age: 18
};
const girl = {
getAge: function() {
return this.age;
}
}
console.log('輸出:' + girl.getAge.bind(people)()); // 輸出:18
console.log('輸出:' + girl.getAge.call(people)); // 輸出:18
console.log('輸出:' + girl.getAge.apply(people)); // 輸出:18
一次看到個有趣的問題是如果多次 bind 呢,會有什么效果?
const people1 = {
age: 18
}
const people2 = {
age: 19
}
const people3 = {
age: 20
}
const girl = {
getAge: function() {
return this.age
}
}
const callFn = girl.getAge.bind(people1)
const callFn1 = girl.getAge.bind(people1).bind(people2)
const callFn2 = girl.getAge.bind(people1).bind(people2).bind(people3)
console.log(callFn(), callFn1(), callFn2())
// 18 18 18
這里都輸出 18 ,而沒有期待中的 19 和 20 ,原因是在 Javascript 中,多次 bind() 是無效的。更深層次的原因, bind() 的實現,相當於使用函數在內部包了一個 call / apply ,第二次 bind() 相當於再包住第一次 bind() ,故第二次及以后的 bind 是無法生效的
再看一段示例
const tempFn = function () {
console.log(this, [...arguments])
}
const cont1 = tempFn.bind({
name: '渣渣逆天'
}, 1)
cont1.call({
age: 24
}, 2)
// {name: "渣渣逆天"} [1]
const cont2 = cont1.bind({
apper: 'bueaty'
}, 2)
cont2()
// {name: "渣渣逆天"} [1, 2]
const cont3 = cont2.bind({
fat: 'thin'
}, 3)
cont3()
// {name: "渣渣逆天"} [1, 2, 3]
從上面的代碼執行結果中我們發現一點,第一次 bind 綁定的對象是固定的,也就是后面通過 bind 或者 call 再次綁定的時候,就無法修改這個 this 了,從 ES5 文檔中我們能找到答案
When the [[Call]] internal method of a function object, F, which was created using the bind function is called with a this value and a list of arguments ExtraArgs, the following steps are taken:
Let boundArgs be the value of F’s [[BoundArgs]] internal property.
Let boundThis be the value of F’s [[BoundThis]] internal property.
Let target be the value of F’s [[TargetFunction]] internal property.
Let args be a new list containing the same values as the list boundArgs in the same order followed by the same values as the list ExtraArgs in the same order.
Return the result of calling the [[Call]] internal method of target providing boundThis as the this value and providing args as the arguments.
這段話中說到如果我們在一個由 bind 創建的函數中調用 call,假設是 x.call(obj, y, z, …) 並且傳入 this,和參數列表的時候會執行下面的步驟:
- 首先用三個參數分別保存函數x函數的內部屬性中存的this值、目標函數和參數 列表。
- 然后執行目標函數的內部 call 函數,也就是執行目標函數的代碼,並且傳入1中保存的 this 和實參(這里的實參是目標函數本來就有的也就是 bind 時傳入的實參加上調用 call 時傳的實參)
重點在1中,從 ES5 的 bind 函數說明中我們知道,當我們用一個函數調用 bind 的時候,返回的函數中會保存這三個參數。所以最后調用 call 的時候執行的函數是目標函數,也就是調用了 bind 的函數,傳入的 this 也是 bind 調用時傳入的,這些都是無法被修改的了,但是參數是調用 bind 和 call 時的疊加,這是我們唯一可以修改的地方。執行兩次 bind 的原理可以參考 bind 的源碼,和 call 的差不多,也是目標函數和 this 是被固定的了,只有參數列表會疊加