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

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

HMR 的背景

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

HMR 簡單概念

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

HRM 流程概覽

  1. Webpack Compile:  watch 打包本地文件 寫入內存

  2. Boundle Server: 啓一個本地服務,提供文件在瀏覽器端進行訪問

  3. HMR Server: 將熱更新的文件輸出給 HMR Runtime

  4. HRM Runtime: 生成的文件,注入至瀏覽器內存

  5. Bundle: 構建輸出文件

HMR 入門體驗

開啓 HMR 其實也極其容易 因爲 HMR 本身就已經集成在了 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 值發送給瀏覽器。

// 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 操作。

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 方法刷新頁面。

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

在這個過程中,其實是 webpack 三個模塊配合執行之後獲取的結果

  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 請求是否有更新的文件,如果有則會將新的文件返回給瀏覽器)

獲取更新文件列表獲取模塊更新以後的最新代碼

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 的理解,同時解決一下開發場景會遇到的問題 (例如 脫離框架自己實現模塊熱更新),最後,歡迎大家一鍵三連~🎉🎉🎉

公衆號:前端食堂

知乎:童歐巴

掘金:童歐巴

這是一個終身學習的男人,他在堅持自己熱愛的事情,歡迎你加入前端食堂,和這個男人一起開心的變胖~

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/GlwGJ4cEe-1FgWW4EVpG_w