阅读 397
webpack 热更新原理

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 的标识符。

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

这里的Hash值改变了:480ff6313164fc01043e

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

:

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

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

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

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

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

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

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

3、webpack 的热更新原理

webpack-dev-server

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

// node_modules/webpack-dev-server/bin/webpack-dev-server.js
 
// 生成 webpack 编译主引擎 compiler
let 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可以建立本地服务器与浏览器的双向通信的功能,当本地文件变化时就可以通知浏览器做热更新的操作。

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: 这个文件主要是用于检测更新的逻辑的。

监听webpack编译结束

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

// node_modules/webpack-dev-server/lib/Server.js

const { 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值了。

webpack监听文件变化

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

webpack-dev-server:负责启动服务和前置准备工作

webpack-dev-middleware:负责本地文件的编译和输出以及监听。

// node_modules/webpack-dev-server/lib/server.js

setupDevMiddleware() {
  // 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.js
compiler.watch(options.watchOptions, (err) => {
    if (err) { /*错误处理*/ }
});
 
// 通过“memory-fs”库将打包后的文件写入内存
setFs(context, compiler); 
复制代码

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

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

浏览器收到更新通知

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

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

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

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

看一下这个核心代码:

// webpack-dev-server/client/index.js
var 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.js
function 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.js
var 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这个是来自哪里呢?

HotModuleReplacementPlugin

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

没有配置的:

配置了的:

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

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

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

module.hot.check

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

  • 利用上一次保存的hash值,调用 hotDownloadManifest 发送 xxx/hash-update.json的ajax请求
  • 请求结果获取热更模块,以及下次热更新的Hash标识,并进入热更新准备阶段。

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

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这个方法。

// webpackHotUpdate
window["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();
}
复制代码
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、总结

文章分类
前端
文章标签