120 行代碼幫你瞭解 Webpack 下的 HMR 機制

掘金引流終版.gif

構建專欄系列目錄入口javascript

朱海華: 微醫前端技術部平臺支撐組 我本地是好的,你再試試~🤔css

HMR 的背景

在使用Webpack Dev Server之後 可讓咱們在開發工程中 專一於 Coding, 由於它能夠監聽代碼的變化 從而實現打包更新,而且最後經過自動刷新的方式同步到瀏覽器,便於咱們及時查看效果。 可是 Dev Server 從監聽到打包再到通知瀏覽器總體刷新頁面 就會致使一個讓人困擾的問題 那就是 沒法保存應用狀態 所以 針對這個問題,Webpack 提供了一個新的解決方案 Hot Module Replacement前端

HMR 簡單概念

Hot Module Replacement 是指當咱們對代碼修改並保存後,Webpack 將會對代碼進行從新打包,並將新的模塊發送到瀏覽器端,瀏覽器用新的模塊替換掉舊的模塊,以實如今不刷新瀏覽器的前提下更新頁面。最明顯的優點就是相對於傳統的live reload而言,HMR 並不會丟失應用的狀態,提升開發效率。在開始深刻了解 Webpack HMR 以前 咱們能夠先簡單過一下下面這張流程圖java

HMR 流程概覽

1597240262452-5ecbaec0-6245-4ed5-9195-59c7a38e8b24.png

  1. Webpack Compile: watch 打包本地文件 寫入內存
  2. Boundle Server: 啓一個本地服務,提供文件在瀏覽器端進行訪問
  3. HMR Server: 將熱更新的文件輸出給 HMR Runtime
  4. HMR Runtime: 生成的文件,注入至瀏覽器內存
  5. Bundle: 構建輸出文件

HMR 入門體驗

開啓 HMR 其實也極其容易 由於 HMR 自己就已經集成在了 Webpack 裏 開啓方式有兩種webpack

  1. 直接經過運行 webpack-dev-server 命令時 加入 --hot參數 直接開啓 HMR
  2. 寫入配置文件 代碼以下
// ./webpack.config.js
const webpack = require('webpack')
module.exports = {
  // ...
  devServer: {
    // 開啓 HMR 特性 若是不支持 MMR 則會 fallback 到 live reload
    hot: true,
  },
  plugins: [
    // ...
    // HMR 依賴的插件
    new webpack.HotModuleReplacementPlugin()
  ]
}
複製代碼

HMR 中的 Server 和 Client

devServer 通知瀏覽器文件變動

經過翻閱 webpack-dev-server源碼 在這一過程當中,依賴於sockjs提供的服務端與瀏覽器端之間的橋樑,在 devServer 啓動的同時,創建了一個 webSocket 長連接,用於通知瀏覽器在 webpack 編譯和打包下的各個狀態,同時監聽 compile 下的 done 事件,當 compile 完成之後,經過 sendStats 方法, 將從新編譯打包好的新模塊 hash 值發送給瀏覽器。git

// webpack-dev-server/blob/master/lib/Server.js
sendStats(sockets, stats, force) {
    const shouldEmit =
      !force &&
      stats &&
      (!stats.errors || stats.errors.length === 0) &&
      (!stats.warnings || stats.warnings.length === 0) &&
      stats.assets &&
      stats.assets.every((asset) => !asset.emitted);

    if (shouldEmit) {
      this.sockWrite(sockets, 'still-ok');

      return;
    }

    this.sockWrite(sockets, 'hash', stats.hash);

    if (stats.errors.length > 0) {
      this.sockWrite(sockets, 'errors', stats.errors);
    } else if (stats.warnings.length > 0) {
      this.sockWrite(sockets, 'warnings', stats.warnings);
    } else {
      this.sockWrite(sockets, 'ok');
    }
  }
複製代碼

Client 接收到服務端消息作出響應

webpack-dev-server/client 當接收到 type 爲 hash 消息後會將 hash 值暫時緩存起來,同時當接收到到 type 爲 ok 的時候,對瀏覽器執行 reload 操做。 企業微信 20210518-153454.pnggithub

reload 策略選擇

function reloadApp( { hotReload, hot, liveReload }, { isUnloading, currentHash } ) {
  if (isUnloading || !hotReload) {
    return;
  }

  if (hot) {
    log.info('App hot update...');

    const hotEmitter = require('webpack/hot/emitter');

    hotEmitter.emit('webpackHotUpdate', currentHash);

    if (typeof self !== 'undefined' && self.window) {
      // broadcast update to window
      self.postMessage(`webpackHotUpdate${currentHash}`, '*');
    }
  }
  // allow refreshing the page only if liveReload isn't disabled
  else if (liveReload) {
    let rootWindow = self;

    // use parent window for reload (in case we're in an iframe with no valid src)
    const intervalId = self.setInterval(() => {
      if (rootWindow.location.protocol !== 'about:') {
        // reload immediately if protocol is valid
        applyReload(rootWindow, intervalId);
      } else {
        rootWindow = rootWindow.parent;

        if (rootWindow.parent === rootWindow) {
          // if parent equals current window we've reached the root which would continue forever, so trigger a reload anyways
          applyReload(rootWindow, intervalId);
        }
      }
    });
  }

  function applyReload(rootWindow, intervalId) {
    clearInterval(intervalId);

    log.info('App updated. Reloading...');

    rootWindow.location.reload();
  }
複製代碼

經過翻閱 webpack-dev-server/client源碼,咱們能夠看到,首先會根據 hot 配置決定是採用哪一種更新策略,刷新瀏覽器或者代碼進行熱更新(HMR),若是配置了 HMR,就調用 webpack/hot/emitter 將最新 hash 值發送給 webpack,若是沒有配置模塊熱更新,就直接調用 applyReload下的location.reload 方法刷新頁面。 ​web

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 會根據返回的新模塊代碼作進一步處理,多是刷新頁面,也多是對模塊進行熱更新。 ​npm

在這個過程當中,實際上是 webpack 三個模塊配合執行以後獲取的結果json

  1. webpack/hot/dev-server監聽 client 發送的webpackHotUpdate消息
// ....
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();
		}
	});
	log("info", "[HMR] Waiting for update signal from WDS...");
} else {
	throw new Error("[HMR] Hot Module Replacement is disabled.");
複製代碼
  1. [HMR runtime/check()](https://github.com/webpack/webpack/blob/v4.41.5/lib/HotModuleReplacement.runtime.js)檢測是否有新的更新,check 過程當中會利用webpack/lib/web/JsonpMainTemplate.runtime.js中的hotDownloadUpdateChunk(經過 jsonp 請求新的模塊代碼而且返回給 HMR Runtime)以及hotDownloadManifest(發送 AJAx 請求向 Server 請求是否有更新的文件,若是有則會將新的文件返回給瀏覽器)

企業微信 20210524-162204.png 獲取更新文件列表 企業微信 20210524-171538.png 獲取模塊更新之後的最新代碼 ​

HMR Runtime 對模塊進行熱更新

這裏就是整個 HMR 最關鍵的步驟了,而其中 最關鍵的 無非就是hotApply這個方法了,因爲代碼量實在太多,這裏咱們直接進入過程解析(關鍵代碼),有興趣的同窗能夠閱讀一下源碼。

  1. 找出 outdatedModulesoutdatedDependencies
  2. 刪除過時的模塊以及對應依賴
// remove module from cache
delete installedModules[moduleId];

// when disposing there is no need to call dispose handler
delete outdatedDependencies[moduleId];
複製代碼
  1. 新模塊添加至 modules 中
for(moduleId in appliedUpdate) {
  if(Object.prototype.hasOwnProperty.call(appliedUpdate, moduleId)) {
      modules[moduleId] = appliedUpdate[moduleId];
  }
}
複製代碼

至此 一整個模塊替換的流程已經結束了,已經能夠獲取到最新的模塊代碼了,接下來就輪到業務代碼如何知曉模塊已經發生了變化~

HMR 中的 hot 成員

HotModuleReplaceMentPlugin

因爲咱們編寫的 JavaScript 代碼是沒有任何規律可言的模塊,能夠導出的是一個模塊、函數、甚至於只是一個字符串 而對於這些毫無規律可言的模塊來講 Webpack 是沒法提供一個通用的模塊替換方案去處理的 所以在這種狀況下,還想要體驗完整的 HMR 開發流程 是須要咱們本身手動處理 當 JS 模塊更新之後,如何將更新之後的 JS 模塊替換至頁面當中 所以 HotModuleReplacementPlugin 爲咱們提供了一系列關於 HMR 的 API 而其中 最關鍵的部分則是hot.accept

接下來 咱們將嘗試 本身手動處理 JS 模塊更新 並通知到瀏覽器實現對應的局部刷新

:::info 當前主流開發框架 Vue、React 都提供了統一的模塊替換函數, 所以 Vue、React 項目並不須要針對 HMR 作手動的代碼處理,同時 css 文件也由 style-loader 統一處理 所以也不須要額外的處理,所以接下去的代碼處理邏輯,所有創建在純原生開發的基礎之上實現 :::

回到代碼中來 假設當前 main.js 文件以下

// ./src/main.js
import createChild from './child'

const child = createChild()
document.body.appendChild(child)
複製代碼

main.js 是 Webpack 打包的入口文件 在文件中引入了 Child 模塊 所以 當 Child 模塊裏的業務代碼更改之後 webpack 必然會從新打包,而且從新使用這些更新之後的模塊,因此,咱們須要在 main.js 裏實現去處理它所依賴的這些模塊更新後的熱替換邏輯

在 HMR 已開啓的狀況下,咱們能夠經過訪問全局的module對象下的hot 成員它提供了一個accept 方法,這個方法用來註冊當某個模塊更新之後須要如何處理,它接受兩個參數 一個是須要監聽模塊的 path(相對路徑),第二個參數就是當模塊更新之後如何處理 其實也就是一個回調函數

// main.js
// 監聽 child 模塊變化
module.hot.accept('./child', () => {
  console.log('老闆好,child 模塊更新啦~')
})
複製代碼

當作完這些之後,從新運行 npm run serve 同時修改 child 模塊 你會發現,控制檯會輸出以上的 console 內容,同時,瀏覽器也不會自動更新了,所以,咱們能夠得出一個結論 當你手動處理了某個模塊的更新之後,是不會出發自動刷新機制的,接下來 就來一塊兒看看 其中的原理 以及 如何實現 HMR 中的 JS 模塊替換邏輯

module.hot.accept 原理

爲何咱們只有調用了moudule.hot.accept才能夠實現熱更新, 翻看源碼 其實能夠發現實現以下

// 部分源碼
accept: function (dep, callback, errorHandler) {
				if (dep === undefined) hot._selfAccepted = true;
				else if (typeof dep === "function") hot._selfAccepted = dep;
				else if (typeof dep === "object" && dep !== null) {
					for (var i = 0; i < dep.length; i++) {
						hot._acceptedDependencies[dep[i]] = callback || function () {};
						hot._acceptedErrorHandlers[dep[i]] = errorHandler;
					}
				} else {
					hot._acceptedDependencies[dep] = callback || function () {};
					hot._acceptedErrorHandlers[dep] = errorHandler;
				}
			},
複製代碼
// module.hot.accept 其實等價於 module.hot._acceptedDependencies('./child) = render
// 業務邏輯實現
module.hot.accept('./child', () => {
  console.log('老闆好,child 模塊更新啦~')
})

複製代碼

accept 往hot._acceptedDependencies這個對象裏存入局部更新的 callback, 當模塊改變時,對模塊須要作的變動,蒐集到_acceptedDependencies中,同時當被監聽的模塊內容發生了改變之後,父模塊能夠經過_acceptedDependencies知道哪些內容發生了變化。

實現 JS 模塊替換

當了解了 accpet 方法之後,其實咱們要考慮的事情就很是簡單了,也就是如何實現 cb 裏的業務邏輯,其實當 accept 方法執行了之後,在其回調裏是能夠獲取到最新的被修改了之後的模塊的函數內容的

// ./src/main.js
import createChild from './child'

console.log(createChild) // 未更新前的函數內容
module.hot.accept('./child', ()=> {
	console.log(createChild) // 此時已經能夠獲取更新之後的函數內容
})
複製代碼

既然是能夠獲取到最新的函數內容 其實也就很簡單了 咱們只須要移除以前的 dom 節點 並替換爲最新的 dom 節點便可,同時咱們也須要記錄節點裏的內容狀態,當節點替換爲最新的節點之後,追加更新本來的內容狀態

// ./src/main.js
import createChild from './child'

const child = createChild()
document.body.appendChild(child)

// 這裏須要額外注意的是,child 變量每一次都會被移除,因此其實咱們一個記錄一下每次被修改前的 child
let lastChild = child
module.hot.accept('./child', ()=> {
  // 記錄狀態
  const value = lastChild.innerHTML
  // 刪除節點
	document.body.remove(child)
  // 建立最新節點
  lastChild = createChild()
  // 恢復狀態
  lastChild.innerHTMl = value
  // 追加內容
  document.body.appendChild(lastChild)
})
複製代碼

到這裏爲止,對於如何手動實現一個 child 模塊的熱更新替換邏輯已經所有實現完畢了,有興趣的同窗能夠本身也手動實現一下~

:::tips tips: 手動處理 HMR 邏輯過程當中 若是 HMR 過程當中出現報錯 致使的 HRM 失效,其實只須要在配置文件中將hot: true 修改成 hotOnly: true 便可 :::

寫在最後

但願經過這篇文章,可以幫助到你們加深對 HMR 的理解,同時解決一下開發場景會遇到的問題(例如 脫離框架本身實現模塊熱更新),最後,歡迎你們一鍵三連~🎉🎉🎉

眼藥水.gif

相關文章
相關標籤/搜索