1. 程式人生 > 實用技巧 >Webpack HMR 原理解析

Webpack HMR 原理解析

Hot Module Replacement(以下簡稱 HMR)是 webpack 發展至今引入的最令人興奮的特性之一 ,當你對程式碼進行修改並儲存後,webpack 將對程式碼重新打包,並將新的模組傳送到瀏覽器端,瀏覽器通過新的模組替換老的模組,這樣在不重新整理瀏覽器的前提下就能夠對應用進行更新。例如,在開發 Web 頁面過程中,當你點選按鈕,出現一個彈窗的時候,發現彈窗標題沒有對齊,這時候你修改 CSS 樣式,然後儲存,在瀏覽器沒有重新整理的前提下,標題樣式發生了改變。感覺就像在 Chrome 的開發者工具中直接修改元素樣式一樣。

本篇文章不是告訴你怎麼使用 HMR,如果你對 HMR 依然感覺到陌生,建議先閱讀官網 HMR 指南,上面有 HMR 最簡單的用例,我會等著你回來的。週末更新的webpack系列也是一些簡單的基礎,希望對你有用:

Webpack從3到4升級完全指南

基於vue2.X的webpack基本配置,教你手動擼一個webpack4的配置

webpack輸出檔案分析以及編寫一個loader

理解webpack外掛系統,動手編寫一個簡單的Plugin

webpack 配置,教你如何提取公共模組

為什麼需要 HMR

在 webpack HMR 功能之前,已經有很多 live reload 的工具或庫,比如 live-server,這些庫監控檔案的變化,然後通知瀏覽器端重新整理頁面,那麼我們為什麼還需要 HMR 呢?答案其實在上文中已經提及一些。

  • live reload 工具並不能夠儲存應用的狀態(states),當重新整理頁面後,應用之前狀態丟失,還是上文中的例子,點選按鈕出現彈窗,當瀏覽器重新整理後,彈窗也隨即消失,要恢復到之前狀態,還需再次點選按鈕。而 webapck HMR 則不會重新整理瀏覽器,而是執行時對模組進行熱替換,保證了應用狀態不會丟失,提升了開發效率。在古老的開發流程中,我們可能需要手動執行命令對程式碼進行打包,並且打包後再手動重新整理瀏覽器頁面,而這一系列重複的工作都可以通過 HMR 工作流來自動化完成,讓更多的精力投入到業務中,而不是把時間浪費在重複的工作上。HMR 相容市面上大多前端框架或庫,比如 React Hot Loader,Vue-loader,能夠監聽 React 或者 Vue 元件的變化,實時將最新的元件更新到瀏覽器端。Elm Hot Loader 支援通過 webpack 對 Elm 語言程式碼進行轉譯並打包,當然它也實現了 HMR 功能。

HMR 的工作原理圖解

初識 HMR 的時候覺得其很神奇,一直有一些疑問縈繞在腦海。

  1. webpack 可以將不同的模組打包成 bundle 檔案或者幾個 chunk 檔案,但是當我通過 webpack HMR 進行開發的過程中,我並沒有在我的 dist 目錄中找到 webpack 打包好的檔案,它們去哪呢?通過檢視 webpack-dev-server 的 package.json 檔案,我們知道其依賴於 webpack-dev-middleware 庫,那麼 webpack-dev-middleware 在 HMR 過程中扮演什麼角色?使用 HMR 的過程中,通過 Chrome 開發者工具我知道瀏覽器是通過 websocket 和 webpack-dev-server 進行通訊的,但是 websocket 的 message 中並沒有發現新模組程式碼。打包後的新模組又是通過什麼方式傳送到瀏覽器端的呢?為什麼新的模組不通過 websocket 隨訊息一起傳送到瀏覽器端呢?瀏覽器拿到最新的模組程式碼,HMR 又是怎麼將老的模組替換成新的模組,在替換的過程中怎樣處理模組之間的依賴關係?當模組的熱替換過程中,如果替換模組失敗,有什麼回退機制嗎?

帶著上面的問題,於是決定深入到 webpack 原始碼,尋找 HMR 底層的奧祕。

上圖是webpack 配合 webpack-dev-server 進行應用開發的模組熱更新流程圖。

  • 上圖底部紅色框內是服務端,而上面的橙色框是瀏覽器端。
  • 綠色的方框是 webpack 程式碼控制的區域。藍色方框是 webpack-dev-server 程式碼控制的區域,洋紅色的方框是檔案系統,檔案修改後的變化就發生在這,而青色的方框是應用本身。

上圖顯示了我們修改程式碼到模組熱更新完成的一個週期,通過深綠色的阿拉伯數字符號已經將 HMR 的整個過程標識了出來。

1、第一步,在 webpack 的 watch 模式下,檔案系統中某一個檔案發生修改,webpack 監聽到檔案變化,根據配置檔案對模組重新編譯打包,並將打包後的程式碼通過簡單的 JavaScript 物件儲存在記憶體中。

2、第二步是 webpack-dev-server 和 webpack 之間的介面互動,而在這一步,主要是 dev-server 的中介軟體 webpack-dev-middleware 和 webpack 之間的互動,webpack-dev-middleware 呼叫 webpack 暴露的 API對程式碼變化進行監控,並且告訴 webpack,將程式碼打包到記憶體中。

3、第三步是 webpack-dev-server 對檔案變化的一個監控,這一步不同於第一步,並不是監控程式碼變化重新打包。當我們在配置檔案中配置了devServer.watchContentBase 為 true 的時候,Server 會監聽這些配置資料夾中靜態檔案的變化,變化後會通知瀏覽器端對應用進行 live reload。注意,這兒是瀏覽器重新整理,和 HMR 是兩個概念。

4、第四步也是 webpack-dev-server 程式碼的工作,該步驟主要是通過 sockjs(webpack-dev-server 的依賴)在瀏覽器端和服務端之間建立一個 websocket 長連線,將 webpack 編譯打包的各個階段的狀態資訊告知瀏覽器端,同時也包括第三步中 Server 監聽靜態檔案變化的資訊。瀏覽器端根據這些 socket 訊息進行不同的操作。當然服務端傳遞的最主要資訊還是新模組的 hash 值,後面的步驟根據這一 hash 值來進行模組熱替換。

5、webpack-dev-server/client 端並不能夠請求更新的程式碼,也不會執行熱更模組操作,而把這些工作又交回給了 webpack,webpack/hot/dev-server 的工作就是根據 webpack-dev-server/client 傳給它的資訊以及 dev-server 的配置決定是重新整理瀏覽器呢還是進行模組熱更新。當然如果僅僅是重新整理瀏覽器,也就沒有後面那些步驟了。

6、HotModuleReplacement.runtime 是客戶端 HMR 的中樞,它接收到上一步傳遞給他的新模組的 hash 值,它通過 JsonpMainTemplate.runtime 向 server 端傳送 Ajax 請求,服務端返回一個 json,該 json 包含了所有要更新的模組的 hash 值,獲取到更新列表後,該模組再次通過 jsonp 請求,獲取到最新的模組程式碼。這就是上圖中 7、8、9 步驟。

7、而第 10 步是決定 HMR 成功與否的關鍵步驟,在該步驟中,HotModulePlugin 將會對新舊模組進行對比,決定是否更新模組,在決定更新模組後,檢查模組之間的依賴關係,更新模組的同時更新模組間的依賴引用。

8、最後一步,當 HMR 失敗後,回退到 live reload 操作,也就是進行瀏覽器重新整理來獲取最新打包程式碼。

運用 HMR 的簡單例子

在上一個部分,通過一張 HMR 流程圖,簡要的說明了 HMR 進行模組熱更新的過程。當然你可能感覺還是很迷糊,對上面出現的一些英文名詞也可能比較陌生(上面這些英文名詞代表著程式碼倉庫或者倉庫中的檔案模組),沒關係,在這一部分,我將通過一個最簡單最純粹的例子,通過分析 wepack及 webpack-dev-server 原始碼詳細說明各個庫在 HMR 過程中的具體職責。

在開始這個例子之前簡單對這個倉庫檔案進行下說明,倉庫中包含檔案如下:

--hello.js
--index.js
--index.html
--package.json
--webpack.config.js

專案中包含兩個 js 檔案,專案入口檔案是 index.js 檔案,hello.js 檔案是 index.js 檔案的一個依賴,js 程式碼如你所見(點選上面例子連結可以檢視原始碼),將在 body 元素中新增一個包含「hello world」的 div 元素。

webpack.config.js的配置如下:

const path = require('path')
const webpack = require('webpack')
module.exports = {
entry: './index.js',
output: {
filename: 'bundle.js',
path: path.join(__dirname, '/')
},
devServer: {
hot: true
}
}

值得一提的是,在上面的配置中並沒有配置 HotModuleReplacementPlugin,原因在於當我們設定 devServer.hot 為 true 後,並且在package.json 檔案中新增如下的 script 指令碼:

"start": "webpack-dev-server --hot --open"

新增 —hot 配置項後,devServer 會告訴 webpack 自動引入HotModuleReplacementPlugin外掛,而不用我們再手動引入了。

進入到倉庫目錄,npm install 安裝依賴後,執行 npm start 就啟動了 devServer 服務,訪問 http://127.0.0.1:8080 就可以看到我們的頁面了。

下面將進入到關鍵環節,在簡單例子中,我將修改 hello.js 檔案中的程式碼,在原始碼層面上來分析 HMR 的具體執行流程,當然我還是將按照上面圖解來分析。修改程式碼如下:(以下所有程式碼塊首行就是該檔案的路徑)

// hello.js
- const hello = () => 'hello world' // 將 hello world 字串修改為 hello eleme
+ const hello = () => 'hello eleme'

頁面中 hello world 文字隨即變成 hello eleme。

第一步:webpack 對檔案系統進行 watch 打包到記憶體中

webpack-dev-middleware 呼叫 webpack 的 api 對檔案系統 watch,當 hello.js 檔案發生改變後,webpack 重新對檔案進行編譯打包,然後儲存到記憶體中。

// webpack-dev-middleware/lib/Shared.js
if(!options.lazy) {
var watching = compiler.watch(options.watchOptions, share.handleCompilerCallback);
context.watching = watching;
}

你可能會疑問了,為什麼 webpack 沒有將檔案直接打包到 output.path 目錄下呢?檔案又去了哪兒?原來 webpack 將 bundle.js 檔案打包到了記憶體中,不生成檔案的原因就在於訪問記憶體中的程式碼比訪問檔案系統中的檔案更快,而且也減少了程式碼寫入檔案的開銷,這一切都歸功於memory-fs,memory-fs 是 webpack-dev-middleware 的一個依賴庫,webpack-dev-middleware 將 webpack 原本的 outputFileSystem 替換成了MemoryFileSystem 例項,這樣程式碼就將輸出到記憶體中。webpack-dev-middleware 中該部分原始碼如下:

// webpack-dev-middleware/lib/Shared.js
var isMemoryFs = !compiler.compilers && compiler.outputFileSystem instanceof MemoryFileSystem;
if(isMemoryFs) {
fs = compiler.outputFileSystem;
} else {
fs = compiler.outputFileSystem = new MemoryFileSystem();
}

首先判斷當前 fileSystem 是否已經是 MemoryFileSystem 的例項,如果不是,用 MemoryFileSystem 的例項替換 compiler 之前的 outputFileSystem。這樣 bundle.js 檔案程式碼就作為一個簡單 javascript 物件儲存在了記憶體中,當瀏覽器請求 bundle.js 檔案時,devServer就直接去記憶體中找到上面儲存的 javascript 物件返回給瀏覽器端。

第二步:devServer 通知瀏覽器端檔案發生改變

在這一階段,sockjs 是服務端和瀏覽器端之間的橋樑,在啟動 devServer 的時候,sockjs 在服務端和瀏覽器端建立了一個 webSocket 長連線,以便將 webpack 編譯和打包的各個階段狀態告知瀏覽器,最關鍵的步驟還是 webpack-dev-server 呼叫 webpack api 監聽 compile的 done 事件,當compile 完成後,webpack-dev-server通過 _sendStatus 方法將編譯打包後的新模組 hash 值傳送到瀏覽器端。

第三步:webpack-dev-server/client 接收到服務端訊息做出響應

可能你又會有疑問,我並沒有在業務程式碼裡面新增接收 websocket 訊息的程式碼,也沒有在 webpack.config.js 中的 entry 屬性中新增新的入口檔案,那麼 bundle.js 中接收 websocket 訊息的程式碼從哪來的呢?原來是 webpack-dev-server 修改了webpack 配置中的 entry 屬性,在裡面添加了 webpack-dev-client 的程式碼,這樣在最後的 bundle.js 檔案中就會有接收 websocket 訊息的程式碼了。

webpack-dev-server/client 當接收到 type 為 hash 訊息後會將 hash 值暫存起來,當接收到 type 為 ok 的訊息後對應用執行 reload 操作,如下圖所示,hash 訊息是在 ok 訊息之前。

在 reload 操作中,webpack-dev-server/client 會根據 hot 配置決定是重新整理瀏覽器還是對程式碼進行熱更新(HMR)。程式碼如下:

如上面程式碼所示,首先將 hash 值暫存到 currentHash 變數,當接收到 ok 訊息後,對 App 進行 reload。如果配置了模組熱更新,就呼叫 webpack/hot/emitter 將最新 hash 值傳送給 webpack,然後將控制權交給 webpack 客戶端程式碼。如果沒有配置模組熱更新,就直接呼叫 location.reload 方法重新整理頁面。

第四步:webpack 接收到最新 hash 值驗證並請求模組程式碼

在這一步,其實是 webpack 中三個模組(三個檔案,後面英文名對應檔案路徑)之間配合的結果,首先是 webpack/hot/dev-server(以下簡稱 dev-server) 監聽第三步 webpack-dev-server/client 傳送的 webpackHotUpdate 訊息,呼叫 webpack/lib/HotModuleReplacement.runtime(簡稱 HMR runtime)中的 check 方法,檢測是否有新的更新,在 check 過程中會利用 webpack/lib/JsonpMainTemplate.runtime(簡稱 jsonp runtime)中的兩個方法 hotDownloadUpdateChunk 和 hotDownloadManifest , 第二個方法是呼叫 AJAX 向服務端請求是否有更新的檔案,如果有將發更新的檔案列表返回瀏覽器端,而第一個方法是通過 jsonp 請求最新的模組程式碼,然後將程式碼返回給 HMR runtime,HMR runtime 會根據返回的新模組程式碼做進一步處理,可能是重新整理頁面,也可能是對模組進行熱更新。

hotDownloadUpdateChunk獲取到更新的新模組程式碼

如上兩圖所示,值得注意的是,兩次請求的都是使用上一次的 hash 值拼接的請求檔名,hotDownloadManifest 方法返回的是最新的 hash 值,hotDownloadUpdateChunk 方法返回的就是最新 hash 值對應的程式碼塊。然後將新的程式碼塊返回給 HMR runtime,進行模組熱更新。

還記得 HMR 的工作原理圖解 中的問題 3 嗎?為什麼更新模組的程式碼不直接在第三步通過 websocket 傳送到瀏覽器端,而是通過 jsonp 來獲取呢?我的理解是,功能塊的解耦,各個模組各司其職,dev-server/client 只負責訊息的傳遞而不負責新模組的獲取,而這些工作應該有 HMR runtime 來完成,HMR runtime 才應該是獲取新程式碼的地方。再就是因為不使用 webpack-dev-server 的前提,使用 webpack-hot-middleware 和 webpack 配合也可以完成模組熱更新流程,在使用 webpack-hot-middleware 中有件有意思的事,它沒有使用 websocket,而是使用的 EventSource。綜上所述,HMR 的工作流中,不應該把新模組程式碼放在 websocket 訊息中。

第五步:HotModuleReplacement.runtime 對模組進行熱更新

這一步是整個模組熱更新(HMR)的關鍵步驟,而且模組熱更新都是發生在HMR runtime 中的 hotApply 方法中,這兒我不打算把 hotApply 方法整個原始碼貼出來了,因為這個方法包含 300 多行程式碼,我將只摘取關鍵程式碼片段。

從上面 hotApply 方法可以看出,模組熱替換主要分三個階段,第一個階段是找出 outdatedModules 和 outdatedDependencies,這兒我沒有貼這部分程式碼,有興趣可以自己閱讀原始碼。第二個階段從快取中刪除過期的模組和依賴,如下:

delete installedModules[moduleId];

delete outdatedDependencies[moduleId];

第三個階段是將新的模組新增到 modules 中,當下次呼叫 __webpack_require__ (webpack 重寫的 require 方法)方法的時候,就是獲取到了新的模組程式碼了。

模組熱更新的錯誤處理,如果在熱更新過程中出現錯誤,熱更新將回退到重新整理瀏覽器,這部分程式碼在 dev-server 程式碼中,簡要程式碼如下:

dev-server 先驗證是否有更新,沒有程式碼更新的話,過載瀏覽器。如果在 hotApply 的過程中出現 abort 或者 fail 錯誤,也進行過載瀏覽器。

第六步:業務程式碼需要做些什麼?

當用新的模組程式碼替換老的模組後,但是我們的業務程式碼並不能知道程式碼已經發生變化,也就是說,當 hello.js 檔案修改後,我們需要在 index.js 檔案中呼叫 HMR 的 accept 方法,新增模組更新後的處理函式,及時將 hello 方法的返回值插入到頁面中(JSONP的方式)。程式碼如下:

// index.js
if(module.hot) {
module.hot.accept('./hello.js', function() {
div.innerHTML = hello()
})
}

這樣就是整個 HMR 的工作流程了。