這一課時,我們再來聊聊前端開發過程中一個經典的提高開發效率的技術:瀏覽器的熱更新。
**什么是瀏覽器的熱更新**
看見瀏覽器熱更新,相信你很容易想到 webpack 和 webpack-dev-server 。確實,現在各類型的腳手架工具在創建項目時通常已配置好了開啟各種優化選項的 webpack ,其中自然也包含了開發服務器。大家在上手開發時,可以簡單地執行 npm start (cra) 或 npm run serve (vue cli),就能體驗到熱更新的效果。
但是在我過去擔任中高級前端崗位的面試官時,經常發現很多來面試的同學對于到底什么是熱更新都很難講清楚,熱更新是保存后自動編譯(Auto Compile)嗎?還是自動刷新瀏覽器(Live Reload)?還是指 HMR(Hot Module Replacement,模塊熱替換)?這些不同的效果背后的技術原理是什么呢?今天我們就來回答下這些問題。
先來看下,究竟什么是瀏覽器的熱更新。瀏覽器的熱更新,指的是我們在本地開發的同時打開瀏覽器進行預覽,當代碼文件發生變化時,瀏覽器自動更新頁面內容的技術。這里的自動更新,表現上又分為自動刷新整個頁面,以及頁面整體無刷新而只更新頁面的部分內容。
與之相對的是在早期開發流程中,每次代碼變更后需要手動刷新瀏覽器才能看到變更效果的情況。甚至于,代碼變更后還需要手動執行打包腳本,完成編譯打包后再刷新瀏覽器。而使用瀏覽器的熱更新,可以大大減少這些麻煩。
webpack 中的熱更新配置
下面我們就以 webpack 工具為例,來看下四種不同配置對結果的影響(完整示例代碼 https://github.com/fe-efficiency/lessons_fe_efficiency/02_webpack_hmr)。
一切依賴手動
首先來看第一個最簡單的配置,在入口 js 中我們簡單地打印一個文本,然后在構建配置里只有最簡單的 entry 和 mode 配置。
```js
src/index0.js
function render() {
div = document.createElement('div')
div.innerHTML = 'Hello World0';
document.body.appendChild(div)
}
render()
webpack.config.basic.js
module.exports = {
entry: './src/index0.js',
mode: 'development',
}
package.json
"scripts": {
"build:basic": "webpack --config webpack.config.basic.js"
}
```
當我們執行 npm run build:basic 時,webpack 將 entry 中的源文件 index0.js 打包為 dist/main.js,并退出進程。流程很簡單,但是如果我們接下來改動了源文件的輸出文本,會發現由于構建配置中沒有任何對應處理,所以在保存后,打包后的文件內容并沒有更新。為了同步改動效果,我們需要再次手動執行該命令。
Watch 模式
第二種配置是 watch 模式。為了擺脫每次修改文件后都需要手動執行腳本才能進行編譯的問題,webpack 中增加了 watch 模式,通過監控源碼文件的變化來解決上面不能自動編譯問題。我們可以在配置腳本中增加 watch:true,如下:
```js
webpack.config.watch.js
{...
watch: true
...}
package.json
"scripts": {
"build:watch": "webpack --config webpack.config.watch.js"
}
```
當我們執行 npm run build:watch,webpack 同樣執行一次打包過程,但在打包結束后并未退出當前進程,而是繼續監控源文件內容是否發生變化,當源文件發生變更后將再次執行該流程,直到用戶主動退出(除了在配置文件中加入參數外,也可以在 webpack 命令中增加 --watch 來實現)。
有了 watch 模式之后,我們在開發時就不用每次手動執行打包腳本了。但問題并未解決,為了看到執行效果,我們需要在瀏覽器中進行預覽,但在預覽時我們會發現,即使產物文件發生了變化,在瀏覽器里依然需要手動點擊刷新才能看到變更后的效果。那么這個問題又該如何解決呢?
Live Reload
第三種配置是 Live Reload。為了使每次代碼變更后瀏覽器中的預覽頁面能自動顯示最新效果而無須手動點擊刷新,我們需要一種通信機制來連接瀏覽器中的預覽頁面與本地監控代碼變更的進程。在 webpack 中,我們可以使用官方提供的開發服務器來實現這一目的,配置如下:
```js
webpack.config.reload.js
{...
devServer: {
contentBase: './dist', //為./dist目錄中的靜態頁面文件提供本地服務渲染
open: true //啟動服務后自動打開瀏覽器網頁
}
...}
package.json
"scripts": {
"dev:reload": "webpack-dev-server --config webpack.config.reload.js"
}
```
當我們執行 npm run dev:reload,從日志中可以看到本地服務 http://localhost:8080/ 已啟動,然后我們在瀏覽器中輸入網址 http://localhost:8080/index.html (也可以在 devServer 的配置中加入 open 和 openPage 來自動打開網頁)并打開控制臺網絡面板,可以看到在加載完頁面和頁面中引用的 js 文件后,服務還加載了路徑前綴名為 /sockjs-node 的 websocket 鏈接,如下圖:

通過這個 websocket 鏈接,就可以使打開的網頁和本地服務間建立持久化的通信。當源代碼發生變更時,我們就可以通過 Socket 通知到網頁端,網頁端在接到通知后會自動觸發頁面刷新。
到了這里,在使用體驗上我們似乎已經達到預期的效果了,但是在以下場景中仍然會遇到阻礙:在開發調試過程中,我們可能會在網頁中進行一些操作,例如輸入了一些表單數據想要調試錯誤提示的樣式、打開了一個彈窗想要調試其中按鈕的位置,然后切換回編輯器,修改樣式文件進行保存。可是當我們再次返回網頁時卻發現,網頁刷新后,之前輸入的內容與打開的彈窗都消失了,網頁又回到了初始化的狀態。于是,我們不得不再次重復操作才能確認改動后的效果。對于這個問題,又該如何解決呢?
Hot Module Replacement
第四種配置就是我們常說的 HMR(Hot Module Replacement,模塊熱替換)了。為了解決頁面刷新導致的狀態丟失問題,webpack 提出了模塊熱替換的概念。下面我們通過一個復雜一些的示例來了解熱替換的配置與使用場景:
```js
src/index1.js
import './style.css'
...
src/style.css
div { color: red }
webpack.config.hmr.js
{...
entry: './src/index1.js',
...
devServer: {
...
hot: true
},
module: {
rules: [
{
test: /\.css$/,
use: ['style-loader', 'css-loader']
}
]
}
}
package.json
"scripts": {
"dev:hmr": "webpack-dev-server --config webpack.config.hmr.js"
}
```
在上面的代碼改動中,我們只是在源碼部分新增導入了一個簡單的 CSS 文件,用于演示熱替換的效果。在配置文件中,首先我們在 devServer 配置中新增了 hot:true,其次,新增 module 的配置,使用 style-loader 和 css-loader 來解析導入的 CSS 文件。其中 css-loader 處理的是將導入的 CSS 文件轉化為模塊供后續 Loader 處理;而 style-loader 則是負責將 CSS 模塊的內容在運行時添加到頁面的 style 標簽中。
當我們執行 npm run dev:hmr 命令,可以看到頁面控制臺的網絡面板與上個示例并無區別,而在審查元素面板中可以看到源碼中的 CSS 被添加到了頁面頭部的新增 style 標簽中。

而當修改源碼中 CSS 的樣式后,再回到網頁端,我們則會發現這樣一些變化:
首先在網絡面板中,只是新增了兩個請求:hot-update.json 和 hot-update.js,而不像上一個立即刷新的示例中那樣,會刷新頁面重載所有請求。

其次,在審查元素面板中我們可以看到,在頁面的頭部新增了 hot-update.js,并替換了原先 style 標簽中的樣式內容。

正如我們所見,對于代碼中引入的樣式文件,可以通過上述設置來開啟熱替換。但是有同學也許會問,我們為什么不像上一個例子中那樣改動 JS 的內容(例如改動顯示的文本)來觀察熱替換的效果呢?原因在于,簡單改動 JS 中的顯示文本并不能達到熱替換的效果。盡管網絡端同樣新增了 hot-update.json 和 hot-update.js,但緊隨其后的是如上一個示例一般的刷新了整個頁面。
那么,為什么導入的 CSS 能觸發模塊熱替換,而 JS 文件的內容修改就失效了呢?要回答這個問題,我們還得從 webpack 的熱更新原理說起。
webpack 中的熱更新原理
下圖是 webpackDevServer 中 HMR 的基本流程圖,完整的 HMR 功能主要包含了三方面的技術:
+ watch 示例中體現的,對本地源代碼文件內容變更的監控。
+ instant reload 示例中體現的,瀏覽器網頁端與本地服務器端的 Websocket 通信。
+ hmr 示例中體現的,也即是最核心的,模塊解析與替換功能。

也就是說在這三種技術中,我們可以基于 Node.js 中提供的文件模塊 fs.watch 來實現對文件和文件夾的監控,同樣也可以使用 sockjs-node 或 socket.io 來實現 Websocket 的通信。而在這里,我們重點來看下第三種, webpack 中的模塊解析與替換功能。
webpack 中的打包流程
在講 webpack 的打包流程之前我們先解釋幾個 webpack 中的術語:
+ module:指在模塊化編程中我們把應用程序分割成的獨立功能的代碼模塊。
+ chunk:指模塊間按照引用關系組合成的代碼塊,一個 chunk 中可以包含多個 module。
+ chunk group:指通過配置入口點(entry point)區分的塊組,一個 chunk group 中可包含一到多個 chunk。
+ bundling:webpack 打包的過程。
+ asset/bundle:打包產物。
webpack 的打包思想可以簡化為 3 點:
+ 一切源代碼文件均可通過各種 Loader 轉換為 JS 模塊 (module),模塊之間可以互相引用。
+ webpack 通過入口點(entry point)遞歸處理各模塊引用關系,最后輸出為一個或多個產物包 js(bundle) 文件。
+ 每一個入口點都是一個塊組(chunk group),在不考慮分包的情況下,一個 chunk group 中只有一個 chunk,該 chunk 包含遞歸分析后的所有模塊。每一個 chunk 都有對應的一個打包后的輸出文件(asset/bundle)。


在上面的 hmr 示例中,從 entry 中的 './src/index1.js' 到打包產物的 dist/main.js,以模塊的角度而言,其基本流程是:
+ 唯一 entry 創建一個塊組(chunk group), name 為 main,包含了 ./src/index1.js 這一個模塊。
+ 在解析器中處理 ./src/index1.js 模塊的代碼,找到了其依賴的 './style.css',找到匹配的 loader: css-loader 和 style-loader。
+ 首先通過 css-loader 處理,將 css-loader/dist/cjs.js!./src/style.css 模塊(即把 CSS 文件內容轉化為 js 可執行代碼的模塊,這里簡稱為 Content 模塊)和 css-loader/dist/runtime/api.js 模塊打入 chunk 中。
+ 然后通過 style-loader 處理,將 style-loader/dist/runtime/injectStylesIntoStyleTag.js 模塊 (我們這里簡稱為 API 模塊),以及處理后的 .src/style.css 模塊(作用是運行時中通過 API 模塊將 Content 模塊內容注入 Style 標簽)導入 chunk 中。
+ 依次類推,直到將所有依賴的模塊均打入到 chunk 中,最后輸出名為 main.js 的產物(我們稱為 Asset 或 Bundle)。
上述流程的結果我們可以在預覽頁面中控制臺的 Sources 面板中看到,這里,我們重點看經過 style-loader 處理的 style.css 模塊的代碼:

style-loader 中的熱替換代碼
我們簡化一下上述控制臺中看到的 style-loader 處理后的模塊代碼,只看其熱替換相關的部分。
```js
//為了清晰期間,我們將模塊名稱注釋以及與熱更新無關的邏輯省略,并將 css 內容模塊路徑賦值為變量 cssContentPath 以便多處引用,實際代碼可從示例運行時中查看
var cssContentPath = "./node_modules/css-loader/dist/cjs.js!./src/style.css"
var api = __webpack_require__("./node_modules/style-loader/dist/runtime/injectStylesIntoStyleTag.js");
? ? ? ? ? ? var content = __webpack_require__(cssContentPath);
...
var update = api(content, options);
...
module.hot.accept(
cssContentPath,
function(){
content = __webpack_require__(cssContentPath);
...
update(content);
}
)
module.hot.dispose(function() {
? update();
});
```
從上面的代碼中我們可以看到,在運行時調用 API 實現將樣式注入新生成的 style 標簽,并將返回函數傳遞給 update 變量。然后,在 module.hot.accept 方法的回調函數中執行 update(content),在 module.hot.dispose 中執行 update()。通過查看上述 API 的代碼,可以發現 update(content) 是將新的樣式內容更新到原 style 標簽中,而 update() 則是移除注入的 style 標簽,那么這里的 module.hot 究竟是什么呢?
模塊熱替換插件(HotModuleReplacementPlugin)
上面的 module.hot 實際上是一個來自 webpack 的基礎插件 HotModuleReplacementPlugin,該插件作為熱替換功能的基礎插件,其 API 方法導出到了 module.hot 的屬性中。
在上面代碼的兩個 API 中,hot.accept 方法傳入依賴模塊名稱和回調方法,當依賴模塊發生更新時,其回調方法就會被執行,而開發者就可以在回調中實現對應的替換邏輯,即上面的用更新的樣式替換原標簽中的樣式。另一個 hot.dispose 方法則是傳入一個回調,當代碼上下文的模塊被移除時,其回調方法就會被執行。例如當我們在源代碼中移除導入的 CSS 模塊時,運行時原有的模塊中的 update() 就會被執行,從而在頁面移除對應的 style 標簽。
module.hot 中還包含了該插件提供的其他熱更新相關的 API 方法,這里就不再贅述了,感興趣的同學可以從 [官方文檔](https://webpack.js.org/api/hot-module-replacement/)中進一步了解。
通過上面的分析,我們就了解了熱替換的基本原理,這也解釋了為什么我們替換 index1.js 中的輸出文本內容時,并沒有觀察到熱更新,而是看到了整個頁面的刷新:因為代碼中并未包含對熱替換插件 API 的調用,代碼的解析也沒有配置額外能對特定代碼調用熱替換 API 的 Loader。所以在最后,我們就來實現下 JS 中更新文本內容的熱替換。
示例:JS 代碼中的熱替換
```js
./text.js
export const text = 'Hello World'
./index2.js
import {text} from './text.js'
const div = document.createElement('div')
document.body.appendChild(div)
function render() {
div.innerHTML = text;
}
render()
if (module.hot) {
module.hot.accept('./text.js', function() {
render()
})
}
```
在上面的代碼中,我們將用于修改的文本單獨作為一個 JS 模塊,以便傳入 hot.accept 方法。當文本發生變更時,可以觀察到瀏覽器端顯示最新內容的同時并未觸發頁面刷新,驗證生效。此外, accept 方法也支持監控當前文件的變更,對應的 DOM 更新邏輯稍做調整也能達到無刷新效果,區別在于替換自身模塊時示例中不可避免地需要更改 DOM。
從上面的例子中我們可以看到,熱替換的實現,既依賴 webpack 核心代碼中 HotModuleReplacementPlugin 所提供的相關 API,也依賴在具體模塊的加載器中實現相應 API 的更新替換邏輯。因此,在配置中開啟 hot:true 并不意味著任何代碼的變更都能實現熱替換,除了示例中演示的 style-loader 外, vue-loader、 react-hot-loader 等加載器也都實現了該功能。當開發時遇到 hmr 不生效的情況時,可以優先確認對應加載器是否支持該功能,以及是否使用了正確的配置。
總結
在這一講中我們討論了瀏覽器的熱更新,并結合示例代碼,了解了 webpack 中實現熱更新的不同配置,并進一步分析了其中熱替換技術的原理。相信你通過這一講,對 hmr 的原理和實現就有了一定的概念。在實際項目的開發中,我們可以確認項目是否正確開啟了熱替換的功能。
課后布置一個小作業供有興趣的同學們鞏固:找到一個實現熱替換的 Loader,看看其代碼中都用到了哪些相關的 API。
- 1 建立上帝視角,全面系統掌握前端效率工程化
- 2 界面調試:熱更新技術如何開著飛機修引擎?
- 3 構建提速:如何正確使用 SourceMap?
- 4 接口調試:Mock 工具如何快速進行接口調試?
- 5 編碼效率:如何提高編寫代碼的效率?
- 6 團隊工具:如何利用云開發提升團隊開發效率?
- 7 低代碼工具:如何用更少的代碼實現更靈活的需求?
- 8 無代碼工具:如何做到不寫代碼就能高效交付?
- 9 構建總覽:前端構建工具的演進
- 10 流程分解:Webpack 的完整構建流程
- 11 編譯提效:如何為 Webpack 編譯階段提速?
- 12 打包提效:如何為 Webpack 打包階段提速?
- 13 緩存優化:那些基于緩存的優化方案
- 14 增量構建:Webpack 中的增量構建
- 15 版本特性:Webpack 5 中的優化細節
- 16 無包構建:盤點那些 No-bundle 的構建方案
- 17 部署初探:為什么一般不在開發環境下部署代碼?
- 18 工具盤點:掌握那些流行的代碼部署工具
- 19 安裝提效:部署流程中的依賴安裝效率優化
- 20 流程優化:部署流程中的構建流程策略優化
- 22 容器方案:從構建到部署,容器化方案的優勢有哪些?
- 23 案例分析:搭建基本的前端高效部署系統
- 24 前端效率工程化的未來展望