追溯 React Hot Loader 的實現

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

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

若是你使用 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 的由來,它實現的原理,以及在實現中遇到的問題對應的解決方法。也許你認爲這篇文章太過於底層,對平常的業務並無幫助,但但願你和我同樣能經過了解一個實現獲得樂趣,以及收穫一些思路。java

首先,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 以一種有趣的方式結合在一塊兒。node

因而他在 Twitter 上錄製了一個簡單的視頻(請看下面),事實上視頻中的實現依賴於它在 React 源代碼裏面插入了不少本身的全局變量。他本沒期望到這個視頻能帶來多大的關注,但結果是他收到了不少點贊,而且粉絲狂增,他意識到必須以一個真正的工程去實現。react

上傳大小有限制= =

大圖請戳webpack

初步嘗試, 直接使用 HMR

HMR 是屬於 webpack 範疇內的實現,你能夠在 webpack 的官方文檔 看到如何開啓它以及它提供的接口。若是你有印象,你會記得使用它須要 在 webpack config 或者 webpack-dev-server cli 裏面指定開啓 hot reloading 模式,而且在你的代碼裏寫上 module.hot.accept(xxx)。但 HMR 究竟是什麼?咱們能夠用一句話總結:當一個 import 進來的模塊發生了變化,HMR 提供了一個接口讓咱們使用 callback 回調去作一些事情。git

一個使用 HMR 實現自動刷新的 React App 像下面這樣:github

// 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。web

得益於 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 來看),到時候拭目以待吧。


2019年,iKcamp原創新書《Koa與Node.js開發實戰》已在京東、天貓、亞馬遜、噹噹開售啦!

相關文章
相關標籤/搜索