學習時間:2020.06.14
學習章節:《Webpack HMR 原理解析》
javascript
Hot Module Replacement(如下簡稱:HMR 模塊熱替換)是 Webpack 提供的一個很是有用的功能,它容許在 JavaScript 運行時更新各類模塊,而無需徹底刷新。html
Hot Module Replacement (or HMR) is one of the most useful features offered by webpack. It allows all kinds of modules to be updated at runtime without the need for a full refresh.
--《Hot Module Replacement》
當咱們修改代碼並保存後,Webpack 將對代碼從新打包,HMR 會在應用程序運行過程當中替換、添加或刪除模塊,而無需從新加載整個頁面。
HMR 主要經過如下幾種方式,來顯著加快開發速度:java
須要注意:HMR 不適用於生產環境,這意味着它應當只在開發環境使用。
node
在 Webpack 中啓用 HMR 功能比較簡單:webpack
只須要在 webpack.config.js
中添加 devServer
選項,並設置 hot
值爲 true
,並使用HotModuleReplacementPlugin
和 NamedModulesPlugin
(可選)兩個 Plugins :git
// 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, // 啓動模塊熱更新 HMR + open: true, // 開啓自動打開瀏覽器頁面 + }, plugins: [ + new webpack.NamedModulesPlugin(), + new webpack.HotModuleReplacementPlugin() ] }
而後在 package.json
中爲 scripts
命令便可:github
// package.json { // ... "scripts": { + "start": "webpack-dev-server" }, // ... }
另外一種是經過添加 --hot
參數來實現。添加 --hot
參數後,devServer 會告訴 Webpack 自動引入 HotModuleReplacementPlugin
,而不須要咱們手動引入。
另外經常也搭配 --open
來自動打開瀏覽器到頁面。
這裏移除掉前面添加的兩個 Plugins :web
// webpack.config.js const path = require('path') const webpack = require('webpack') module.exports = { // ... - plugins: [ - new webpack.NamedModulesPlugin(), - new webpack.HotModuleReplacementPlugin() - ] }
而後修改 package.json
文件中的 scripts
配置:chrome
// package.json { // ... "scripts": { - "start": "webpack-dev-server" + "start": "webpack-dev-server --hot --open" }, // ... }
基於上述配置,咱們簡單實現一個場景: index.js
文件中導入 hello.js
模塊,當 hello.js
模塊發生變化時, index.js
將更新模塊。
模塊代碼以下實現:express
// hello.js export default () => 'hi leo!'; // index.js import hello from './hello.js' const div = document.createElement('div'); div.innerHTML = hello(); document.body.appendChild(div);
而後在 index.html
中導入打包後的 JS 文件,並執行 npm start
運行項目:
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> </head> <body> <div>了不得的 Webpack HMR 學習指南</div> <script src="bundle.js"></script> </body> </html>
當咱們經過 HotModuleReplacementPlugin
插件啓用了 HMR,則它的接口將被暴露在全局 module.hot
屬性下面。一般,能夠先檢查這個接口是否可訪問,而後再開始使用它。
舉個例子,你能夠這樣 accept
一個更新的模塊:
if (module.hot) { module.hot.accept('./library.js', function() { // 使用更新過的 library 模塊執行某些操做... }) }
關於 module.hot
更多 API ,能夠查看官方文檔《Hot Module Replacement API》 。
回到上面示例,咱們測試更新模塊的功能。
這時咱們修改 index.js
代碼,來監聽 hello.js
模塊中的更新:
import hello from './hello.js'; const div = document.createElement('div'); div.innerHTML = hello(); document.body.appendChild(div); + if (module.hot) { + module.hot.accept('./hello.js', function() { + console.log('如今在更新 hello 模塊了~'); + div.innerHTML = hello(); + }) + }
而後修改 hello.js
文件內容,測試效果:
- export default () => 'hi leo!'; + export default () => 'hi leo! hello world';
當咱們保存代碼時,控制檯輸出 "如今在更新 hello模塊了~"
,而且頁面中 "hi leo!"
也更新爲 "hi leo! hello world"
,證實咱們監聽到文件更新了。
簡單 Webpack HMR 使用方式就介紹到這,更多介紹,還請閱讀官方文檔《Hot Module Replacement》。
根據目錄結構的不一樣,contentBase
、openPage
參數要配置合適的值,不然運行時應該不會馬上訪問到你的首頁。 同時要注意你的 publicPath
,靜態資源打包後生成的路徑是一個須要思考的點,取決於你的目錄結構。
devServer: { contentBase: path.join(__dirname, 'static'), // 告訴服務器從哪裏提供內容(默認當前工做目錄) openPage: 'views/index.html', // 指定默認啓動瀏覽器時打開的頁面 index: 'views/index.html', // 指定首頁位置 watchContentBase: true, // contentBase下文件變更將reload頁面(默認false) host: 'localhost', // 默認localhost,想外部可訪問用'0.0.0.0' port: 8080, // 默認8080 inline: true, // 能夠監控js變化 hot: true, // 熱啓動 open: true, // 啓動時自動打開瀏覽器(指定打開chrome,open: 'Google Chrome') compress: true, // 一切服務都啓用gzip 壓縮 disableHostCheck: true, // true:不進行host檢查 quiet: false, https: false, clientLogLevel: 'none', stats: { // 設置控制檯的提示信息 chunks: false, children: false, modules: false, entrypoints: false, // 是否輸出入口信息 warnings: false, performance: false, // 是否輸出webpack建議(如文件體積大小) }, historyApiFallback: { disableDotRule: true, }, watchOptions: { ignored: /node_modules/, // 略過node_modules目錄 }, proxy: { // 接口代理(這段配置更推薦:寫到package.json,再引入到這裏) "/api-dev": { "target": "http://api.test.xxx.com", "secure": false, "changeOrigin": true, "pathRewrite": { // 將url上的某段重寫(例如此處是將 api-dev 替換成了空) "^/api-dev": "" } } }, before(app) { }, }
dev-server 輸出的代碼一般在內存中,但也能夠寫入硬盤,產出實體文件:
devServer:{ writeToDisk: true, }
一般能夠用於代理映射文件調試,編譯時會產出許多帶 hash 的 js 文件,不帶 hash 的文件一樣也是實時編譯的。
有的時候,啓動服務時,想要默認使用本地的 ip 地址打開:
devServer:{ disableHostCheck: true, // true:不進行host檢查 // useLocalIp: true, // 建議不在這裏配置 // host: '0.0.0.0', // 建議不在這裏配置 }
同時還須要將 host 配置爲 0.0.0.0
,這個配置建議在 scripts 命令中追加,而非在配置中寫死,不然未來不想要這種方式往回改折騰,取巧一點,配個新命令:
"dev-ip": "yarn run dev --host 0.0.0.0 --useLocalIp"
有時啓動的時候但願是指定的調試域名,例如:local.test.baidu.com
:
devServer:{ open: true, public: 'local.test.baidu.com:8080', // 須要帶上端口 port: 8080, }
同時須要將 127.0.0.1
修改成指定的 host,能夠藉助 iHost 等工具去修改,各個工具大同小異,格式以下:
127.0.0.1 local.test.baidu.com
服務啓動後將自動打開 local.test.baidu.com:8080
訪問
devServer:{ compress: true, }
從前面介紹中,咱們知道:HMR 主要功能是會在應用程序運行過程當中替換、添加或刪除模塊,而無需從新加載整個頁面。
那麼,Webpack 編譯源碼所產生的文件變化在編譯時,替換模塊實如今運行時,二者如何聯繫起來?
帶着這兩個問題,咱們先簡單看下 HMR 核心工做流程(簡化版):
接下來開始 HMR 工做流程分析:
其中,HMR Runtime 是構建工具在編譯時注入的,經過統一的 Module ID 將編譯時的文件與運行時的模塊對應起來,而且對外提供一系列 API 供應用層框架(如 React)調用。
💖注意💖:建議先理解上面這張圖的大體流程,在進行後續閱讀。放心,我等着你們~😃
經過上一節內容,咱們大概知道 HMR 簡單工做流程,那麼或許你如今可能還有不少疑惑:文件更新是什麼通知 HMR Plugin?HMR Plugin 怎麼發送更新到 HMR Runtime?等等問題。
那麼接下來咱們開始詳細結合源碼分析整個 HMR 模塊熱更新流程,首先仍是先看流程圖,能夠先不瞭解圖中方法名稱(紅色字體黃色背景色部分):
上圖展現了從咱們修改代碼,到模塊熱更新完成的一個 HMR 完整工做流程,圖中已用紅色阿拉伯數字符號將流程標識出來。
要了解上面工做原理,咱們先理解圖中這幾個名稱概念:
下面一塊兒學習 HMR 整個工做原理吧:
首先根據 devServer 配置,使用 npm start
將啓動 Webpack-dev-server 啓動本地服務器並進入 Webpack 的 watch 模式,而後初始化 Webpack-dev-middleware ,在 Webpack-dev-middleware 中經過調用 startWatch()
方法對文件系統進行 watch:
// webpack-dev-server\bin\webpack-dev-server.js // 1.啓動本地服務器 Line 386 server = new Server(compiler, options); // webpack-dev-server\lib\Server.js // 2.初始化 Webpack-dev-middleware Line 109 this.middleware = webpackDevMiddleware(compiler, Object.assign({}, options, wdmOptions)); // webpack-dev-middleware\lib\Shared.js // 3.開始 watch 文件系統 Line 171 startWatch: function() { //... // start watching if(!options.lazy) { var watching = compiler.watch(options.watchOptions, share.handleCompilerCallback); context.watching = watching; } //... } share.startWatch(); // ...
當 startWatch()
方法執行後,便進入 watch 模式,若發現文件中代碼發生修改,則根據配置文件對模塊從新編譯打包。
Webpack 與 Webpack-dev-middleware 交互,Webpack-dev-middleware 調用 Webpack 的 API 對代碼變化進行監控,並通知 Webpack 將從新編譯的代碼經過 JavaScript 對象保存在內存中。
咱們會發現,在 output.path
指定的 dist
目錄並無保存編譯結果的文件,這是爲何?
其實, Webpack 將編譯結果保存在內存中,由於訪問內存中的代碼比訪問文件系統中的文件快,這樣能夠減小代碼寫入文件的開銷。
Webpack 能將代碼保存到內存中,須要歸功於 Webpack-dev-middleware 的 memory-fs
依賴庫,它將本來 outputFileSystem
替換成了 MemoryFileSystem
的實例,便實現代碼輸出到內存中。其中部分源碼以下:
// webpack-dev-middleware\lib\Shared.js Line 108 // store our files in memory var fs; var isMemoryFs = !compiler.compilers && compiler.outputFileSystem instanceof MemoryFileSystem; if(isMemoryFs) { fs = compiler.outputFileSystem; } else { fs = compiler.outputFileSystem = new MemoryFileSystem(); } context.fs = fs;
上述代碼先判斷 fileSystem
是不是 MemoryFileSystem
的實例,若不是,則用 MemoryFileSystem
的實例替換 compiler 以前的 outputFileSystem
。這樣 bundle.js 文件代碼就做爲一個簡單 JavaScript 對象保存在內存中,當瀏覽器請求 bundle.js 文件時,devServer 就直接去內存中找到上面保存的 JavaScript 對象並返回給瀏覽器端。
Webpack-dev-server 開始監控文件變化,與第 1 步不一樣的是,這裏並非監控代碼變化從新編譯打包。
當咱們在配置文件中配置了 devServer.watchContentBase
爲 true
,Webpack-dev-server 會監聽配置文件夾中靜態文件的變化,發生變化時,通知瀏覽器端對應用進行瀏覽器刷新,這與 HMR 不同。
// webpack-dev-server\lib\Server.js // 1. 讀取參數 Line 385 if (options.watchContentBase) { defaultFeatures.push('watchContentBase'); } // 2. 定義 _watch 方法 Line 697 Server.prototype._watch = function (watchPath) { // ... const watcher = chokidar.watch(watchPath, options).on('change', () => { this.sockWrite(this.sockets, 'content-changed'); }); this.contentBaseWatchers.push(watcher); }; // 3. 執行 _watch() 監聽文件變化 Line 339 watchContentBase: () => { if (/^(https?:)?\/\//.test(contentBase) || typeof contentBase === 'number') { throw new Error('Watching remote files is not supported.'); } else if (Array.isArray(contentBase)) { contentBase.forEach((item) => { this._watch(item); }); } else { this._watch(contentBase); } }
這一步都是 Webpack-dev-server 中處理,主要經過 sockjs(Webpack-dev-server 的依賴),在 Webpack-dev-server 的瀏覽器端(Client)和服務器端(Webpack-dev-middleware)之間創建 WebSocket 長鏈接。
而後將 Webpack 編譯打包的各個階段狀態信息同步到瀏覽器端。其中有兩個重要步驟:
Webpack-dev-server 經過 Webpack API 監聽 compile 的 done
事件,當 compile 完成後,Webpack-dev-server 經過 _sendStats
方法將編譯後新模塊的 hash 值用 socket 發送給瀏覽器端。
瀏覽器端將_sendStats
發送過來的 hash
保存下來,它將會用到後模塊熱更新。
// webpack-dev-server\lib\Server.js // 1. 定義 _sendStats 方法 Line 685 // send stats to a socket or multiple sockets Server.prototype._sendStats = function (sockets, stats, force) { //... this.sockWrite(sockets, 'hash', stats.hash); }; // 2. 監聽 done 事件 Line 86 compiler.plugin('done', (stats) => { // 將最新打包文件的 hash 值(stats.hash)做爲參數傳入 _sendStats() this._sendStats(this.sockets, stats.toJson(clientStats)); this._stats = stats; }); // webpack-dev-server\client\index.js // 3. 保存 hash 值 Line 74 var onSocketMsg = { // ... hash: function hash(_hash) { currentHash = _hash; }, // ... } socket(socketUrl, onSocketMsg);
當 hash
消息發送完成後,socket 還會發送一條 ok
的消息告知 Webpack-dev-server,因爲客戶端(Client)並不請求熱更新代碼,也不執行熱更新模塊操做,所以經過 emit
一個 "webpackHotUpdate"
消息,將工做轉交回 Webpack。
// webpack-dev-server\client\index.js // 1. 處理 ok 消息 Line 135 var onSocketMsg = { // ... ok: function ok() { sendMsg('Ok'); if (useWarningOverlay || useErrorOverlay) overlay.clear(); if (initial) return initial = false; // eslint-disable-line no-return-assign reloadApp(); }, // ... } // 2. 處理刷新 APP Line 218 function reloadApp() { // ... if (_hot) { // 動態加載 emitter var hotEmitter = require('webpack/hot/emitter'); hotEmitter.emit('webpackHotUpdate', currentHash); if (typeof self !== 'undefined' && self.window) { // broadcast update to window self.postMessage('webpackHotUpdate' + currentHash, '*'); } } // ... }
Webpack/hot/dev-server 監聽瀏覽器端 webpackHotUpdate
消息,將新模塊 hash 值傳到客戶端 HMR 核心中樞的 HotModuleReplacement.runtime ,並調用 check
方法檢測更新,判斷是瀏覽器刷新仍是模塊熱更新。
若是是瀏覽器刷新的話,則沒有後續步驟咯~~
// webpack\hot\dev-server.js // 1.監聽 webpackHotUpdate Line 42 var hotEmitter = require("./emitter"); hotEmitter.on("webpackHotUpdate", function(currentHash) { lastHash = currentHash; if(!upToDate() && module.hot.status() === "idle") { log("info", "[HMR] Checking for updates on the server..."); check(); } }); var check = function check() { module.hot.check(true).then(function(updatedModules) { if(!updatedModules) { // ... window.location.reload();// 瀏覽器刷新 return; } if(!upToDate()) { check(); } }).catch(function(err) { /*...*/}); }; // webpack\lib\HotModuleReplacement.runtime.js // 3.調用 HotModuleReplacement.runtime 定義的 check 方法 Line 167 function hotCheck(apply) { if(hotStatus !== "idle") throw new Error("check() is only allowed in idle status"); hotApplyOnUpdate = apply; hotSetStatus("check"); return hotDownloadManifest(hotRequestTimeout).then(function(update) { //... }); }
當 HotModuleReplacement.runtime 調用 check
方法時,會調用 JsonpMainTemplate.runtime 中的 hotDownloadUpdateChunk
(獲取最新模塊代碼)和 hotDownloadManifest
(獲取是否有更新文件)兩個方法,這兩個方法的源碼,在下一步展開。
// webpack\lib\HotModuleReplacement.runtime.js // 1.調用 HotModuleReplacement.runtime 定義 hotDownloadUpdateChunk 方法 Line 171 function hotCheck(apply) { if(hotStatus !== "idle") throw new Error("check() is only allowed in idle status"); hotApplyOnUpdate = apply; hotSetStatus("check"); return hotDownloadManifest(hotRequestTimeout).then(function(update) { //... { // hotEnsureUpdateChunk 方法中會調用 hotDownloadUpdateChunk hotEnsureUpdateChunk(chunkId); } }); }
其中 hotEnsureUpdateChunk
方法中會調用 hotDownloadUpdateChunk
:
// webpack\lib\HotModuleReplacement.runtime.js Line 215 function hotEnsureUpdateChunk(chunkId) { if(!hotAvailableFilesMap[chunkId]) { hotWaitingFilesMap[chunkId] = true; } else { hotRequestedFilesMap[chunkId] = true; hotWaitingFiles++; hotDownloadUpdateChunk(chunkId); } }
在調用 check
方法時,會先調用 JsonpMainTemplate.runtime 中的 hotDownloadManifest
方法, 經過向服務端發起 AJAX 請求獲取是否有更新文件,若是有的話將 mainfest
返回給瀏覽器端。
這邊涉及一些原生 XMLHttpRequest
,就不所有貼出了~
// webpack\lib\JsonpMainTemplate.runtime.js // hotDownloadManifest 定義 Line 22 function hotDownloadManifest(requestTimeout) { return new Promise(function(resolve, reject) { try { var request = new XMLHttpRequest(); var requestPath = $require$.p + $hotMainFilename$; request.open("GET", requestPath, true); request.timeout = requestTimeout; request.send(null); } catch(err) { return reject(err); } request.onreadystatechange = function() { // ... }; }); }
在 hotDownloadManifest
方法中,還會執行 hotDownloadUpdateChunk
方法,經過 JSONP 請求最新的模塊代碼,並將代碼返回給 HMR runtime 。
而後 HMR runtime 會將新代碼進一步處理,判斷是瀏覽器刷新仍是模塊熱更新。
// webpack\lib\JsonpMainTemplate.runtime.js // hotDownloadManifest 定義 Line 12 function hotDownloadUpdateChunk(chunkId) { // 建立 script 標籤,發起 JSONP 請求 var head = document.getElementsByTagName("head")[0]; var script = document.createElement("script"); script.type = "text/javascript"; script.charset = "utf-8"; script.src = $require$.p + $hotChunkFilename$; $crossOriginLoading$; head.appendChild(script); }
這一步是整個模塊熱更新(HMR)的核心步驟,經過 HMR runtime 的 hotApply
方法,移除過時模塊和代碼,並添加新的模塊和代碼實現熱更新。
從 hotApply
方法能夠看出,模塊熱替換主要分三個階段:
outdatedModules
和過時依賴 outdatedDependencies
;// webpack\lib\HotModuleReplacement.runtime.js // 找出 outdatedModules 和 outdatedDependencies Line 342 function hotApply() { // ... var outdatedDependencies = {}; var outdatedModules = []; function getAffectedStuff(updateModuleId) { var outdatedModules = [updateModuleId]; var outdatedDependencies = {}; // ... return { type: "accepted", moduleId: updateModuleId, outdatedModules: outdatedModules, outdatedDependencies: outdatedDependencies }; }; function addAllToSet(a, b) { for (var i = 0; i < b.length; i++) { var item = b[i]; if (a.indexOf(item) < 0) a.push(item); } } for(var id in hotUpdate) { if(Object.prototype.hasOwnProperty.call(hotUpdate, id)) { // ... 省略多餘代碼 if(hotUpdate[id]) { result = getAffectedStuff(moduleId); } if(doApply) { for(moduleId in result.outdatedDependencies) { // 添加到 outdatedDependencies addAllToSet(outdatedDependencies[moduleId], result.outdatedDependencies[moduleId]); } } if(doDispose) { // 添加到 outdatedModules addAllToSet(outdatedModules, [result.moduleId]); appliedUpdate[moduleId] = warnUnexpectedRequire; } } } }
// webpack\lib\HotModuleReplacement.runtime.js // 從緩存中刪除過時模塊、依賴和全部子元素的引用 Line 442 function hotApply() { // ... var idx; var queue = outdatedModules.slice(); while(queue.length > 0) { moduleId = queue.pop(); module = installedModules[moduleId]; // ... // 移除緩存中的模塊 delete installedModules[moduleId]; // 移除過時依賴中不須要使用的處理方法 delete outdatedDependencies[moduleId]; // 移除全部子元素的引用 for(j = 0; j < module.children.length; j++) { var child = installedModules[module.children[j]]; if(!child) continue; idx = child.parents.indexOf(moduleId); if(idx >= 0) { child.parents.splice(idx, 1); } } } // 從模塊子組件中刪除過期的依賴項 var dependency; var moduleOutdatedDependencies; for(moduleId in outdatedDependencies) { if(Object.prototype.hasOwnProperty.call(outdatedDependencies, moduleId)) { module = installedModules[moduleId]; if(module) { moduleOutdatedDependencies = outdatedDependencies[moduleId]; for(j = 0; j < moduleOutdatedDependencies.length; j++) { dependency = moduleOutdatedDependencies[j]; idx = module.children.indexOf(dependency); if(idx >= 0) module.children.splice(idx, 1); } } } } }
__webpack_require__
(webpack 重寫的 require
方法)方法的時候,就是獲取到了新的模塊代碼了。// webpack\lib\HotModuleReplacement.runtime.js // 將新模塊代碼添加到 modules 中 Line 501 function hotApply() { // ... for(moduleId in appliedUpdate) { if(Object.prototype.hasOwnProperty.call(appliedUpdate, moduleId)) { modules[moduleId] = appliedUpdate[moduleId]; } } }
hotApply
方法執行以後,新代碼已經替換舊代碼,可是咱們業務代碼並不知道這些變化,所以須要經過 accept
事件通知應用層使用新的模塊進行「局部刷新」,咱們在業務中是這麼使用:
if (module.hot) { module.hot.accept('./library.js', function() { // 使用更新過的 library 模塊執行某些操做... }) }
在熱更新過程當中,hotApply
過程當中可能出現 abort
或者 fail
錯誤,則熱更新退回到刷新瀏覽器(Browser Reload),整個模塊熱更新完成。
// webpack\hot\dev-server.js Line 13 module.hot.check(true).then(function (updatedModules) { if (!updatedModules) { return window.location.reload(); } // ... }).catch(function (err) { var status = module.hot.status(); if (["abort", "fail"].indexOf(status) >= 0) { window.location.reload(); } });
本文主要和你們分享 Webpack 的 HMR 使用和實現原理及源碼分析,在源碼分析中,經過一張「Webpack HMR 工做原理解析」圖讓你們對 HMR 整個工做流程有所瞭解,HMR 自己源碼內容較多,許多細節之處本文沒有完整寫出,須要各位讀者本身慢慢閱讀和理解源碼。
1.官方文檔《Hot Module Replacement》
2.《Webpack HMR 原理解析》
3.《webpack HMR》
4.《配置 dev-server》