一.HMR
HMR(Hot Module Replacement)能夠對運行時的 JavaScript 模塊進行熱更新(無需重刷,即可替換、新增、刪除模塊)
(摘自 webpack HMR)
HMR 特性由 webpack 等構建工具提供,並暴露出一系列 運行時 API 供應用層框架(如 React、Vue 等)對接:
Basically it's just a way for modules to say "When a new version of some module I import is available, run a callback in my app so I can do something with it".
其基本原理是在運行時對(構建工具啟動的)Dev Server 發起輪詢,通過 script 標籤將有更新的模塊注入到運行環境,並執行相關的回調函數:
HMR is just a fancy way to poll the development server, inject
例如:
import printMe from './print.js';
if (module.hot) {
module.hot.accept('./print.js', function() {
console.log('Accepting the updated printMe module!');
printMe();
})
}
開啟 HMR 後,當 ./print.js 模塊有更新時,會觸發回調函數,表明模塊已經替換完成,此後訪問該模塊取到的都是新模塊實例
基於運行時的模塊替換能力(HMR),可以結合應用層框架(React、Vue、甚至 Express)進一步實現 Live Reloading、Hot Reloading 等更加高效的開發模式
二.Live Reloading
所謂 Live Reloading,就是在模塊文件發生變化時,重新加載整個應用程序:
Live reloading reloads or refreshes the entire app when a file changes. For example, if you were four links deep into your navigation and saved a change, live reloading would restart the app and load the app back to the initial route.
以 React 為例:
const App = require('./App')
const React = require('react')
const ReactDOM = require('react-dom')
// Render the root component normally
const rootEl = document.getElementById('root')
ReactDOM.render(<App />, rootEl)
// Are we in development mode?
if (module.hot) {
// Whenever a new version of App.js is available
module.hot.accept('./App', function () {
// Require the new version and render it instead
const NextApp = require('./App')
ReactDOM.render(<NextApp />, rootEl)
})
}
利用 HMR 換掉根組件,並重新渲染即可。因為 HMR 模塊更新有 冒泡機制,未經 accept 處理的更新事件會沿依賴鏈反向傳遞,所以在組件樹頂層能夠監聽到樹中所有組件的變化,此時重新創建整棵組件樹,過程中取到的都是已經更新完成的組件,渲染出來即可得到新的視圖
這種方案對應用層框架的依賴很少(僅 re-render 部分),實現簡單而且穩定可靠,但此前的運行狀態都將丟失,對 SPA 等運行時狀態多且複雜的場景極不友好,刷完後要重新操作一遍才能回到先前的視圖狀態,開發效率上的提升非常有限
那麼,有沒有辦法保留運行時的狀態數據,只刷新有變化的視圖呢?
有,Hot Reloading
三.Hot Reloading
下層同樣基於 HMR,但 Hot Reloading 能夠保留應用程序的運行狀態,只對有變化的部分進行局部刷新:
Hot reloading only refreshes the files that were changed without losing the state of the app. For example, if you were four links deep into your navigation and saved a change to some styling, the state would not change, but the new styles would appear on the page without having to navigate back to the page you are on because you would still be on the same page.
針對視圖的局部刷新免去了整個刷新之後再次回到先前狀態所需的繁瑣操作,從而真正提升開發效率
然而,局部刷新要求對組件(甚至組件的一部分)進行熱替換,這在實現上存在不小的挑戰(包括如何保障正確性、縮小影響範圍、及時反饋錯誤等,具體見 My Wishlist for Hot Reloading)
如何動態替換組件?
因為 HMR 替換後的新模塊,在運行時看來是完全不同的兩個組件,相當於:
function getMyComponent() {
// 通過 script 標籤,重新加載相同的組件代碼
class MyComponent {}
return MyComponent;
}
getMyComponent() === getMyComponent() // false
顯然無法通過 React 自身的 Diff 機制 來完成無傷替換,那麼,只能從 JavaScript 語言尋找可能性了
一個經典的 React 組件通過 [ES6 Class](/articles/class-es6 筆記 10/) 來定義:
class Foo extends Component {
state = {
clicked: false
}
handleClick = () => {
console.log('Click happened');
this.setState({ clicked: true });
}
render() {
return <button onClick={this.handleClick}>{!this.state.clicked ? 'Click Me' : 'Clicked'}</button>;
}
}
在運行時根據組件類創建出一系列的組件實例,它們擁有 render 生命周期等原型方法,也有 handleClick 之類的實例方法,以及 state 等實例屬性
原型方法、原型屬性不難替換,但要換掉實例方法和實例屬性就不那麼容易了,因為它們被緊緊地包裹在了組件樹中
為此,有人想到了一種很聰明的辦法
四.React Hot Loader
在 React 生態裡,目前(2020/5/31)應用最廣泛的 Hot Reloading 方案仍然是 RHL(React Hot Loader):
Tweak React components in real time.
為了實現組件方法的動態替換,RHL在 React 組件之上加了一層代理:
Proxies React components without unmounting or losing their state.
P.S.具體見 react-proxy
關鍵原理

通過一層代理將組件狀態剝離出來,放到代理組件中維護(其餘生命周期方法等全都代理到源組件上),因此換掉源組件後仍能保留組件狀態:
The proxies hold the component's state and delegate the lifecycle methods to the actual components, which are the ones we hot reload.
源組件被代理組件包起來了,掛在組件樹上的都是代理組件,熱更新前後組件類型沒有變化(背後的源組件已經被偷摸換成了新的組件實例),因而不會觸發額外的生命周期(如 componentDidMount):
Proxy component types so that the types that React sees stay the same, but the actual implementations change to refer to the new underlying component type on every hot update.
具體實現細節,見:
Redux Store
特殊地,對於 Redux 應用而言,有必要讓 Reducer 的變化也能熱生效(因為大多數狀態都交由 Redux 來管理了):
// configureStore.js
import { createStore, applyMiddleware, compose } from 'redux';
import thunk from 'redux-thunk';
import reducer from '../reducers';
export default function configureStore(initialState) {
const store = createStore(
reducer,
initialState,
applyMiddleware(thunk),
);
if (module.hot) {
module.hot.accept(() => {
const nextRootReducer = require('../reducers/index').default;
store.replaceReducer(nextRootReducer);
});
}
return store;
};
藉助 replaceReducer 換掉 Reducer,同時保留 store 狀態
P.S.關於 Redux 應用 Hot Reloading 的更多信息,見 RFC: remove React Transform from examples
暫無評論,快來發表你的看法吧