詳解 new/bind/apply/call 的模擬實現
先看一下真正的new
的使用方法:
function MyClass(name, age){ this.name = name this.age = age } var obj = new MyClass({name:'asd', age:10}) 複製程式碼
new
是關鍵字,呼叫方式是沒法模仿的,只能以函式的形式實現,比如myNew()
。
然後規定一下myNew
接收引數的方式:
var obj2 = mynew(MyClass, 'asd', 10) 複製程式碼
第一階段:基本實現
建立一個新物件,通過將其__proto__
指向建構函式的prototype
實現繼承
function mynew(){ // 新建空物件 var obj = {} // 第一個引數是建構函式 var constructor = [].shift.call(arguments) // 其餘的引數是建構函式的引數 var args = [].slice.call(arguments) // 修改原型 obj.__proto__ = constructor.prototype // 修改建構函式上下文,為 obj 賦值 constructor.apply(obj, args) return obj } 複製程式碼
[].slice.call()
就是Array.prototype.slice.call()
第二階段:實現返回值
建構函式也是函式,也可能有返回值。
而new
有一個特性:建構函式返回值為基本型別值時,不返回;引用型別值時,返回。
只要判斷constructor.apply()
的結果即可:
function mynew(){ var obj = {} var constructor = [].shift.call(arguments) var args = [].slice.call(arguments) obj.__proto__ = constructor.prototype var result = constructor.apply(obj, args) // 判斷結果的型別 return (typeof result === 'object' || 'function') ? result : obj } 複製程式碼
第三階段:細節
-
返回值的判斷
前面的程式碼在判斷返回值時有問題,因為
typeof null === "object"
。修改一下:return (typeof result === 'object' || 'function') ? result||obj : obj 複製程式碼
-
建立空物件以及實現繼承的方式
建立空物件有三種方法:
var obj = new Object() var obj = {} Object.create()
前兩種是相同的,但是考慮到這是模擬
new
,所以第一種不太合適。實現繼承有兩種方法:
var obj = Object.create(constructor.prototype) obj.__proto__ = constructor.prototype
第一種在建立物件時直接繼承。
第二種先建立物件,再設定原型。 要注意:這時不能通過
Object.create(null)
來建立物件 ,可以參考這個 ISSUE 。如果使用
Object.create(null)
,訪問不到__proto__
這個原型屬性 ,因此在後續賦值時,__proto__
被當做普通屬性 進行賦值。
參考連結
bind 的模擬實現
是用apply
或call
來實現的。
注意apply
和call
的區別
先大致回顧一下bind
的用法:
name = 'global' function test(sex, age) { console.log(this.name, sex, age) return 'return value' } obj = {name: 'asd'} testBinded = test.bind(obj, 'M') console.log(testBinded(10)) // 輸出: // asd M 10 // return value 複製程式碼
第一階段:基本實現
Function.prototype.bind2 = function () { // this 即將要執行 bind 的函式 var self = this // 傳入的第一個引數是新的上下文 var context = arguments[0] // 返回一個閉包,繫結之後的函式 return function () { // 原函式可能有返回值,所以這裡返回 apply 之後的結果 return self.apply(context) } } 複製程式碼
第二階段:實現引數傳遞
bind()
可以在繫結時給原函式傳遞引數,繫結之後的函式執行時還可以再次傳遞引數。
可以順便學習一下柯里化
Function.prototype.bind2 = function () { var self = this // bind 時第一個引數是新的上下文 var context = [].shift.call(arguments) // 其餘的引數是傳遞給原函式的引數 var args1 = [].slice.call(arguments) return function () { // bind 後的函式執行時傳入的引數 var args2 = [].slice.call(arguments) // 合併引數 return self.apply(context, args1.concat(args2)) } } 複製程式碼
第三階段:實現建構函式效果
一個函式執行bind()
後,如果使用new
呼叫,即當做建構函式,那麼:
-
bind()
時傳入的上下文context
會失效 -
但是兩次傳入的引數
args
仍然有效
第一次看到這個的時候,想的是,bind()
已經執行完了,之後怎麼呼叫跟bind()
的實現有什麼關係?
你們抓的是周樹人,跟我魯迅有什麼關係?
關係在於,
bind()
返回的是閉包,函式並沒有執行
。
在前面new
的模擬實現裡,需要通過apply()
改變建構函式的上下文,在這裡建構函式就是bind()
之後的函式。
但是看一下上面bind2()
的實現,返回函式時,直接把上下文設定為了執行bind2()
時傳入的context
,根本沒判斷這個函式是不是接受了新的上下文
。
所以修改的方法是,在bind2()
中獲取this
,也就是apply()
傳入的上下文(如果有的話),並判斷。
Function.prototype.bind2 = function () { var self = this var context = [].shift.call(arguments) var args1 = [].slice.call(arguments) var result = function () { var args2 = Array.prototype.slice.call(arguments) // 如果 this 是 result 這個函式的例項,說明 result 作為建構函式被呼叫了 var context = this instanceof result ? this : context return self.apply(context, args1.concat(args2)) } return result } 複製程式碼
第四階段:繼承
bind
還有一些關於繼承的特性。
舉個栗子:
function F1(){} F2 = F1.bind({}) f1 = new F1() f2 = new F2() F1.prototype.name = 'ads' console.log(f2.name) // asd console.log(f2.__proto__ === f1.__proto__) // true console.log(F1.prototype) // {name: "ads", constructor: ƒ} console.log(F2.prototype) // undefined 複製程式碼
即:
-
f1
與f2
,他們的原型物件是相同的,都是原函式的原型F1.prototype
-
但是
F1
與F2
,他們的原型卻是不相同的,並且F2
壓根就沒有原型
先不管第2條。
為了實現第1條,首先想到的就是使F2
與F1
有同樣的原型。也就是說bind2
的程式碼需要加上這麼一行:
result.prototype = self.prototype 複製程式碼
但是存在一個問題,這樣一來可以通過F2.prototype
來修改原型上的屬性,而真正的bind()
是沒有prototype
的,更別說通過prototype
去修改原型上的屬性了。
怎麼辦呢?
不要忘了,現在的目的是讓bind()
之後的的函式能夠訪問原型上的屬性,實現這個目標就可以了。
只需要在原函式和新函式中間再加一層繼承。
下面是最後的程式碼:
Function.prototype.bind2 = function () { var self = this var context = [].shift.call(arguments) var args1 = [].slice.call(arguments) var result = function () { var args2 = Array.prototype.slice.call(arguments) var context = this instanceof result ? this : context return self.apply(context, args1.concat(args2)) } // 新建一個你爸 var Agent = function () {} // 讓你爸繼承原函式的原型,或者說你爺爺 Agent.prototype = self.prototype // 然後你繼承你爸 result.prototype = new Agent() return result } 複製程式碼
至於F2.prototype
應該為undefined
這一點該怎麼搞呢?看下一部分。
MDN 提供的 Polyfill
MDN 提供了一個bind()
的墊片,這裡就不再貼程式碼了,戳連結自己看。
後面緊跟著也說明了這個相容方案的不足之處。
實際上也就是上面手動實現的方案的不足。
參考連結
apply 和 call 的模擬實現
apply()
和call()
只是接收引數的方式不一樣。
這裡以apply()
為例實現一下。call()
的模擬實現可以參考《JavaScript 深入之 call 和 apply 的模擬實現》
。
先回顧一下apply
的效果:
name = 'global' function test(age, sex) { console.log(this.name, age, sex) return 'return value' } console.log(test.apply({name: 'asd'}, [1, 'M'])) // 輸出: // asd 1 M // return value 複製程式碼
第一階段:基本實現
首先,apply()
在給定的上下文中立即執行了一個函式。
而說到“在給定的上下文中執行”,讓人不得不想到把函式作為物件的方法來執行:
obj = { name: 'asd', showName() { console.log(this.name) } } obj.showName() 複製程式碼
那麼第一步可以這樣實現一下:
Function.prototype.apply2 = function () { // 新的上下文,是一個物件 var context = arguments[0] // 把原函式新增為這個物件的方法 context.fn = this // 執行,並且函式可能有返回值 return context.fn() } 複製程式碼
但是這樣有兩個問題:
fn fn
增加了,只要刪掉就好了;而重名的情況,可以用Symbol
解決。
雖然Symbol
是 ES6 的內容,但是不要在意這些細節!
call
還從 ES1 開始就有了呢,又不是從底層重寫,意思意思就行...
Function.prototype.apply2 = function () { var context = arguments[0] // 生成一個唯一的 key,就不會與原物件中其他的 key 衝突了 var symbol = Symbol() context.symbol = this var result = context.symbol() // 最後刪掉 delete context.symbol return result } 複製程式碼
第二階段:實現引數傳遞
apply()
接受兩個引數,第一個引數為新的上下文,第二個是由傳遞給原函式的引數組成的陣列。
獲取引數很簡單,第二個引數就是arguments[1]
。
重點在於,函式接收引數的時候一般是以逗號為分隔符,每個變數挨個放上去的,而不是直接接受一個數組。
可以想到這麼兩種實現方式:
eval()
eval()
接受一個字串,並把字串作為 JS 來執行:
eval("console.log('asd')") // asd 複製程式碼
你以為它是字串,其實是我 JS 噠!
那麼在這裡就改寫成了:
Function.prototype.apply2 = function () { var context = arguments[0] var args_arr = arguments[1] var symbol = Symbol() context.symbol = this // 1. 使用 eval() // 處理引數,字串需要加上雙引號 var args_string = '' args_arr.forEach((val) => { if (typeof val === 'string') args_string += '"' + val + '",' else args_string += val + ',' }) var result = eval('context.symbol(' + args_string + ')') // 2. 或者使用展開運算子 // var result = context.symbol(...args_arr) delete context.symbol return result } 複製程式碼
其實首先想到的是柯里化。
但是回頭一想要實現柯里化好像用到了apply
,那這裡就不合適了。
第三階段:細節
第一個引數也可以是null
,瀏覽器環境下指向window
。只要改一行:
var context = arguments[0] || window 複製程式碼