Webpack HMR 原理解析

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

本篇文章不是告訴你怎麼使用 HMR,如果你對 HMR 依然感覺到陌生,建議先閱讀官網 HMR 指南,上面有 HMR 最簡單的用例,我會等著你回來的。

為什麼需要 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 打包好的文件,它們去哪呢?
  2. 通過查看 webpack-dev-server 的 package.json 文件,我們知道其依賴於 webpack-dev-middleware 庫,那麼 webpack-dev-middleware 在 HMR 過程中扮演什麼角色?
  3. 使用 HMR 的過程中,通過 Chrome 開發者工具我知道瀏覽器是通過 websocket 和 webpack-dev-server 進行通信的,但是 websocket 的 message 中並沒有發現新模塊代碼。打包後的新模塊又是通過什麼方式發送到瀏覽器端的呢?為什麼新的模塊不通過 websocket 隨消息一起發送到瀏覽器端呢?
  4. 瀏覽器拿到最新的模塊代碼,HMR 又是怎麼將老的模塊替換成新的模塊,在替換的過程中怎樣處理模塊之間的依賴關係?
  5. 當模塊的熱替換過程中,如果替換模塊失敗,有什麼回退機制嗎?

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

圖一: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.jsn--index.jsn--index.htmln--package.jsonn--webpack.config.jsn

項目中包含兩個 js 文件,項目入口文件是 index.js 文件,hello.js 文件是 index.js 文件的一個依賴,js 代碼如你所見(點擊上面例子鏈接可以查看源碼),將在 body 元素中添加一個包含「hello world」的 div 元素。

webpack.config.js的配置如下:

const path = require(path)nconst webpack = require(webpack)nmodule.exports = {n entry: ./index.js,n output: {n filename: bundle.js,n path: path.join(__dirname, /)n },n devServer: {n hot: truen }n}n

值得一提的是,在上面的配置中並沒有配置 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.jsn- const hello = () => hello world // 將 hello world 字元串修改為 hello elemen+ const hello = () => hello elemen

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

第一步:webpack 對文件系統進行 watch 打包到內存中

webpack-dev-middleware 調用 webpack 的 api 對文件系統 watch,當 hello.js 文件發生改變後,webpack 重新對文件進行編譯打包,然後保存到內存中。

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

你可能會疑問了,為什麼 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.jsnvar isMemoryFs = !compiler.compilers && compiler.outputFileSystem instanceof MemoryFileSystem;nif(isMemoryFs) {n fs = compiler.outputFileSystem;n} else {n fs = compiler.outputFileSystem = new MemoryFileSystem();n}n

首先判斷當前 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/lib/Server.jsncompiler.plugin(done, (stats) => {n // stats.hash 是最新打包文件的 hash 值n this._sendStats(this.sockets, stats.toJson(clientStats));n this._stats = stats;n});n...nServer.prototype._sendStats = function (sockets, stats, force) {n if (!force && stats &&n (!stats.errors || stats.errors.length === 0) && stats.assets &&n stats.assets.every(asset => !asset.emitted)n ) { return this.sockWrite(sockets, still-ok); }n // 調用 sockWrite 方法將 hash 值通過 websocket 發送到瀏覽器端n this.sockWrite(sockets, hash, stats.hash);n if (stats.errors.length > 0) { this.sockWrite(sockets, errors, stats.errors); } n else if (stats.warnings.length > 0) { this.sockWrite(sockets, warnings, stats.warnings); } else { this.sockWrite(sockets, ok); }n};n

第三步: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 消息之前。

圖二:websocket 接收 dev-server 通過 sockjs 發送到瀏覽器端的消息列表

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

// webpack-dev-server/client/index.jsnhash: function msgHash(hash) {n currentHash = hash;n},nok: function msgOk() {n // ...n reloadApp();n},n// ...nfunction reloadApp() {n // ...n if (hot) {n log.info([WDS] App hot update...);n const hotEmitter = require(webpack/hot/emitter);n hotEmitter.emit(webpackHotUpdate, currentHash);n // ...n } else {n log.info([WDS] App updated. Reloading...);n self.location.reload();n }n}n

如上面代碼所示,首先將 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)中的兩個方法 hotDownloadUpdateChunkhotDownloadManifest , 第二個方法是調用 AJAX 向服務端請求是否有更新的文件,如果有將發更新的文件列表返回瀏覽器端,而第一個方法是通過 jsonp 請求最新的模塊代碼,然後將代碼返回給 HMR runtime,HMR runtime 會根據返回的新模塊代碼做進一步處理,可能是刷新頁面,也可能是對模塊進行熱更新。

圖三:hotDownloadManifest方法獲取更新文件列表

圖四: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 多行代碼,我將只摘取關鍵代碼片段。

// webpack/lib/HotModuleReplacement.runtimenfunction hotApply() {n // ...n var idx;n var queue = outdatedModules.slice();n while(queue.length > 0) {n moduleId = queue.pop();n module = installedModules[moduleId];n // ...n // remove module from cachen delete installedModules[moduleId];n // when disposing there is no need to call dispose handlern delete outdatedDependencies[moduleId];n // remove "parents" references from all childrenn for(j = 0; j < module.children.length; j++) {n var child = installedModules[module.children[j]];n if(!child) continue;n idx = child.parents.indexOf(moduleId);n if(idx >= 0) {n child.parents.splice(idx, 1);n }n }n }n // ...n // insert new coden for(moduleId in appliedUpdate) {n if(Object.prototype.hasOwnProperty.call(appliedUpdate, moduleId)) {n modules[moduleId] = appliedUpdate[moduleId];n }n }n // ...n}n

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

delete installedModules[moduleId];

delete outdatedDependencies[moduleId];

第三個階段是將新的模塊添加到 modules 中,當下次調用 __webpack_require__ (webpack 重寫的 require 方法)方法的時候,就是獲取到了新的模塊代碼了。

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

module.hot.check(true).then(function(updatedModules) {n if(!updatedModules) {n return window.location.reload();n }n // ...n}).catch(function(err) {n var status = module.hot.status();n if(["abort", "fail"].indexOf(status) >= 0) {n window.location.reload();n }n});n

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

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

當用新的模塊代碼替換老的模塊後,但是我們的業務代碼並不能知道代碼已經發生變化,也就是說,當 hello.js 文件修改後,我們需要在 index.js 文件中調用 HMR 的 accept 方法,添加模塊更新後的處理函數,及時將 hello 方法的返回值插入到頁面中。代碼如下:

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

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

寫在最後

這篇文章的作用並不是對 webpack HMR 的詳盡解析,很多細節方面也沒過多討論,而只想起到一個拋磚引玉的作用,給大家展現一個 HMR 概述的工作流程,如果對 webpack 感興趣,想了解 webpack HMR 更多的底層細節,相信閱讀 webpack 源碼將是一個不錯的選擇,也希望這篇文章能夠對你閱讀源碼有所幫助,這才是我真正的寫作目的。


推薦閱讀:

?? 使用 Dawn 快速搭建 React 項目!
HTML里為什麼一些input里要設置value為空?
Egg + Vue SSR 組件非同步載入

TAG:webpack | 前端开发 | 前端工程化 |