深入 JavaScript 原型繼承原理——babel 編譯碼解讀

Linesh發表於2018-10-22

上一篇文章中,我們提到 ES6 的 class 語法糖是個近乎完美的方案,並且講解了實現繼承的許多內部機制,如 prototype/__proto__/constructor 等等。這篇,我們就以實際的 babel 程式碼為例子,來驗證上節所言不虛。此外,本文還解釋了 React 元件中你需要 bind 一下類方法的原理所在。

原文連結:blog.linesh.tw/#/post/2018…

Github:github.com/linesh-simp…

目錄

  • 無繼承——簡單的 class + 欄位宣告
  • 無繼承——簡單的 class + 方法宣告
  • 簡單繼承——一層繼承 + 欄位覆蓋
  • 無繼承——靜態函式
  • 無繼承——靜態變數
  • 神祕的類 arrow function

無繼承——簡單的 class + 欄位宣告

先來看個最簡單的例子,我們僅僅使用了 class 關鍵字並定義了一個變數:

class Animal {
  constructor(name) {
    this.name = name || 'Kat'
  }
}
複製程式碼

最後 babel 編譯出來的程式碼如下。這裡筆者用的是 Babel 6 的穩定版 6.26,不同版本編譯出來可能有差異,但不至於有大的結構變動。

'use strict'

function _classCallCheck(instance, Constructor) {
  if (!(instance instanceof Constructor)) {
    throw new TypeError('Cannot call a class as a function')
  }
}

var Animal = function Animal(name) {
  _classCallCheck(this, Animal)

  this.name = name || 'Kat'
}
複製程式碼

確實十分簡單,對吧。這段程式碼值得留意的點有兩個:

一個是,使用 class 宣告的 Animal 最後其實是被編譯為一個函式。證明 class 跟類沒關係,只是個語法糖。

另一個地方是,編譯器幫我們插入了一個 _classCallCheck 函式呼叫,它會檢查你有沒有用 new Animal() 操作符來初始化這個函式。若有,則 this 會是被例項化的 Animal 物件,自然能通過 animal instanceof Animal 檢查;若是直接呼叫函式,this 會被初始化為全域性物件,自然不會是 Animal 例項,從而丟擲執行時錯誤。這個檢查,正解決了上一篇文章提到的問題:如果忘記使用 new 去呼叫一個被設計建構函式的函式,沒有任何執行時錯誤的毛病。

無繼承——簡單的 class + 方法宣告

讓我們再擴充套件一下例子,給它加兩個方法。

class Animal {
  constructor(name) {
    this.name = name || 'Kat'
  }

  move() {}
  getName() {
    return this.name
  }
}
複製程式碼
'use strict'

var _createClass = (function() {
  function defineProperties(target, props) {
    for (var i = 0; i < props.length; i++) {
      var descriptor = props[i]
      descriptor.enumerable = descriptor.enumerable || false
      descriptor.configurable = true
      if ('value' in descriptor) descriptor.writable = true
      Object.defineProperty(target, descriptor.key, descriptor)
    }
  }
  return function(Constructor, protoProps, staticProps) {
    if (protoProps) defineProperties(Constructor.prototype, protoProps)
    if (staticProps) defineProperties(Constructor, staticProps)
    return Constructor
  }
})()

function _classCallCheck(instance, Constructor) {
  if (!(instance instanceof Constructor)) {
    throw new TypeError('Cannot call a class as a function')
  }
}

var Animal = (function() {
  function Animal(name) {
    _classCallCheck(this, Animal)

    this.name = name || 'Kat'
  }

  _createClass(Animal, [
    {
      key: 'move',
      value: function move() {},
    },
    {
      key: 'getName',
      value: function getName() {
        return this.name
      },
    },
  ])

  return Animal
})()
複製程式碼

例子長了不少,但其實主要的變化只有兩個:一是 Animal 被包了一層而不是直接返回;二是新增的方法 movegetName 是通過一個 _createClass() 方法來實現的。它將兩個方法以 key/value 的形式作為陣列傳入,看起來,是要把它們設定到 Animal 的原型鏈上面,以便後續繼承之用。

為啥 Animal 被包了一層呢,這是個好問題,但答案我們將留到後文揭曉。現在,我們先看一下這個長長的 _createClass 實現是什麼:

var _createClass = (function() {
  function defineProperties(target, props) {
    for (var i = 0; i < props.length; i++) {
      var descriptor = props[i]
      descriptor.enumerable = descriptor.enumerable || false
      descriptor.configurable = true
      if ('value' in descriptor) descriptor.writable = true
      Object.defineProperty(target, descriptor.key, descriptor)
    }
  }

  return function(Constructor, protoProps, staticProps) {
    if (protoProps) defineProperties(Constructor.prototype, protoProps)
    if (staticProps) defineProperties(Constructor, staticProps)
    return Constructor
  }
})()
複製程式碼

它是個立即執行函式,執行又返回了另一個函式。說明啥,一定用了閉包,說明裡面要封裝些「私有」變數,那就是 defineProperties 這個函式。這很好,一是這個函式只會生成一次,二是明確了這個函式只與 _createClass 這個事情相關。

再細看這個返回的函式,接受 ConstructorprotoPropsstaticProps 三個引數。staticProps 我們暫時不會用到,回頭再講;我們傳入的陣列是通過 protoProps 接受的。接下來,看一下 defineProperties 做了啥事。

它將每一個傳進來的 props 做了如下處理:分別設定了他們的 enumerableconfigurablewritable 屬性。而傳進來的 targetAnimal.prototype,相當於,這個函式最後的執行效果會是這樣:

function defineProperties(target, props) {
  for (var i = 0; i < props.length; i++) {
    // 前面處理其實得到這樣這個 descriptor 物件:
    var descriptor = {
      ...props[i],
      enumerable: false,
      configurable: true,
      writable: true,
    }
    Object.defineProperty(target, descriptor.key, descriptor)
  }
}
複製程式碼

看到這裡就很明白了,它就是把你定義的 movegetName 方法通過 Object.defineProperty 方法設定到 Animal.prototype 上去。前面我們說過,prototype 是用來儲存公共屬性的。也就是說,這兩個方法在你使用繼承的時候,可以被子物件通過原型鏈上溯訪問到。也就是說,我們這個小小的例子裡,宣告的兩個方法已經具備了繼承能力了。

至於 enumerableconfigurablewritable 屬性是什麼東西呢,查一下語言規範就知道了。簡單來說,writablefalse 時,其值不能通過 setter 改變;enumerablefalse 時,不能出現在 for-in 迴圈中。當然,這裡是粗淺的理解,暫時不是這篇文章的重點。

簡單繼承——一層繼承 + 欄位覆蓋

class Animal {
  constructor(name) {
    this.name = name || 'Kat'
  }
}

class Tiger extends Animal {
  constructor(name, type) {
    super(name)
    this.type = type || 'Paper'
  }
}
複製程式碼

加一層繼承和欄位覆蓋能看到啥東西呢?能看到繼承底下的實現機制是怎麼樣的,以及它的 constructor__proto__ 屬性將如何被正確設定。帶著這兩個問題,我們一起來看下編譯後的原始碼:

'use strict'

function _possibleConstructorReturn(self, call) {
  if (!self) {
    throw new ReferenceError(
      "this hasn't been initialised - super() hasn't been called"
    )
  }
  return call && (typeof call === 'object' || typeof call === 'function')
    ? call
    : self
}

function _inherits(subClass, superClass) {
  if (typeof superClass !== 'function' && superClass !== null) {
    throw new TypeError(
      'Super expression must either be null or a function, not ' +
        typeof superClass
    )
  }
  subClass.prototype = Object.create(superClass && superClass.prototype, {
    constructor: {
      value: subClass,
      enumerable: false,
      writable: true,
      configurable: true,
    },
  })
  if (superClass)
    Object.setPrototypeOf
      ? Object.setPrototypeOf(subClass, superClass)
      : (subClass.__proto__ = superClass)
}

function _classCallCheck(instance, Constructor) {
  if (!(instance instanceof Constructor)) {
    throw new TypeError('Cannot call a class as a function')
  }
}

var Animal = function Animal(name) {
  _classCallCheck(this, Animal)

  this.name = name || 'Kat'
}

var Tiger = (function(_Animal) {
  _inherits(Tiger, _Animal)

  function Tiger(name, type) {
    _classCallCheck(this, Tiger)

    var _this = _possibleConstructorReturn(
      this,
      (Tiger.__proto__ || Object.getPrototypeOf(Tiger)).call(this, name)
    )

    _this.type = type || 'Paper'
    return _this
  }

  return Tiger
})(Animal)
複製程式碼

相比無繼承的程式碼,這裡主要增加了幾個函式。_possibleConstructorReturn 顧名思義,可能不是很重要,回頭再讀。精華在 _inherits(Tiger, Animal) 這個函式,我們按順序來讀一下。首先是一段異常處理,簡單地檢查了 superClass 要麼是個函式,要麼得是個 null。也就是說,如果你這樣寫那是不行的:

const Something = 'not-a-function'
class Animal extends Something {}
// Error: Super expression must either be null or a function, not string
複製程式碼

接下來這句程式碼將 prototypeconstructor 一併設定到位,是精華。注意,這個地方留個問題:為什麼要用 Object.create(superClass.prototype),而不是直接這麼寫:

function _inherits(subClass, superClass) {
  subClass.prototype = superClass && superClass.prototype
  subClass.prototype.constructor = { ... }
}
複製程式碼

很明顯,是為了避免任何對 subClass.prototype 的修改影響到 superClass.prototype。使用 Object.create(asPrototype) 出來的物件,其實上是將 subClass.prototype.__proto__ = superClass.prototype,這樣 subClass 也就繼承了 superClass,可以達到這樣兩個目的:

  1. 當查詢到 subClass 上沒有的屬性時,會自動往 superClass 上找;這樣 superClass.prototype 原型上發生的修改都能實時反映到 subClass
  2. subClass.prototype 本身是個新的物件,可以存放 subClass 自己的屬性,這樣 subClass.prototype 上的任何修改不會影響到 superClass.prototype

最後,如果 superClass 不為空,那麼將 subClass.__proto__ 設定為 superClass。這點我並不是很理解。

至此,一個簡單的繼承就完成了。在使用了 extends 關鍵字後,實際上背後發生的事情是:

  • 子「類」prototype 上的 __proto__ 被正確設定,指向父「類」的 prototype: subClass.prototype = { __proto__: superClass.prototype }
  • 子「類」prototype 上的 constructor 被正確初始化,這樣 instanceof 關係能得到正確結果

好,要點看完了。後面內容跟繼承關係不大,但既然原始碼扒都扒了,我們不妨繼續深入探索一些場景:

無繼承——靜態函式

看一個簡單的程式碼:

class Animal {
  static create() {
    return new Animal()
  }
}
複製程式碼

首先要知道,這個「靜態」同樣不是強型別類繼承語言裡有的「靜態」的概念。所謂靜態,就是說它跟例項是沒關係的,而跟「類」本身有關係。比如,你可以這樣呼叫:Animal.create(),但不能這樣用:new Animal().create。什麼場景下會用到這種模式呢?比如說:

  • 工廠模式或單例模式
  • Object.createObject.keys 等常用方法

既然只有通過建構函式本身去呼叫,而不能通過例項來呼叫,期望它們被繫結到函式本身上似乎很自然。我們來看看上面這段程式碼將被如何編譯:

'use strict'

var _createClass = (function() {
  function defineProperties(target, props) {
    for (var i = 0; i < props.length; i++) {
      var descriptor = props[i]
      descriptor.enumerable = descriptor.enumerable || false
      descriptor.configurable = true
      if ('value' in descriptor) descriptor.writable = true
      Object.defineProperty(target, descriptor.key, descriptor)
    }
  }
  return function(Constructor, protoProps, staticProps) {
    if (protoProps) defineProperties(Constructor.prototype, protoProps)
    if (staticProps) defineProperties(Constructor, staticProps)
    return Constructor
  }
})()

function _classCallCheck(instance, Constructor) {
  if (!(instance instanceof Constructor)) {
    throw new TypeError('Cannot call a class as a function')
  }
}

var Animal = (function() {
  function Animal() {
    _classCallCheck(this, Animal)
  }

  _createClass(Animal, null, [
    {
      key: 'create',
      value: function create() {},
    },
  ])

  return Animal
})()
複製程式碼

熟悉的函式,熟悉的配方。與本文的第二個例子相比,僅有一個地方的不同:create 方法是作為 _createClass 方法的第三個引數被傳入的,這正是我們上文提到的 staticProps 引數:

var _createClass = (function() {
  function defineProperties(target, props) { ... }

  return function(Constructor, protoProps, staticProps) {
    if (protoProps) defineProperties(Constructor.prototype, protoProps)
    if (staticProps) defineProperties(Constructor, staticProps)
    return Constructor
  }
})()

_createClass(Animal, null, [
  {
    key: 'create',
    value: function create() {},
  },
])
複製程式碼

可以看見,create 方法是直接被建立到 Animal 上的:defineProperties(Animal, [{ key: 'create', value: function() {} }]),最終會將函式賦給 Animal.create。我們的猜測並沒有錯誤。

無繼承——靜態變數

class Tiger {
  static TYPE = 'REAL'
}
複製程式碼

還有個小例子。如果是靜態變數的話,同樣因為不希望在例項物件上所使用,我們會看到編譯出來的程式碼中它是直接被設定到函式上。程式碼已經很熟悉,不必再講。

'use strict'

function _classCallCheck(instance, Constructor) {
  if (!(instance instanceof Constructor)) {
    throw new TypeError('Cannot call a class as a function')
  }
}

var Tiger = function Tiger() {
  _classCallCheck(this, Tiger)
}

Tiger.TYPE = 'REAL'
複製程式碼

有趣的是,靜態變數會不會被「子類」繼承呢?這個可請讀者自己做個實驗,驗證驗證。

神祕的類 arrow function

寫 React 的東西,一定遇見過這個問題:

class Button extends React.Component {
  constructor() {
    super()
    this.state = {
      isToggleOn: true,
    }
    // 畫重點 ????????????
    this.toggleButton = this.toggleButton.bind(this)
  }

  static propTypes = {
    text: PropTypes.string,
  }

  // ❌❌❌ Uncaught TypeError: this.setState is not a function
  toggleButton() {
    this.setState({
      isToggleOn: !this.state.isToggleOn,
    })
  }

  render() {
    return <button onClick={this.toggleButton}>Toggle Me</button>
  }
}
複製程式碼

為什麼會有這個問題呢?因為你扔進去的 this.toggleButton 函式,在 button 內部一定是通過 onClick() 這樣的方式來呼叫的,這樣的話,this 引用就會丟失為 undefined,那麼 React.Component 上的 setState 就呼叫不到。

可以直接去 React 官方示例看看:codepen.io/gaearon/pen…

class Button extends React.Component {
  ...

  // ✅✅✅ This will work!
  toggleButton = () => {
    this.setState({ ... })
  }

  ...
}
複製程式碼

解決方案呢,自然也有很多種,比如引用 @autobind、使用 ES7 的 ::this.toggleButton、使用箭頭函式等。比如上面 ? 這種最常用的解決方案。那麼同學們有沒有想過這個問題,為什麼這樣寫 this 應用就可以正確拿到呢?「因為箭頭函式將 this 繫結到詞法作用域的上下文中了呀~」那誰來給我解釋一下這句話呢?反正我是從來沒理解過這個「外層」的作用域,應該是繫結到哪裡。因此,只好另闢路徑,直接看原始碼來理解這個寫法的含義。

我寫了個簡單的例子,足以復現這個問題:

class Button {
  constructor() {
    this.value = 1
  }

  increment = () => {
    this.value += 2
  }

  render() {
    const onClick = this.increment
    onClick()
  }
}
複製程式碼

當我們呼叫 render() 時,increment() 這樣的呼叫方式會使 this 引用無法被初始化,這也正是我們傳入的 onClick 在 React 中會被呼叫的方式。而上圖的 increment 寫法可以重新拯救失去的 this 引用!讓我們來看看原始碼,一探究竟。

'use strict'

var _createClass = (function() {})()
function _classCallCheck(instance, Constructor) {}

var Button = (function() {
  function Button() {
    var _this = this

    _classCallCheck(this, Button)

    this.increment = function() {
      _this.value += 2
    }

    this.value = 1
  }

  _createClass(Button, [
    {
      key: 'render',
      value: function render() {
        var increment = this.increment
        increment()
      },
    },
  ])

  return Button
})()
複製程式碼

我略去了大家耳熟能詳的程式碼,只留下關鍵的部分。可以看到,編譯後的程式碼中,Button 例項的 this 引用被閉包儲存了下來!這種寫法,與以前我們 var that = this 的寫法是一致的,我也終於理解「不再需要 that 引用了」以及各種語焉不詳的作用域啊最外層變數啊這些理論。其實,就是 this 引用會始終被繫結到建構函式上,而這底下是通過閉包實現的。只是把你以前手寫的程式碼自動化生成而已。

在本文的第二個例子中,我們留意到 Animal() 建構函式被額外包了一層,當時不得其解。看到這裡,我們也許可以理解它的意圖:就是為了將你在類中編寫的箭頭函式做個閉包,將 this 引用儲存下來,以做後用。

相關文章