new操作符到底幹了什麼?

MeloGuo發表於2018-07-15

前幾天無意間發現了剛開始學JavaScript時在知乎寫的一些回答,有一個就是講new操作符到底幹了什麼。從現在的視角看我當時的回答雖然是正確的,但是在對原理的剖析和細節的理解上還相去甚遠。所以藉此機會,就想重新梳理一下這一年多來對new操作符理解的變化與成長。

模擬new操作符

第一次去了解new操作符,是在看《JS高階程式設計(第三版)》這本書時,P145是這樣寫到的。

要建立Person的新例項,必須使用new操作符。以這種方式呼叫建構函式實際上會經歷以下4個步驟:

  1. 建立一個新物件;
  2. 將建構函式的作用域賦給新物件(因此this就指向了這個新物件);
  3. 執行建構函式中的程式碼(為這個新物件新增屬性);
  4. 返回新物件;

儘管書中是這樣描述,但是並沒有給出實踐的程式碼,所以我就按照這個步驟自己去實踐模擬一個new操作符。程式碼如下:

var mockNew = function (constructor) {
  var o = {} // 建立一個新物件
  constructor.apply(o, Array.prototype.slice.call(arguments, 1)) // 賦作用域 執行程式碼
  return o // 返回新物件
}
複製程式碼

然後我們用這個模擬new操作符的函式來創造一個物件試試。

var Person = function (name, age) {
  this.name = name
  this.age = age
}

Person.prototype.sayName = function () {
  console.log(this.name)
}

var person1 = mockNew(Person, 'MeloGuo', 22)

console.log(person1.name) // 'MeloGuo'
console.log(person1.age) // 22
person1.sayName() // Uncaught TypeError: person1.sayName is not a function
複製程式碼

看起來我的模擬函式雖然能訪問例項中的屬性,但是卻不能訪問sayName方法,而且當我使用instanceof操作符檢測時卻得到了這樣的結果。

console.log(person1 instanceof Person) // false
console.log(person1 instanceof Object) // true
複製程式碼

改進的模擬函式

可見person1並不是Person的例項。當時的我還不知道問題出在哪裡,直到學習到原型鏈時我才直到mockNew函式缺少了一個步驟,即繫結建構函式原型。所以person1例項是無法訪問到Person原型中的sayName方法,同時instanceof操作符的結果也為false。因為instanceof操作符是用來檢測一個物件在其原型鏈中是否存在一個建構函式的prototype屬性的,而person1的原型鏈中並不存在Person.prototype,所以返回值為false。因此,我們改造mockNew函式如下:

var mockNew = function (constructor) {
  var o = {}
  o.__proto__ = constructor.prototype // 繫結建構函式原型,但是生產程式碼中千萬別用.__proto__
  constructor.apply(o, Array.prototype.slice.call(arguments, 1))
  return o
}
複製程式碼

這時我們再建立例項,然後使用instanceof操作符檢測一下,同時呼叫下sayName方法。

var person1 = mockNew(Person, 'MeloGuo', 22)

console.log(person1 instanceof Person) // true
console.log(person1 instanceof Object) // true
person1.sayName() // 'MeloGuo'
複製程式碼

這時看似mockNew函式已經完全模擬了new操作符了,但是當我們嘗試下面這種情況時,又出現了問題。

function Person (name) {
  this.name = name
  return { age: 22 }
}

var person1 = new Person('MeloGuo')
var person2 = mockNew(Person, 'MeloGuo')

console.log(person1) // {age: 22}
console.log(person2) // Person {name: "MeloGuo"}
console.log(person1 instanceof Person) // false
console.log(person2 instanceof Person) // true
複製程式碼

什麼!!!用new操作符呼叫的Person建構函式並沒有按照預期返回帶有name屬性並且在Person.prototype上的物件,而是返回了我們手動return的帶有age屬性的物件,但是我們的mockNew函式是正常返回了。所以我們的mockNew函式中肯定又丟失了一些細節,為了弄清楚,只好硬著頭皮去讀ECMA-262規範了。看到規範中的steps後才恍然大悟了new操作符的整個執行流程。

new操作符到底幹了什麼?

完善模擬函式

簡單來說我們在返回物件前缺失了判斷返回值型別的步驟。

  • 如果建構函式的返回值是值型別,那麼就丟棄掉,依然返回建構函式建立的例項。
  • 如果建構函式的返回值是引用型別,那麼就返回這個引用型別,丟棄建構函式建立的例項。

注:如果沒有顯示return,那麼相當於隱式返回了undefined,則丟棄它。

吸取了規範中的內容,並且加入ES6語法後,我們新的mockNew函式如下:

function mockNew (constructor, ...args) {
  const isPrimitive = result => {
    // 如果result為值型別則返回true
    // 如果result為引用型別則返回false    
  }

  const o = Object.create(constructor.prototype)
  const result = constructor.apply(o, args)
  
  return isPrimitive(result) ? o : result
}
複製程式碼

這時的mockNew函式可以說是較好的模擬了new操作符的功能。

總結

其實new操作符就是一個語法糖。在傳統的面向類的語言中,“建構函式”是類中的一些特殊方法,使用new初始化類時會呼叫類中的建構函式。而JavaScript中的new其實是用來告訴函式,我要以“構造”的方式呼叫你了,從而向mockNew函式中的流程一樣,得到我們的例項。所以本質上來說JS是沒有所謂建構函式的,有的應該是構造呼叫。這樣的稱呼能讓我們更清楚認識JS中的new操作符。

相關文章