前言
Webpack热更新( Hot Module Replacement,简称 HMR,后续均以 HMR 替代),无需完全刷新整个页面的同时,更新所有类型的模块,是 Webpack 提供的最有用的功能之一。
HMR 作为一个 Webpack 内置的功能,可以通过 --hot 或者 HotModuleReplacementPlugin 开启。
刷新分为两种:一种是页面刷新,不保留页面状态,就是简单粗暴,直接window.location.reload();另一种是基于 WDS(Webpack-dev-server)的模块热替换,只需要局部刷新页面上发生变化的模块,同时可以保留当前的页面状态,比如复选框的选中状态、输入框的输入等。
HMR 的好处,在日常开发工作中体会颇深:节省宝贵的开发时间、提升开发体验。引用官网的描述来概述一下:
模块热替换(HMR - hot module replacement)功能会在应用程序运行过程中,替换、添加或删除 模块,而无需重新加载整个页面。主要是通过以下几种方式,来显著加快开发速度:
- 保留在完全重新加载页面期间丢失的应用程序状态。
- 只更新变更内容,以节省宝贵的开发时间。
- 在源代码中对 CSS / JS 进行修改,会立刻在浏览器中进行更新,这几乎相当于在浏览器 devtools 直接更改样式。
在开发环境,可以将 HMR 作为 LiveReload 的替代。那么,HMR到底是怎么实现热更新的呢?下面通过观察编译构建过程,以及分析源代码来了解一下。
本次探索依赖公司前端 Vue 项目开发脚手架配置:Webpack + Webpack-Dev-Middleware + Webpack-Hot-Middleware + Express。
webpack 构建
项目启动之后,会进行首次构建打包,控制台中会输出整个的构建过程,可以观察到一个 Hash 值 3606e1ab1ddcf6626797。
在每次代码的修改后,保存时都会在控制台上出现 compiling…字样,可以在控制台中观察到:
-
Hash 值更新:4f8c0eff7ac051c13277;
-
新生成文件:3606e1ab1ddcf6626797.hot-update.json
-
main1.3606e1ab1ddcf6626797.hot-update.js。
如果没有任何改动,直接保存,控制台输出编译打包信息,Hash 值没有发生变化,仍旧是 4f8c0eff7ac051c13277。
再次修改代码保存,控制台输出编译打包信息。根据文件名可以发现,上次输出的 Hash 值被作为本次编译新生成的 Hmr 文件标识。同样,本次输出的 Hash 值会被作为下次热更新的标识。
Webpack Watch
为什么代码的改动保存会自动编译,重新打包?这一系列的重新检测编译依赖于 Webpack 的文件监听:在项目启动之后,Webpack 会通过 Compiler 类的 Run 方法开启编译构建过程,编译完成后,调用 Watch 方法监听文件变更,当文件发生变化,重新编译,编译完成之后继续监听。
页面的访问需要依赖 Web 服务器,那要如何将 Webpack 编译打包之后的文件传递给 Web 服务器呢?这就要看 Webpack-dev-middleware了。 Webpack-dev-middleware 是一个封装器( wrapper ),它可以把 Webpack 处理过的文件发送到一个 Server(其中 Webpack-Dev-Server 就是内置了 Webpack-dev-middleware 和 Express 服务器)。上面有说到编译之后的文件会被写入到内存,而 Webpack-dev-middleware 插件通过 memory-fs
实现静态资源请求直接访问内存文件。
const webpack = require('webpack');
const webpackConfig = require('./webpack.dev.conf');
const compiler = webpack(webpackConfig);
debug('webpack编译完成');
debug('将编译流通过webpack-dev-middleware');
const devMiddleware = require('webpack-dev-middleware')(compiler, {
// self-define options
});
复制代码
上面代码可以看到,Webpack 编译打包之后得到一个 Compilation ,并将 Compilation 传递到 Webpack-dev-middleware 插件中,Webpack-dev-middleware 可以通过 Compilation 调用 Webpack中 的 Watch 方法实时监控文件变化,并重新编译打包写入内存。
留意一下浏览器端,在 Network 中可以看到几个请求:
/__Webpack_hmr 请求返回的消息包含了首次 Hash 值,每次代码变动重新编译后,浏览器会发出 hash.hot-update.json、fileChunk.hash.hot-update.js 资源请求。
点开查看 hash.hot-update.json 请求,返回的结果中,h 是一个 hash 值,用于下次文件热更新请求的前缀,c 表示当前要热更新的文件是 main1 。
继续查看 fileChunk.hash.hot-update.js,返回的内容是使用 webpackHotUpdate 标识的 fileChunk 内容。
那么 Webpack 服务器和浏览器端是怎么建立起通信的呢?这就是 Webpack-hot-middleware 插件的功劳了。 Webpack-hot-middleware 的 README.md 文档中有这样一段描述:
This module is only concerned with the mechanisms to connect a browser client to a Webpack server & receive updates.
Webpack-hot-middleware 插件的作用就是提供浏览器和 Webpack 服务器之间的通信机制、且在浏览器端接收 Webpack 服务器端的更新变化。
为了更好的理解这一段话,打开浏览器开发者调试工具,可以看到在 Webpack 打包好的 Js 中主要包含了以下几部分。下面截取关键部分进行说明:
- Webpack-hot-middleware/client.js
源码中有这么一段配置,看到这里瞬间想到了在开发时浏览器的 Network 中总是有一个 __Webpack_hmr 的请求,点开查看会看到EventStream 事件流(服务器端事件流,服务器向浏览器推送消息,除了 websocket 全双工通道双向通信方式还有一种 Server-Sent Events 单向通道的通信方法,只能服务器端向浏览器端通过流信息的方式推送消息;页面可以通过 EventSource 实例接收服务器发送事件通知并触发 onmessage 事件),并且以 2s 的频率不停的更新消息内容,每行消息内容都有 ❤️ 的图标,没错这就是一个心跳请求。
var options = {
path: '/__Webpack_hmr',
timeout: 20 * 1000,
overlay: true,
reload: false,
log: true,
warn: true,
name: '',
autoConnect: true,
overlayStyles: {},
overlayWarnings: false,
ansiColors: {},
};
复制代码
继续向下查看 Client.js 代码,发现这完全就是一个只要浏览器支持就可以自发建立通信通道的客户端。
if (typeof window === 'undefined') {
// do nothing
} else if (typeof window.EventSource === 'undefined') {
// warning
} else {
if (options.autoConnect) {
// 建立通信连接
connect();
}
}
复制代码
在建立通信的过程中,浏览器端会初始化一个 EventSource 实例并通过 onmessage 事件监听消息。浏览器端在收到服务器发来的数据时,就会触发 onmessage 事件,可以通过定义 onmessage 的回调函数处理接收到的消息。
// 浏览器端建立连接
function EventSourceWrapper() {
var source;
var listeners = [];
// 初始化EventSource实例
source = new window.EventSource(options.path);
// 定义onmessage事件监听服务器端消息返回
source.onmessage = handleMessage;
function handleMessage(event) {
for (var i = 0; i < listeners.length; i++) {
listeners[i](event);
}
}
return {
addMessageListener: function(fn) {
listeners.push(fn);
}
};
}
// 浏览器端建立通信通道,监听处理服务器端推送的消息
function connect() {
EventSourceWrapper().addMessageListener(handleMessage);
function handleMessage(event) {
try {
processMessage(JSON.parse(event.data));
} catch (ex) {
// handler exception
}
}
}
复制代码
Client.js 监听的消息有:
-
building/built:构建中,不会触发热更新;
-
sync:开始更新的流程。
在 processUpdate 方法中,处理一切异常/错误的方法都是直接更新整个页面即调用 window.location.reload(),首先调用 module.hot.check 方法检测是否有更新,然后进入 HotModuleReplacement.runtime 的 Check 阶段。
function processMessage(obj) {
switch (obj.action) {
case 'building':
// tell you rebuilding
break;
case 'built':
// tell you rebuilt in n ms
// fall through
case 'sync':
// 省略...
var applyUpdate = true;
if (applyUpdate) {
processUpdate(obj.hash, obj.modules, options);
}
break;
default:
// do something
}
}
复制代码
热更新过程
改动页面代码保存之后,Webpack 会重新编译文件并发消息通知浏览器,浏览器在 Check 之后触发 WebpackHotUpdateCallback,具体 HotModuleReplacement.runtime.js 会做以下几个操作:
-
进入 HotCheck,调用 hotDownloadManifest 发送 /hash.hot-update.json 请求;
-
通过 Json 请求结果获取热更新文件,以及下次热更新的 Hash 标识,并进入热更新准备阶段;
hotAvailableFilesMap = update.c;// 需要更新的文件 hotUpdateNewHash = update.h;// 下次热更新hash值 hotSetStatus("prepare");// 进入热更新准备状态 复制代码
-
HotCheck 确认需要热更新之后进入 hotAddUpdateChunk 方法,该方法先检查 Hash 标识的模块是否已更新,如果没更新,则通过在 DOM 中添加 Script 标签的方式,动态请求js: /fileChunk.hash.hot-update.js,获取最新打包的 js 内容;
-
最新打包的js内容如何更新的呢?HotModuleReplacement.runtime.js 在 window 对象上定义了 WebpackHotUpdate方法;在这里定义了如何解析前面 fileChunk.hash.hot-update.js 请求返回的js内容 webpackHotUpdate(main1, { moreModules }),直接遍历 moreModules,并且执行 hotUpdate 方法更新;
结语
至此页面已经完成热更新,Webpack 如何实现热更新的呢?首先是建立起浏览器端和服务器端之间的通信,浏览器会接收服务器端推送的消息,如果需要热更新,浏览器发起http请求去服务器端获取打包好的资源解析并局部刷新页面。
作者:政采云前端团队
链接:https://juejin.im/post/5d8b755fe51d45781332e919