詳解 new/bind/apply/call 的模擬實現

迪斯馬斯克發表於2019-03-29

分步實現 new/bind/apply/call 函式。

new 的模擬實現

先看一下真正的 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
}
複製程式碼

第三階段:細節

  1. 返回值的判斷

    前面的程式碼在判斷返回值時有問題,因為 typeof null === "object"。修改一下:

    return (typeof result === 'object' || 'function') ? result||obj : obj
    複製程式碼
  2. 建立空物件以及實現繼承的方式

    建立空物件有三種方法:

    • 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__被當做普通屬性進行賦值。

參考連結

JavaScript 深入之 new 的模擬實現
面試官問:能否模擬實現 JS的new操作符

bind 的模擬實現

是用 applycall 來實現的。

注意 applycall 的區別

先大致回顧一下 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 還有一些關於繼承的特性。

舉個栗子:

// 宣告一個建構函式 F1()
function F1(){}
// bind 生成建構函式 F2()
F2 = F1.bind({})
// f1 和 f2 分別是它們的例項
f1 = new F1()
f2 = new F2()
// 在 F1() 上新增原型屬性
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
複製程式碼

即:

  • f1f2,他們的原型物件是相同的,都是原函式的原型 F1.prototype
  • 但是 F1F2 ,他們的原型卻是不相同的,並且 F2 壓根就沒有原型

先不管第2條。
為了實現第1條,首先想到的就是使 F2F1 有同樣的原型。也就是說 bind2 的程式碼需要加上這麼一行:

result.prototype = self.prototype
複製程式碼

但是存在一個問題,這樣一來可以通過 F2.prototype 來修改原型上的屬性,而真正的 bind() 返回的函式是沒有 prototype 的,更別提通過 prototype 去修改原型上的屬性了。

怎麼辦呢?
不要忘了,現在的目的是讓 bind() 之後的函式能夠訪問原函式原型物件上的屬性,實現這個目標就可以了。

而想要訪問原函式的原型物件,不必非得直接基於原函式進行繼承。
因為在原型鏈上尋找屬性時是一級一級向上尋找的,就算最末端的物件與實際想要繼承的原型物件之間隔著 n 層,但是隻要它們在同一條原型鏈上,就可以訪問到原型物件。

所以在這裡,完全可以新建一箇中介函式,並且繼承原函式的原型物件,然後去繼承這個新的函式。
這樣一來,bind() 之後的函式實際上是通過這個中介函式把自己新增到了原函式的原型鏈上。並且因為 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() 的墊片,這裡就不再貼程式碼了,戳連結自己看。

後面緊跟著也說明了這個相容方案的不足之處。
實際上也就是上面手動實現的方案的不足。

參考連結

JavaScript 深入之 bind 的模擬實現
Polyfill - MDN

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()
}
複製程式碼

但是這樣有兩個問題:

  1. 原物件被修改了,增加了一個叫 fn 的方法
  2. 如果原物件裡本來就有一個鍵叫 fn 呢?

增加了,只要刪掉就好了;而重名的情況,可以用 Symbol 解決。

雖然Symbol 是 ES6 的內容,但是不要在意這些細節!
call 還從 ES3 開始就有了呢,又不是從底層重寫,意思意思就行...

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]
重點在於,函式接收引數的時候一般是以逗號為分隔符,每個變數挨個放上去的,而不是直接接受一個陣列。

可以想到這麼兩種實現方式:

  1. eval()
  2. 展開運算子

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
複製程式碼

參考連結

JavaScript 深入之 call 和 apply 的模擬實現

打個廣告

我的其他文章:

超詳細的10種排序演算法原理及 JS 實現》
《免費為網站新增 SSL 證照》
《深入 JavaScript 常用的8種繼承方案》

相關文章