三七互娱技术团队

Webpack热更原理揭秘

本篇以 webpack4 版本作为讲解。

1、什么是 webpack 热更新呢?

相信做过前端的同学应该不陌生,热更新就是 webpack 其中的一个重要功能。

最初写 HTML 的时候,每次代码写完之后,都要在页面自己手动去刷新一遍,有没有觉得很蛋疼。自从用上了 webpack 的这个热更新功能之后,简直爽得飞天啊。不用手动刷新,直接 ctrl + s ,页面就会自动更新,这就是热更新。

2、webpack 的编译构建过程

在了解 webpack 热更新原理之前,我们先来看看 webpack 的编译构建过程。

设置 devServer.stats 的值为 normal,即可在控制台看到输出日志过程。

当配置了 quiet 或 noInfo 时,该配置不起作用。需要关闭 quiet 或 noInfo

当我们 npm run dev 启动的时候,这时候我们可以在控制台看到输出的日志:

这里有一个Hash值:b6db0705ddcf5a433075,代表着这次webpack Compiling 的标识符。

Image

当我们修改了文件后,再次ctrl + s时,可以看到控制打印出的日志:

这里的Hash值改变了:480ff6313164fc01043e

但是下面多了两份文件:hot-update.js 以及 hot-update.json,注意看这两份文件的前缀的hash值:b6db0705ddcf5a433075, 这不就是我们上次的hash值么??

Image

这时候同学们可能就会发现了:上一次编译的hash值,作为这次编译新生成文件的标识。而这次的编译hash值就会作为下次编译新生成文件的标示。

同时,我们可以在浏览器控制台中看到,新加载了两份文件:

Image

首先看json文件:h 代表本次编译生成的hash值。

c表示当前要热更新的文件对应的是index模块(看到下面js文件hash前面还有一个0么?如果前面是index的话,那么这里就是index: true)

而 js 文件就是我们这次修改完后,重新编译打包后的。

还有一种情况:我们本次没有修改,只是重新保存了一下代码然后重新编译时:

可以看到控制台只有一个json文件:里面的C是空的,代表本次没有需要更新的代码。

Image

3、webpack 的热更新原理

3.1 webpack-dev-server

我们通过 webpack-dev-server 来启动本地服务的,看一下这里的源码:

// node_modules/webpack-dev-server/bin/webpack-dev-server.js// 生成 webpack 编译主引擎 compilerlet compiler = webpack(webpackOptions);// 启动本地服务let server = new Server(compiler, options);server.listen(options.port, options.host, (err) => {  if (err) throw err;  if (options.bonjour) broadcastZeroconf(options);  const uri = createDomain(options, server.listeningApp) + suffix;  reportReadiness(uri, options);});function Server(compiler, options) {    if (!options) options = {};  // Init express server  const app = this.app = new express(); // eslint-disable-line  this.listeningApp = http.createServer(app);}Server.prototype.listen = function (port, hostname, fn) {  const returnValue = this.listeningApp.listen(port, hostname, (err) => {    // 启动websocket服务    const sockServer = sockjs.createServer({    });    if (fn) {      fn.call(this.listeningApp, err);    }  });  return returnValue;};

上面的代码中,先是启动webpack,生成compiler实例。接着启动本地server,这样浏览器就可以请求本地的静态资源了。然后再启动websocket服务。通过websocket可以建立本地服务器与浏览器的双向通信的功能,当本地文件变化时就可以通知浏览器做热更新的操作。

3.2.修改webpack.config.js的entry配置

在启动本地服务前,调用了 server.addDevServerEntrypoints

// lib/util/addDevServerEntrypoints.js// 获取websocket客户端代码const domain = createDomain(options, app); const clientEntry = `${require.resolve(   '../../client/' )}?${domain}${sockHost}${sockPath}${sockPort}`;// 根据配置获取热更新代码if (options.hotOnly) {  hotEntry = require.resolve('webpack/hot/only-dev-server');} else if (options.hot) {  hotEntry = require.resolve('webpack/hot/dev-server');}
修改后webpack入口配置如下:
// 修改后的entry入口// 在入口新增两个文件,就会一起打包到bundle文件中去,在线上运行。{    entry: {    main: [        // 上面获取的clientEntry      'xxx/node_modules/webpack-dev-server/client/index.js?http://localhost:8080',      // 上面获取的hotEntry      'xxx/node_modules/webpack/hot/dev-server.js',      // 开发配置的入口      './src/main.js'    ]  }}

webpack-dev-server/client/index.js: 这个文件是用于websocket的,需要双向通信,我们在前面启动server的时候,我们本地启动了websocket,但是浏览器还有没有和我们本地的服务端通信。但是浏览器上又没有事先就有websocket的代码,所以需要我们这边把websocket浏览器通信的代码也塞进我们的代码中(打包进bundle)。

webpack/hot/dev-server.js: 这个文件主要是用于检测更新的逻辑的。

3.3 监听webpack编译结束

修改好入口配置后,注册监听事件,监听每次webpack编译完成。

// node_modules/webpack-dev-server/lib/Server.jsconst { compile, invalid, done } = compiler.hooks;...done.tap('webpack-dev-server', (stats) => {  this._sendStats(this.sockets, this.getStats(stats));  this._stats = stats;});// 通过websoket给客户端发消息_sendStats() {  ...  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');  }}

当监听到一次webpack编译结束,调用_sendStates()方法通过websocket给浏览器发通知:ok和hash事件。这样浏览就拿到最新的hash值了。

3.4 webpack监听文件变化

每次我们本地修改完代码保持,都会触发编译,那证明webpack中间还有监听文件的变化,主要是通过setupDevMiddleware方法实现的。

webpack-dev-server:负责启动服务和前置准备工作webpack-dev-middleware:负责本地文件的编译和输出以及监听。// node_modules/webpack-dev-server/lib/server.jssetupDevMiddleware() {  // middleware for serving webpack bundle  this.middleware = webpackDevMiddleware(    this.compiler,    Object.assign({}, this.options, { logLevel: this.log.options.level })  );}

这个方法主要是执行了 webpack-dev-middleware库,看一下

// node_modules/webpack-dev-middleware/index.jscompiler.watch(options.watchOptions, (err) => {    if (err) { /*错误处理*/ }});// 通过“memory-fs”库将打包后的文件写入内存setFs(context, compiler);

(1) 调用compiler.watch方法,开启对本地文件的监听,当文件发生变化,重新编译,编译完成后继续监听。

(2) 执行setFs方法,这个方法的目的是将编译后的文件打包到内存。开发过程中我们发现并没有dist目录,因为代码都在内存中。因为访问内存中的代码比访问文件系统中的文件要快,而且也减少了代码写入文件的开销。

3.5浏览器收到更新通知

上面说到,当监听到文件发生变化,就会给浏览器发送ok和hash事件。

那浏览器是怎么接受websocket的消息的呢?

还记得我们上面说的修改entry入口么?这个文件会被打包到bundle.js中,并运行在浏览器中。

'xxx/node_modules/webpack-dev-server/client/index.js?http://localhost:8080'

看一下这个核心代码:

// webpack-dev-server/client/index.jsvar socket = require('./socket');var onSocketMessage = {  hash: function hash(_hash) {    // 更新currentHash值    status.currentHash = _hash;  },  ...  ...  ok: function ok() {    sendMessage('Ok');    // 进行更新检查等操作    reloadApp(options, status);  },}socket(socketUrl, onSocketMessage);// webpack-dev-server/client/util/reloadApp.jsfunction reloadApp() {   if (hot) {    log.info('[WDS] App hot update...');    // hotEmitter其实就是EventEmitter的实例    var hotEmitter = require('webpack/hot/emitter');    hotEmitter.emit('webpackHotUpdate', currentHash);  }}

socket 方法建立了websocket和服务端的连接,并注册了很多个监听事件。我们主要看ok 和hash 事件。

  • hash 事件:更新最后一次打包的hash值。

  • ok事件:调用reloadApp()方法,进行热更新检测。

reloadApp()方法中,利用了node.js的EventEmitter,发出webpackHotUpdate消息,发出了这个消息之后,webpack接下来会做什么呢?

我们之前entry入口中,除了新增 'xxx/node_modules/webpack-dev-server/client/index.js?http://localhost:8080', 还有新增了一个文件:'xxx/node_modules/webpack/hot/dev-server.js'

那我们看看这个文件的内容:

// node_modules/webpack/hot/dev-server.jsvar check = function check() {  module.hot.check(true)    .then(function(updatedModules) {    // 容错,直接刷新页面    if (!updatedModules) {      window.location.reload();      return;    }    // 热更新结束,打印信息    if (upToDate()) {      log("info", "[HMR] App is up to date.");    }  })  .catch(function(err) {    window.location.reload();  });};var hotEmitter = require("./emitter");hotEmitter.on("webpackHotUpdate", function(currentHash) {    lastHash = currentHash;    check();});

可以看到这里webpack监听到了 webpackHotUpdate 这个事件,获取到最新的hash值,然后进行检测更新。

但是module.hot.check这个是来自哪里呢

3.6 HotModuleReplacementPlugin

首先对比一次,配置了热更新和不配置热更新时bundle.js的区别。

没有配置的:

Image

配置了的:

Image

对比发现配置了热更新的文件中,多了个hot: hotCreateModule(moduleId)的配置。

当我们在继续往下找hotCreateModule()方法,就可以找到module.hot.check这个来源了。

Image

在浏览器环境中,webpack和plugin会偷偷的加上一些代码,为了检查更新,也为了方便调试。主要是利用了tapable。

3.7 module.hot.check

从上面我们可以知道 module.hot.check 是从 HotModuleReplacementPlugin 中来的。具体做了:

  • 利用上一次保存的hash值,调用 hotDownloadManifest 发送 xxx/hash-update.json的ajax请求

  • 请求结果获取热更模块,以及下次热更新的Hash标识,并进入热更新准备阶段。

Image

调用hotDownloadUpdateChunl 发送 xxx/hash.hot-update.js请求(JSONP)

Image

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);}

为什么使用JSONP获取最新的代码?

新编译后的代码在一个webpackHotUpdate函数体内部的,也就是立即执行webpackHotUpdate这个方法。

// webpackHotUpdatewindow["webpackHotUpdate"] = function (chunkId, moreModules) {    hotAddUpdateChunk(chunkId, moreModules);} ;hotAddUpdateChunk 方法会把更新的模块 moreModules 赋值给全局变量 hotUpdate。hotUpdateDownloaded 方法会调用 hotApply 进行代码的替换。function hotAddUpdateChunk(chunkId, moreModules) {  // 更新的模块moreModules赋值给全局全量hotUpdate  for (var moduleId in moreModules) {    if (Object.prototype.hasOwnProperty.call(moreModules, moduleId)) {      hotUpdate[moduleId] = moreModules[moduleId];    }  }  // 调用hotApply进行模块的替换  hotUpdateDownloaded();}

3.8 hotApply 热更新模块替换

热更新的核心在 hotApply 这个函数中。具体做了:

  • 删除过期的模块,就是需要替换的模块 (通过hotUpdate可以找到旧模块)

var queue = outdatedModules.slice();while (queue.length > 0) {  moduleId = queue.pop();  // 从缓存中删除过期的模块  module = installedModules[moduleId];  // 删除过期的依赖  delete outdatedDependencies[moduleId];  // 存储了被删掉的模块id,便于更新代码  outdatedSelfAcceptedModules.push({    module: moduleId  });}// 将新的模块添加到modules中appliedUpdate[moduleId] = hotUpdate[moduleId];for (moduleId in appliedUpdate) {  if (Object.prototype.hasOwnProperty.call(appliedUpdate, moduleId)) {    modules[moduleId] = appliedUpdate[moduleId];  }}// 通过__webpack_require__执行相关模块的代码for (i = 0; i < outdatedSelfAcceptedModules.length; i++) {  var item = outdatedSelfAcceptedModules[i];  moduleId = item.module;  try {    // 执行最新的代码    __webpack_require__(moduleId);  } catch (err) {    // ...容错处理  }}

4、总结

Image