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阅读 194,242评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,769评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,484评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,133评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,007评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,080评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,496评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,190评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,464评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,549评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,330评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,205评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,567评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,889评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,160评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,475评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,650评论 2 335

推荐阅读更多精彩内容