追溯 React Hot Loader 的實現

iKcamp發表於2018-03-07

文:蘿蔔(滬江金融前端開發工程師)

本文原創,轉載請註明作者及出處

如果你使用 React ,你可以在各個工程裡面看到 Dan Abramov 的身影。他於 2015 年加入 facebook,是 React Hot Loader 、React Transform、redux-thunk、redux-devtools 等等的開發者。同樣也是 React、Redux、Create-React-App 的聯合開發者。從他的簽名 Building tools for humans. 或許表明了他想打造高效的開發環境以及除錯過程。

作為 Dan 的小迷妹,如他說 is curious where the magic comes from。這篇文章會帶你們去了解 React Hot Loader 的由來,它實現的原理,以及在實現中遇到的問題對應的解決方法。也許你認為這篇文章太過於底層,對日常的業務並沒有幫助,但希望你和我一樣能通過了解一個實現得到樂趣,以及收穫一些思路。

首先,React Hot Loader 的產生

Dan 在自己的文章裡面說到。React Hot Loader 起源一個來自 stackoverflow 上的一個問題 —— what exactly is hot module replacement in webpack,這個問題解釋了 webpack 的 hot module replacement(下面簡稱 HMR)到底是什麼,以及我們可以利用它做什麼,Dan 當時想到也 React 可以和 webpack hot module 以一種有趣的方式結合在一起。

於是他在 Twitter 上錄製了一個簡單的視訊(請看下面),事實上視訊中的實現依賴於它在 React 原始碼裡面插入了很多自己的全域性變數。他本沒指望到這個視訊能帶來多大的關注,但結果是他收到了很多點贊,並且粉絲狂增,他意識到必須以一個真正的工程去實現。

上傳大小有限制= =

大圖請戳

初步嘗試, 直接使用 HMR

HMR 是屬於 webpack 範疇內的實現,你可以在 webpack 的官方文件 看到如何開啟它以及它提供的介面。如果你有印象,你會記得使用它需要
在 webpack config 或者 webpack-dev-server cli 裡面指定開啟 hot reloading 模式,並且在你的程式碼裡寫上 module.hot.accept(xxx)。但 HMR 到底是什麼?我們可以用一句話總結:當一個 import 進來的模組發生了變化,HMR 提供了一個介面讓我們使用 callback 回撥去做一些事情。

一個使用 HMR 實現自動重新整理的 React App 像下面這樣:

// index.js

var App = require(`./App`)
var React = require(`react`)
var ReactDOM = require(`react-dom`)

// 像通常一樣 render Root Element
var rootEl = document.getElementById(`root`)
ReactDOM.render(<App />, rootEl)

// 我們是不是在 dev 環境 ?
if (module.hot) {
  // 當 App.js 更新了
  module.hot.accept(`./App`, function () {
    // require 進來更新的 App.js 重新render
    var NextApp = require(`./App`)
    ReactDOM.render(<NextApp />, rootEl)
  })
}

請注意,這個實現沒有使用 React Hot Loader 或者 React Transform 或者任何其他的,這僅僅是 webpack 的HMR 的 api。而這裡的 callback 回撥函式當然是 re-render 我們的 app。

得益於 HMR API 的設計,在巢狀的元件也能實現更新。如果一個模組沒有指明如何去更新自己,那麼引入這個模組的另一個模組也會被包含在熱更新的 bundle 裡,這些更新會”冒泡“,直到某個 import 它們的模組 “接收” 更新。如果有些模組最終沒有被”接受”,那麼熱更新失敗,控制檯會列印出警告。為了“接受”更新,你只需要呼叫 module.hot.accept(`./name`, callback)

因為我們在 index.js 裡的接受了 App.js 的更新 ,這使得我們隱性的接受了所有從 App.js 引入的所有模組(component)的更新。打個比方,假如我編輯了 Button.js 元件,而它被 UserProfile.js 以及 Navbar.js import, 而這兩個模組都被 App.js import 引入了。因為 index.js import 了 App.js,並且它包含了 module.hot.accept(`./App`, callback) ,Webpack 會自動產生一個包含以上所有檔案的 “updated bundle”, 並且執行我們提供的 callback。

你以為 hot reloading 就到此為止了嗎,當然遠遠不夠 ? 。

問題:元件的 state 和 DOM 被銷燬。

當我們的 App.js 更新,實際上是有個新的 App.js 用 script 標籤注入到 html, 並且重新執行了一次。此時新生成的 component 和之前的是一個元件的不同版本,它們是不同版本的同一個元件,但是 NextApp !== App。

如果你瞭解 React ,你會知道當下一個 component 的 type 和之前的不一樣,它會 unmount 之前那個。這就是為什麼 state 和 DOM 會被銷燬。

在解決 state 保留的問題上,有人認為如果工程依賴一個單一的 state 樹,那沒有必要費大精力去保留元件自身的 state。因為在這種型別的 app 裡面我們關注的更多的是全域性的這個 state 樹,而去儲存這個全域性的 state 樹是很容易做到的,比如你可以把它儲存到 localstorage裡面,當 store 初始化的時候你去讀取它,這樣的話連重新整理都不會丟失狀態。

Dan 接受了這個意見,並且在自己的文章裡面總結,如果你使用 redux ,並且主要的狀態儲存在 redux 的 store 上,這時也許你不需要使用 React-Hot-Loader。

但他並沒有因為僅僅 有些人 可能不需要用到而放棄了 React-Hot-Loader。這才有了下文 ? 。

如何解決 state 和 DOM 銷燬問題

當你從上面瞭解了為什麼 DOM 和 state 會丟失,也許你就會 和 Dan 一樣想到了兩種方法。

  1. 找到一種方式把 React 的例項和 Dom nodes 以及 state 分離,建立一個新元件的新例項,然後用一種方式把它遞迴地和現有的 Dom 和 state 結合在一起。
  2. 另外一種,代理 component 的 type,這樣能讓 React 認為 type 沒有變。事實上每次 hot update 實現引用的是新的 component type。

第一種方式看上去好一點,但是 React 暫時沒有提供可以分離(聚合)state 以及不銷燬 DOM、不執行生命週期去替換一個例項。即使深入到使用 React 的私有 API 達到這個目的,採用第一個方案任然面臨著一些細微的問題。

比如,React components 經常 在 componentDidmount 時候訂閱 Flux stores 或者其他資料來源。即使我們做到不銷燬 Dom 以及 state, 偷偷地用一個新的例項替換舊的例項,舊的例項仍然會繼續保持訂閱,而新的例項將不會訂閱。

結論是,如果 React 的 state 的訂閱是申明式,並且獨立於生命週期之外,或者 React 沒有那麼依賴 class 和 instance, 第一個方法才可行。這些也許會出現在以後的 React 版本里,但是現在並沒有。

於是 Dan 採用了第二種,這也是之後的 React Hot Loader 和 React Transform 所使用的到技巧。

為此,Dan 建立了一個獨立的工程(react-proxy)去做 proxy,你可以在這裡 看到它。create-proxy 只是一個底層的工程,它不依賴 wepback 也不依賴 babel。React Hot Loader 和 React Transform 依賴它,它把 React Component 包裝到一個個 proxy 裡面,這些 “proxy” 只是些 class, 它們表現的就像你自己的class,但是提供你一些鉤子讓你能對 class 注入新的實現方法,這樣相當於讓一個已經存在的例項表現的像新的 class,從而不會銷燬 state 和 DOM。

在哪裡 proxy ?

Dan 首先所做的是在 wepback 的 loader 裡面 proxy。

補充,很多人認為 React Hot Loader 不是一個 “loader”,因為它只是實現 hot reloading 的。這是一個普遍的誤解?。

之所以叫 “loader” 是因為 webpack 是這麼稱呼它,而其他 bundlers(打包器)稱呼為 “transform”。打個比方,json-loader 把JSON 檔案 “transform” 成 javascript modules,style-loader 把 CSS 檔案 “transform” 成 js code 然後把它們以 stylesheets 的形式注入。

而關於 React Hot Loader 你可以在這裡 看到,在編譯的時候它通過 export 找到 component,並且“靜默” 的包裹它,然後 export 一個代理的 component 取而代之原來的。

通過 module.exports 去尋找 components 開始聽上去是合理的。開發者們經常把每個元件單獨儲存在一個檔案,自然而然元件將會被exported。然而,隨著時間變化,React 社群發生了一些變化,採取了一些新的寫法或者思想,這導致了一些問題。

  • 隨著高階元件變得流行,大家開始 export 出來的是一個高階元件,而不是實際上自己寫的元件。 結果導致, React Hot Loader 沒有“發現” module.export 裡面包裹的元件,所以沒有給它們建立 proxy。它們的 DOM 以及 local state 將會被在這些檔案每次修改後銷燬。這尤其影響像 React JSS 一樣利用高階元件實現樣式。
  • React 0.14 引進了函式式元件,並且鼓勵在一個檔案裡面最小化拆分元件。即使React Hot Loader 能檢測到匯出的元件,它也“看”不到那些未被匯出的本地的component。所以這些component 將不會包裹在proxy裡面,所以會導致在它以及它下面的子樹丟失 DOM 以及 state。

這顯然是使得從 module.exports 去找元件是不可靠的。

React Transform 的出現

除了上面提到的從 module.exports 不可靠之外,第一版的 React-Hot-Loader 還存在一些其他的問題。比如 webpack 的依賴問題,Dan 想做的是一個通用的工具,而不僅限於 webpack,而現在的工具只是一個 webpack 的 loader。

雖然目前為止只有 webpack 實現了HMR, 但是一旦有其他的編譯工具也實現了 HMR,那現有的 loader 如何整合到新的編譯工具裡面 ?

基於這些問題 Dan 曾經寫過一篇 React-Hot-Loader 之死的文章,文章中提到雖然 React-Hot-Loader 得到了巨大的關注,並且有很多工程也採取了他的思想,他仍然認為這不是他所想要的。

此時 Babel 如浪潮一般突然佔領了整個 javascript 世界。Dan 意識到可以採用靜態分析的方法去找到這些 component,而 babel 正好很適合做這些。不僅如此,Dan 同樣想做一個錯誤處理的方式,因為當 render() 方法報錯的時候,此時元件會處於一種無效狀態,而此時 hot reload 是沒辦法工作的,Dan 想一起 fix 掉這個問題。

把 component 包裹在一個 proxy 裡或者把 component render() 包裹在一個 try/catch 裡,聽上去都像 “一個函式接受一個component class 並且在它身上做些修改”。

那為什麼不創造一個 Babel plugin 在你的基準程式碼裡去定位 React Component 並且包裹它們,這樣就可以進行隨意的 transform。

React Transform 的實現

如果你在 github 去搜 React Transform ,你可以搜到 gearaon ( dan 在github上的名字,也是唯一一個不使用真名的賬號哦~) 幾個工程。 這是因為在開始設定 Transform 實現的時候不確定哪些想法最終會有實質作用,所以他拆分了 React Transform 為以下 5 個子工程:

  • React Proxy 實現了對 React Component 的底層代理的功能
  • React Transform HMR 為每一個傳入的 component 建立了一個代理,並且在全域性物件裡面保持了一個代理的清單,當同一個元件再次經歷 transform,它去更新這些 component
  • React Transform Catch Error 在 render() 方法外面包了一層t ry/catch, 當出現錯誤可以顯示一個自己配置的元件。
  • Babel Plugin for React Transform 會在你的基準程式碼裡找到所有的React component ,在編譯的時候提取它們的資訊,並且把它們包裹在你選擇使用的 Transform 裡(比如,React Transform HMR)
  • React Transform Boilerplate 是個模板,展示如何將這些技術組合在一起使用

這種模組化帶了好處,同時也帶來了弊端,弊端就是使用者在不清楚原理的情況下,不知道這些工程到底如何關聯起來使用。並且這裡有太多的概念暴露給了使用者, “proxies”, “HMR”, “hot middleware”, “error catcher”, 這使得使用者感到很迷惑。

問題:高階元件還是存在問題

當你解決了這些問題,儘量避免引入由解決它們帶來的新的問題

還記得當年 React-Hot-Loader 在高階元件上面束手無策嗎,它沒辦法通過 module.export 匯出的,包裹在高階元件裡面的元件。而 React Transform 通過靜態檢查這些元件的生命去“fix”這個問題,尋找繼承自
React.Component 或者使用 React.createClass() 申明的 class。


// React Hot Loader 找不到它
// React Transform 找得到它
class Counter extends Component {
  constructor(props) {
    super(props)
    this.state = { counter: 0 }
    this.handleClick = this.handleClick.bind(this)
  }
  handleClick() {
    this.setState({
      counter: this.state.counter + 1
    })
  }
  render() {
    return (
      <div className={this.props.sheet.container} onClick={this.handleClick}>
        {this.state.counter}
      </div>
    )
  }
}

const styles = {
  container: { 
    backgroundColor: `yellow`
  }
}

// React Hot Loader 找到到它
// React Transform 找不到它
export default useSheet(styles)(Counter)

猜猜這裡我們遺漏了什麼?被匯出的 components! 在這個例子中,React Transform 會保留 Counter 的 state , hot reload 會改變
render()handleClick() 這些方法,但是任何對 styles 的改變不會體現,因為它不知道 useSheet(styles)(Counter) 正好 return 一個 React component, 這個元件也需要被 proxy。

很多人發現了這個問題,當他們注意到他們在 redux 裡面 selectors 以及 action creators 不再會 hot reload。這是因為 React Transform 沒有發現 connect() 返回一個元件,然後並沒有一個簡單的方法去識別。

問題:使用靜態方法檢查太過於入侵性

找到通過繼承自 React.Component 或者使 React.createClass() 建立的class 不是很難 。然而,它可能出錯,你也不想 帶來誤判

隨著React 0.14的釋出,這個任務變得更加艱難。任何 functions,如果
return 出來的是一個有效的 ReactElement 那就可能是一個元件。由於你不能肯定,所以你不得不採用探索法。比如說,你可在判斷在頂級作用域的 function,如果是以駝峰命名,使用JSX, 並且接受不超過兩個以上(props 和 context)引數,那它可能是個React component。這樣會誤判嗎?是,可能會。

更糟糕的是,你必須讓所有的 “transform” 去處理 classes 和 functions。如果React 在v16版本里面引進另外一種 一種方式去宣告元件呢,我們將要重寫所有的transform嗎?

最後得出結論,用靜態方法 包裹 元件相當複雜。你將要對 functions 和 classes 可能的 export 方式取使用各種方法去處理,包括 default 和 named 的 exports,function宣告,箭頭函式,class宣告,class表示式,createClass() 形式呼叫,以及等等。每種情況你都需要用一種方法針對相同的變數或者表示式去繫結不同的值。

想辦法支援 functional components 是最多的提議, 我現在不會考慮在 React Transform 支援它,因為實現的複雜程度會給工程以及它的維護者帶來巨大困難,並且可能由於一些邊緣情況導致徹底的破壞。

React Hot Loader 3

以上總結是出自 Dan 的一篇在medium上的文章,他稱呼 React Hot Loader 是一個 Accidental Complexity,其中還提到它對 compile-to-js 語言 (其他通過編譯轉成JS的語言)的考慮,以及中途遇到的 babel 的問題等。文章中 Dan 表明他會在幾個月內停止 React Transform 而使用一個新的工程代替,新的工程會解決大多數殘留的問題,末尾給了一些提示在新工程裡面需要做到的。在這篇文章的一個月後,React-Hot-Loader 3 release了,讓我們大致的過一下 3 的到底做了些什麼。

在呼叫的時候 proxy

在原始碼中找到並且包裹React components是非常難做到的,並且有可能是破壞性的。這真的會破壞你的程式碼,但標記它們相對來說是比較安全。比如我們可以通過 babel-plugin 檢查一個檔案,針對頂層 class、function 以及 被 export 出來的模組在檔案末尾做個標記:

class Counter extends Component {
  constructor(props) {
    super(props)
    this.state = { counter: 0 }
    this.handleClick = this.handleClick.bind(this)
  }
  handleClick() {
    this.setState({
      counter: this.state.counter + 1
    })
  }
  render() {
    return (
      <div className={this.props.sheet.container} onClick={this.handleClick}>
        {this.state.counter}
      </div>
    )
  }
}

const styles = {
  container: { 
    backgroundColor: `yellow`
  }
}

const __exports_default = useSheet(styles)(Counter)
export default __exports_default

// 我們 generate 的標記程式碼:
// 在 *遠端* 標記任何看上去像 React Component 的東西
register(`Counter.js#Counter`, Counter)
register(`Counter.js#exports#default`, __exports_default) // every export too

register() 至少會判斷傳進來的值是不是一個函式,如果是,建立一個 React Proxy 包裹它。它不會替換你的 class 或者 function,這個proxy將會待在全域性的map裡面,等待著,直到你使用React.createElement()。

僅僅真正的元件才會經歷 React.createElement,這就是我們為什麼 monkeyPatch React.createElement()。

import createProxy from `react-proxy`

let proxies = {}
const UNIQUE_ID_KEY = `__uniqueId`

export function register(uniqueId, type) {
  Object.defineProperty(type, UNIQUE_ID_KEY, {
    value: uniqueId,
    enumerable: false,
    configurable: false
  })
  
  let proxy = proxies[uniqueId]
  if (proxy) {
    proxy.update(type)
  } else {
    proxy = proxies[id] = createProxy(type)
  }
}

// Resolve 發生在 element 被建立的時候,而不是宣告的時候
const realCreateElement = React.createElement
React.createElement = function createElement(type, ...args)  {
  if (type[UNIQUE_ID_KEY]) {
    type = proxies[type[UNIQUE_ID_KEY]].get()
  }
  
  return realCreateElement(type, ...args)
}

在呼叫端包裹元件解決了很多問題,比如 functional component 不會誤判,包裹的邏輯只要考慮 function 和 class,因為我們把生成的程式碼移到底部這樣不會汙染程式碼。

給 compile-to-js 語言提供了一種相容方式

Dan 提供了類似於 React-Hot-Loader 1 的 webpack loader, 即 react-hot-loader/webpack。在不使用 babel 做靜態分析的情況下,你可以通過它找到 module.export 出來的 component,並且 register 到全域性,然後在呼叫端實現真正的代理。所以這種方式只能針對實際 export 出來的元件做保留 state 以及 DOM 的 hot reloading

什麼情況下會使用這種方式,那就是針對其他 compile-to-js 的語言比如 FigwheelElm Reactor。在這些語言裡面有自己的類的實現等,所以 Babel 沒有針對原始碼辦法去做靜態檢查,所以必須在編譯之後去處理。

錯誤處理

還記得 React Transform 裡面的React Transform Catch Error 嗎。React-Hot-Loader 把處理 render 出錯的邏輯放到 AppContainer 。因為 React V16 增加了 error boundaries ,相信在未來的版本 React-Hot-Loader 也會做相應調整。

寫在最後

這就是對 React-Hot-Loader 的實現的一個追溯,如果你真的理解了,那麼你在配置 React-Hot-Loader 到你的應用程式碼裡面的每個步驟會有一個重新的認識。我不確定大家是否讀懂了,或者存在還存在什麼疑問,歡迎來溝通討論。截止寫文現在 React-Hot-Loader 4 已經在進行中,我比較偏向於 4 會和 React 迭代保持更親密的同步( 從之前 error boundaries official instrumentation API 來看),到時候拭目以待吧。

相關文章