Webpack 熱更新實現原理分析

概述

在使用 Webpack 構建開發期時,Webpack 提供熱更新功能為開發帶來良好的體驗和開發效率,那熱更新機制是怎么實現的呢?

代碼實現

  • Webpack 配置添加 HotModuleReplacementPlugin 插件
new webpack.HotModuleReplacementPlugin({
  // Options...
})
  • Node Server 引入 webpack-dev-middlerwarewebpack-hot-middleware 插件,如果是 koa 引入對應的 koa-webpack-dev-middlerwarekoa-webpack-hot-middleware
 const devMiddleware = require('koa-webpack-dev-middleware')(compiler, {
  publicPath,
   stats: {
    colors: true,
    children: true,
    modules: false,
    chunks: false,
    chunkModules: false,
  },
  watchOptions: {
    ignored: /node_modules/,
  }
});

app.use(devMiddleware);
const hotMiddleware = require('koa-webpack-hot-middleware')(compiler, {
   log: false,
   reload: true
});
app.use(hotMiddleware);
  • entry 注入熱更新代碼
webpack-hot-middleware/client?path=http://127.0.0.1:9000/__webpack_hmr&noInfo=false&reload=true&quiet=false

這里注入熱更新代碼是關鍵,保證服務端和客戶端能夠通信。

熱更新一探

首先我們啟動 egg-vue-webpack-boilerplate 應用,通過 chrome-dev-tool 看看首次打開頁面前端和后端通信是如何建立的?

e931e4cd-870d-4512-b9c0-a80afb490fdb.png | center
e931e4cd-870d-4512-b9c0-a80afb490fdb.png | center

有一個webpack_hmr的請求:http://127.0.0.1:9000/webpack_hmr.

從這張圖里面看到幾個信息

  • 這里看到內容類型為 eventStream,具體是啥請看之后介紹的 EventSource。
  • 返回類型為message, 內容關鍵的有個 action: sync 和 hash:73c528ba5b06e7e9ab26, 這個幾個信息在后面會用到。 這里的 hash 為 Webpack 初始化的一個hash,在 vendor.js 文件里面可以看到, 每次頁面首次加載時,都會重新生成一個。(var hotCurrentHash = “73c528ba5b06e7e9ab26”)
  • 有一個空的message信息,通過觀察發現和后面查看代碼發現,這個是為了保證后端與客戶端通信保持連接,后端隔一段時間會向客戶端發送一段信息。

然后修改 about.vue 文件保存后,發現控制臺 Webpack 馬上重新編譯了,UI 無刷新更新了。

1.這時候會發現 Webpack 編譯結果多了兩個update的文件, 而且文件名包含上面的 hash 信息。

4.73c528ba5b06e7e9ab26.hot-update.js   2.93 kB  4  [emitted]  about/about
73c528ba5b06e7e9ab26.hot-update.json   43 bytes    [emitted]

2.同時,chrome-dev-tool 請求面板下多了兩個請求,其中 hot-update.json 為 ajax請求, hot-update.js 為 GET 請求, 也就是插入 script 鏈接到文檔中的script 請求。

9852c113-8a43-4832-a3a4-57be16de4644.png | center
9852c113-8a43-4832-a3a4-57be16de4644.png | center

3.頁面內容插入了 4.73c528ba5b06e7e9ab26.hot-update.js script文件

7869d6ae-21bf-474c-bdc2-61850e39bccf.png | center
7869d6ae-21bf-474c-bdc2-61850e39bccf.png | center

4.我們來初步看一下兩個文件的內容:

  • 4.73c528ba5b06e7e9ab26.hot-update.js
{"h":"540f0a679c8bcbf12848","c":{"4":true}}
  • 73c528ba5b06e7e9ab26.hot-update.json
webpackHotUpdate(4,{
 (function(module, __webpack_exports__, __webpack_require__) {
    // ...... 此處為 about.vue 組件代碼邏輯
    /* hot reload */
    if (true) {(function () {
        var hotAPI = __webpack_require__(1)
        hotAPI.install(__webpack_require__(0), false)
        if (!hotAPI.compatible) return
        module.hot.accept()
        if (!module.hot.data) {
          hotAPI.createRecord("data-v-80abbab2", Component.options)
        } else {
          hotAPI.reload("data-v-80abbab2", Component.options)
      ' + '  }
        module.hot.dispose(function (data) {
          disposed = true
      })
    })()}
 })
})

5.進行多次熱更新效果

33cd6ab3-3784-4284-94e3-56e09063bde9.png | center
33cd6ab3-3784-4284-94e3-56e09063bde9.png | center

從上面截圖可以看到,每次服務端發送的消息(EventStrean) 的 hash 將作為下次 hot-update.json 和 hot-update.js 文件的 hash。

結合上面的分析,接下來從實現到代碼層面分析一下整個流程。

熱更新實現分析

EventSource 服務端與客戶端通信

首先通過查看代碼 webpack-hot-middleware/client 發現通信是用 window.EventSource 實現,那 EventSource 是什么東西呢?

EventSource 是 HTML5 中 Server-sent Events 規范的一種技術實現。EventSource 接口用于接收服務器發送的事件。它通過HTTP連接到一個服務器,以text/event-stream 格式接收事件, 不關閉連接。通過 EventSource 服務端可以主動給客戶端發現消息,使用的是 HTTP協議,單項通信,只能服務器向瀏覽器發送; 與 WebSocket 相比輕量,使用簡單,支持斷線重連。更多信息參考MDN

Node 端通信實現

Node 通過中間件 webpack-hot-middleware/middleware.js

創建 createEventStream 流

首先看一下中間件核心代碼,主要是向客戶端發送消息

  • compile 發送 編譯中 消息給客戶端
  • build 發送 編譯完成 消息給客戶端
  • sync 文件修復熱更新或者報錯會發送該消息
// 初始化 EventStream 發送消息通道
var eventStream = { 
  handler: function(req, res) {
      req.socket.setKeepAlive(true);
      res.writeHead(200, {
        'Access-Control-Allow-Origin': '*',
        'Content-Type': 'text/event-stream;charset=utf-8',
        'Cache-Control': 'no-cache, no-transform',
        'Connection': 'keep-alive',
        'X-Accel-Buffering': 'no'
      });
      res.write('\n');
      var id = clientId++;
      clients[id] = res;
      req.on("close", function(){
        delete clients[id];
      });
  },
 publish: function(payload) {
    everyClient(function(client) {
        client.write("data: " + JSON.stringify(payload) + "\n\n");
    });
 }
}

// 根據 Webpack 編譯狀態 主動發送消息給客戶端
function webpackHotMiddleware(compiler, opts) {
  compiler.plugin("compile", function() {
    latestStats = null;
    if (opts.log) opts.log("webpack building...");
    eventStream.publish({action: "building"});
  });
  compiler.plugin("done", function(statsResult) {
    // Keep hold of latest stats so they can be propagated to new clients
    latestStats = statsResult;
    // 當首次編譯完成 和 修改代碼重新編譯(熱更新)完成時發送
    publishStats("built", latestStats, eventStream, opts.log);
  });
  var middleware = function(req, res, next) {
    if (!pathMatch(req.url, opts.path)) return next();
    // 見下面的 handler 實現,中間件通過 `req.socket.setKeepAlive` 開啟長鏈接通道, 
    eventStream.handler(req, res);
    if (latestStats) {
      // 服務端向客戶端寫入數據,sync 表示告訴客戶端熱更新已經準備好
       eventStream.publish({
        name: stats.name,
        action: "sync",
        time: stats.time,
        hash: stats.hash,
        warnings: stats.warnings || [],
        errors: stats.errors || [],
        modules: buildModuleMap(stats.modules)
       });
    }
  };
  return middleware;

客戶端通信實現

服務端通過 EventSource 發送消息給客戶端了,我們來看看客戶端的通信實現。打開 webpack-hot-middleware/client.js 的代碼實現:

var source = new window.EventSource('(http://127.0.0.1:9000/__webpack_hmr)');
source.onopen = handleOnline; // 建立鏈接
source.onerror = handleDisconnect;
source.onmessage = handleMessage; // 接收服務端消息,然后進行相應處理

Node端會主動發送消息給客戶端, 客戶端 EventSource 關鍵代碼處理消息代碼如下:

function processMessage(obj) {
  switch(obj.action) {
    case "building": 
      if (options.log) {
        console.log(
          "[HMR] bundle " + (obj.name ? "'" + obj.name + "' " : "") +
          "rebuilding"
        );
      }
      break;
    case "built": // 這里沒有break,所以 編譯完成會執行 build 和 sync 邏輯
      if (options.log) {
        console.log(
          "[HMR] bundle " + (obj.name ? "'" + obj.name + "' " : "") +
          "rebuilt in " + obj.time + "ms"
        );
      }
      // fall through
    case "sync":
      processUpdate(obj.hash, obj.modules, options);
      break;
    default:
      if (customHandler) {
        customHandler(obj);
      }
  }
}

上面 building, built, sync 三種消息于服務端發送的消息對應, 這樣就完成了服務端和客戶端通信。
因 build 的 action 時, build case 沒有 break,所以當修改文件時,編譯完成發送 build 消息時,會依次執行 build 和 sync 邏輯, 也就是進入 processUpdate 流程。processUpdate 接收到信息( hash, module) 之后, 進入 module.hot.checkmodule.hot.apply 流程。

客戶端熱更新

首先我們再來看看 module.hot 初始化實現邏輯

module.hot 初始化

webpack_require 函數定義時,通過 hotCreateModule 為每個 module 初始化 hot 邏輯

function __webpack_require__(moduleId) {
  var module = installedModules[moduleId] = {
    i: moduleId,
    l: false,
    exports: {},
    hot: hotCreateModule(moduleId), // 前端通過 ajax 獲取熱更新文件內容
    parents:xxxx,
    children: []
  };
  return module.exports;
}

hotCreateModule 實現

function hotCreateModule(moduleId) {
  var hot = {
    accept: function(dep, callback) {
    },
    check: hotCheck,
    apply: hotApply,
    status: function(l) {},
    .....
  }
  return hot;
}

hotCheck:前端通過 ajax 獲取熱更新文件內容

熱更新一探:[進行多次熱更新效果] 上面截圖可以看到,每次服務端發送的消息(EventStrean) 的 hash 將作為下次 hot-update.json 和 hot-update.js 文件的 hash。也就是下面客戶端更新當前
hotCurrentHash 值,作為下次的 hot-update.json 和 hot-update.js 更新請求。

function hotCheck(){
    return new Promise(function(resolve, reject) {
        var __webpack_require__.p + "" + hotCurrentHash + ".hot-update.json";
        var request = new XMLHttpRequest();
        request.open("GET", requestPath, true);
        request.timeout = requestTimeout;
        request.send(null);
        request.onreadystatechange = function() {
          if(request.readyState === 4 && request.status === 200){
            reject(new Error("Manifest request to " + requestPath + " failed."));
          }else{
            resolve(JSON.parse(request.responseText));
          }
        });
    }).then(function(update) {
      // {h: "dcc99b114b8c64461a2e", c: {5: true}}
      // 新的hotUpdateHash
      hotUpdateNewHash = update.h;
      // 向文檔插入 hot-update.js script 
      hotEnsureUpdateChunk();
    });
},

hotEnsureUpdateChunk 實現

hotEnsureUpdateChunk 函數的邏輯是向 HTML 文檔插入 hot-update.js script 腳本。 hotEnsureUpdateChunk 調用 hotDownloadUpdateChunk 函數

function hotDownloadUpdateChunk(chunkId) { 
    var head = document.getElementsByTagName("head")[0];
    var script = document.createElement("script");
    script.type = "text/javascript";
    script.charset = "utf-8";
    script.src = __webpack_require__.p + "" + chunkId + "." + hotCurrentHash + ".hot-update.js";
    head.appendChild(script);
}

開啟更新機制

  • 開啟熱更新構建后, 每個 Vue 組件構建的代碼都有下面這么一段 hotAPI 代碼:
/* hot reload */
Component.options.__file = "app/web/page/about/about.vue"
if (true) {(function () {
  var hotAPI = __webpack_require__(3)
  hotAPI.install(__webpack_require__(0), false)
  if (!hotAPI.compatible) return
  module.hot.accept()
  if (!module.hot.data) {
    hotAPI.createRecord("data-v-aafed0d8", Component.options)
  } else {
    hotAPI.reload("data-v-aafed0d8", Component.options)
' + '  }
  module.hot.dispose(function (data) {
    disposed = true
  })
})()}
  • createRecord 和 reload 觸發 UI 更新

獲取 Vue 組件的 render,重新 render 組件, 繼而實現 UI 無刷新更新。

function makeOptionsHot(id, options) {
  if (options.functional) {
    // 獲取組件的render 方法,重新 render
    var render = options.render
    options.render = function (h, ctx) {
      var instances = map[id].instances
      if (instances.indexOf(ctx.parent) < 0) {
        instances.push(ctx.parent)
      }
      return render(h, ctx)
    }
  } else {
    injectHook(options, 'beforeCreate', function() {
      var record = map[id]
      if (!record.Ctor) {
        record.Ctor = this.constructor
      }
      record.instances.push(this)
    })
    injectHook(options, 'beforeDestroy', function() {
      var instances = map[id].instances
      instances.splice(instances.indexOf(this), 1)
    })
  }
}

熱更新流程總結

webpack-hot-update.png
  1. Webpack編譯期,為需要熱更新的 entry 注入熱更新代碼(EventSource通信)
  2. 頁面首次打開后,服務端與客戶端通過 EventSource 建立通信渠道,把下一次的 hash 返回前端
  3. 客戶端獲取到hash,這個hash將作為下一次請求服務端 hot-update.js 和 hot-update.json的hash
  4. 修改頁面代碼后,Webpack 監聽到文件修改后,開始編譯,編譯完成后,發送 build 消息給客戶端
  5. 客戶端獲取到hash,成功后客戶端構造hot-update.js script鏈接,然后插入主文檔
  6. hot-update.js 插入成功后,執行hotAPI 的 createRecord 和 reload方法,獲取到 Vue 組件的 render方法,重新 render 組件, 繼而實現 UI 無刷新更新。

關鍵代碼

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,197評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,415評論 3 415
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,104評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,884評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,647評論 6 408
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,130評論 1 323
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,208評論 3 441
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,366評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,887評論 1 334
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,737評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,939評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,478評論 5 358
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,174評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,586評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,827評論 1 283
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,608評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,914評論 2 372

推薦閱讀更多精彩內容