热更新
热加载(Hot Module Replacement,HMR)是一种在前端开发中非常有用的技术,它允许在无需开发者手动刷新页面的情况下,自动替换、添加或删除模块,从而可以极大地提高开发效率,让开发者在不丢失应用程序状态的情况下看到代码的实时变化。
虽然不同的打包工具(Vite / Webpack / Parcel)等有不同的实现,但基本的原理相同,本文以 Vite 为例进行说明。
基本原理
热更新的核心原理是监听源代码的变化(可通过 chokidar 实现),在变化发生时只更新那些发生变化的模块,而不是重新加载整个页面。具体步骤如下:
- 监听文件变化:当文件发生变化时,Vite 会重新编译这些文件。
- 通知客户端:Vite Dev Server 会通过 WebSocket 通知浏览器端有文件变化。
- 更新模块:浏览器端接收到通知后,会通过 HMR API 请求新的模块代码。
- 替换模块:新的模块代码被加载并替换旧的模块,应用程序状态保持不变。
其中第 1、2、3 步是构建工具实现的(例如 Vite),第 4 布是由框架实现的(例如 Vue / React)的。
接下来我们主要讲一讲第 4 步的实现。
简单例子
可以打开 CodeSandBox - vite-hmr-example 进行体验,首先在 index.html
中注册了 ESM 模块 main.js
:
<body>
<h1>Counter: <span id="counter">0</span></h1>
<button id="increment">点击 +1</button>
<script type="module" src="/main.js"></script>
</body>
然后在 main.js
通过对 HMR API 的回调的注册实现了热更新的能力:
console.log('执行 main.js');
let counter = 0;
// 更新 HTML 中的 counter
function updateCounter() {
document.getElementById('counter').innerText = counter;
}
// 点击事件响应函数
function onIncrement() {
counter++;
updateCounter();
}
// 绑定监听事件
const incrementButton = document.getElementById('increment');
incrementButton.addEventListener('click', onIncrement);
// HMR API 处理
if (import.meta.hot) {
// 注册新代码生效时的回调
import.meta.hot.accept(() => {
console.log('执行 accept');
});
// 注册旧代码被移除时的回调
import.meta.hot.dispose((data) => {
console.log('执行 dispose', data);
// 保存旧数据
data.counter = counter;
// 移除副作用
incrementButton.removeEventListener('increment', onIncrement);
});
if (typeof import.meta.hot.data.counter!== 'undefined') {
// 恢复旧数据
console.log('回复数据');
counter = import.meta.hot.data.counter;
}
}
我们可以点击“+1”按钮,发现 counter
值增加。然后编辑并保存本地的 main.js
,发现它被自动重新加载,而且 counter
保持之前的值不变。
打开控制台,可以发现如下顺序输出:
- 执行 main.js:第一次加载,开始执行
main.js
。 - 执行 dispose:收到热更新通知,移除旧的 main.js。
- 执行 main.js:第二次加载,开始执行新的 main.js。
- 恢复数据:第二次加载,执行到新的 main.js 尾部。
- 执行 accept:第二次加载完成。
核心 API
import.meta.hot
通过 import.meta.hot
来守护所有的 HMR API 使用,这样代码就可以在生产环境中被 tree-shaking 优化。
import.meta.hot.accept
注册新模块加载完成时的回调。
import.meta.hot.dispose
注册旧模块被移除时的回调。
import.meta.hot.prune
注册模块在页面上不再被导入时的回调。
import.meta.hot.data
import.meta.hot.data
对象在同一个更新模块的不同实例之间会被持久化,它可以用于将信息从模块的前一个版本传递到下一个版本(如上一节的例子中用于保存 counter
数据)。
Vue 的热更新实现
Vue 热更新的实现主要是借助全局变量 __VUE_HMR_RUNTIME__
。通过它,浏览器的运行时和本地启动的构建服务实现了交互。
运行时
Vue 运行时对热更新的实现代码在 Github - runtime-core/src/hmr.ts,主要是做了以下几件事情:
- 全局暴露 VUE_HMR_RUNTIME:在开发环境中,将 HMR 运行时暴露到全局对象上,以便构建插件使用。
- 创建和管理 HMR 记录:通过
createRecord
函数创建组件的 HMR 记录,并将其存储在一个全局的map
中。 - 注册和注销组件实例:通过
registerHMR
和unregisterHMR
函数,管理组件实例的注册和注销。 - 重新渲染组件:通过
rerender
函数,在组件模板或渲染函数发生变化时,重新渲染组件。 - 重新加载组件:通过
reload
函数,在组件的逻辑或样式发生变化时,重新加载组件。
构建插件
Vue 构建插件 Vite-plugin-vue 对热更新的实现代码在 Github - plugin-vue/src/main.ts,核心代码如下,主要是注册了 import.meta.hot.accept
的回调函数,用于调用 rerender
或者 reload
函数:
// HMR
if (
devServer &&
devServer.config.server.hmr !== false &&
!ssr &&
!isProduction
) {
output.push(`_sfc_main.__hmrId = ${JSON.stringify(descriptor.id)}`)
output.push(
`typeof __VUE_HMR_RUNTIME__ !== 'undefined' && ` +
`__VUE_HMR_RUNTIME__.createRecord(_sfc_main.__hmrId, _sfc_main)`,
)
// check if the template is the only thing that changed
if (prevDescriptor && isOnlyTemplateChanged(prevDescriptor, descriptor)) {
output.push(`export const _rerender_only = true`)
}
output.push(
`import.meta.hot.accept(mod => {`,
` if (!mod) return`,
` const { default: updated, _rerender_only } = mod`,
` if (_rerender_only) {`,
` __VUE_HMR_RUNTIME__.rerender(updated.__hmrId, updated.render)`,
` } else {`,
` __VUE_HMR_RUNTIME__.reload(updated.__hmrId, updated)`,
` }`,
`})`,
)
}