追溯 React Hot Loader 的實現


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

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

如果你使用 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 來看),到時候拭目以待吧。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM