前端开发环境热更新实现原理
热更新是现代前端开发环境中必不可少的一项,本文整理了一些打包工具parcel
、webpack
和vite
热更新的使用机制,同时了解热更新的实现原理。
参考
- 彻底搞懂并实现webpack热更新原理
- Webpack 热更新HMR 原理全解析
- 轻松理解webpack热更新原理
- What exactly the webpack HMR,这个是redux作者在2014年的提问,然后webpack founder 的一个详细回答
背景
先来考虑一下,在传统的静态页面开发中,浏览器访问静态资源。每次更新代码后,手动触发重新打包,然后刷新页面,就可以看见改动后的效果了
这种效率太低了,不手动打包行不行?不刷新页面行不行?行
自动打包这个比较简单:可以启动一个服务端,服务端会负责返回打包后的静态资源,服务端可以监听文件的变化,自动触发打包。全量打包比较耗时,可以使用增量打包,只打包变化了的文件。
自动刷新呢?由于变化是在服务端进行的,因此需要将文件变化的消息发给浏览器,可以用 WebSocket。那需要我们手动写 websocket 吗?不用,在开发环境打包的时候服务端夹点私货,把 socket 客户端接收消息的代码一起打进去就可以了。
看起来还不错,也很容易理解,但自动刷新在某些场景下就不太适用了,比如一个很多表单项的页面,填了一大半内容了,如果改个文案,页面自动刷新,哦豁,填的表单内容都没了,全得重新填。
因此还需要一个局部刷新的功能,局部刷新指的是文件变化时,只刷新页面上使用该文件的部分,而不是整个页面location.reload
。
学术名叫HMR
,全称Hot Module Replacement
,其最大的作用是可以保持应用的状态。
那么 HMR 是怎么实现的呢?本文主要会研究这个问题,下面展示一下不同打包工具是怎么实现 HRM 的
一些开发工具是怎么实现HMR的
parcel 是怎么做的
在文档中描述到,parcel 默认会完全重载页面,而在某些时候会自动进行 HMR,如 CSS 改动、内置 HMR 支持的框架(React、Vue)等
可以通过module.hot
手动开启 HRM
if (module.hot) {
module.hot.accept();
}
在某个模块文件中声明accept
之后,再修改这个模块,就会替换该模块的代码,然后重新检测它及其所有父模块
来写个简单的 demo
显示 parcel 的入口文件
<h1>hello</h1>
<input type="text" id="myInput" />
<button id="myBtn">click me</button>
<script src="./index.js"></script>
然后是index.js
// index.js
let inputEl = document.querySelector("#myInput");
// 热更新的时候可以看见input编辑的内容不会随着刷新丢失
console.log(inputEl.value);
if (module.hot) {
// 只是简单的声明当前模块文件改动时使用HMR,而不是完全刷新
module.hot.accept(() => {});
}
然后启动开发环境parcel index.html
,在 input 随便输入一点东西,比如123
,然后改动index.js
,随便写点新内容
+ console.log('hrm change')
这个时候查看页面,就可以发现 input 的内容还在,说明浏览器没有全部刷新,而控制台在 clear 之后也重新输出了新的内容
除了浏览器 input 的内容之外,热更新还期望保存应用的状态。我们再修改一下index.js
,
let inputEl = document.querySelector("#myInput");
let myBtn = document.querySelector("#myBtn");
const app = {
text: "",
};
// input改动时将值记录到app.text中
inputEl.oninput = function () {
app.text = inputEl.value;
};
// 点击按钮时获取app保存的数据
myBtn.onclick = function () {
console.log(app.text);
};
if (module.hot) {
module.hot.accept(() => {});
}
如果还是找之前那样改动一下index.js
触发 HMR,虽然浏览器的 input 值还在,但是由于模块的重载,app.text
的值会被重置成空字符串。
为了保存应用的状态,需要在在模块被替换之前保存的状态,然后再模块被替换之后恢复数据,parcel 提供的是hot.dispose
和hot.accept
if (module.hot) {
// 模块将被替换时触发
module.hot.dispose((data) => {
data.text = inputEl.value;
});
// 替换完毕后触发
module.hot.accept(() => {
// 恢复应用状态
const { text } = module.hot.data;
app.text = text;
});
}
这下看看起来,一切都理所应当了。由于HRM只是用于开发期间提高效率,如果在每个模块中都手动保存和恢复应用状态,就会显得十分繁琐,所以成熟的框架内部都支持HMR,无需开发者关心这些逻辑。
从parcel可以看出HRM实现需要的几个核心概念
- server端和client端的通信
- 一个可以替换部分模块的模块管理系统
- 提供一些钩子保存和恢复状态
webpack 是怎么做的
我们再来看看webpack的HMR,参考webpack的HMR文档
首选通过webpack.config.js的devServer.hot
配置项开启热更新
devServer: {
port: 9000,
hot: true,
}
为了方便测试,我们使用上面parcel同一个html模板,然后新建一个入口文件index.js
。
与parcel类似,当模块改动时,webpack默认也是刷新全部页面,当模块注册了module.hot.accept
之后,就会走HMR
// index.js
// 启动热更新
if (module.hot) {
module.hot.accept(()=>{
console.log('module selft change')
})
}
启动服务npx webpack serve --mode=development
先在页面上的input里面输入一点东西,方便观察是不是全量刷新
然后改动一下index.js
文件并保存
+ console.log('hrm change')
就可以看见控制台模块的热更新,而不是刷新整个页面了。
module.hot.accept
除了注册当前模块的热更新,还可以通过传入依赖模块,注册对应依赖模块的热更新
module.hot.accept(
dependencies, // Either a string or an array of strings
callback, // Function to fire when the dependencies are updated
errorHandler // (err, {moduleId, dependencyId}) => {}
);
同理,如果需要保存状态,可以使用module.hot.dispose
vite 是怎么做的
vite采用了完全不同的开发模式,通过现代import直接加载服务端资源,避免了打包启动时的漫长时间。
由于运行方式不同,热更新的实现也有比较大的差异,参考vite源码。
在packages/vite/src/client/client.ts
文件中,实现了createHotContext
,该方法会返回一个hot对象,提供accept
、dispose
等接口
然后packages/vite/src/node/importAnalysis.ts
中,在拼接响应的模块内容时,如果开启了HMR,会拼接createHotContext
因此每个模块的import.meta.hot
就是暴露的HMR接口,与上面parcel、webpack的module.hot
类似
然后来看看vite文件变化时热更新的流程
在服务端
- 文件file变化时触发
handleHMRUpdate(file, server)
moduleGraph.getModulesByFile
获取文件依赖的模块,然后执行updateModules
- 在
updateModules
中遍历传入的模块列表,获得updates,然后通过websocket发送消息update
到浏览器
在浏览器端
- 收到update的消息,遍历
payload.updates
,触发fetchUpdate(update)
- 对于每一个update,最终会触发
import(filePath)
重新请求新的资源,获取到新的模块,然后执行模块的mod.callbacks
mod.callbacks就是hot.accpet
注册的回调。因此模块只要注册了hot.accpet方法,就可以实现HMR了。
小结
看起来每个开发工具都实现了module.hot
类似的接口,暴露给开发者用于决定当前模块是否需要实现HMR
accept
dispose
一些实现HMR接口的模块的例子
下面列举了一些常见的实现了HMR接口的模块的例子
style-loader
style-loader的主要功能是将css样式表的内容转换成JS可执行的代码,然后通过操作DOM将样式添加到页面上,
因此其大概的功能应该是
const styleCode = `
.title {
background: blue;
}
`;
function findStyleTag() {
const id = module.id;
let style = document.querySelector(`[data-id="${id}"]`);
if (!style) {
style = document.createElement("style");
style.setAttribute("data-id", id);
document.body.appendChild(style);
}
return style;
}
const style = findStyleTag();
style.textContent = styleCode;
if (module.hot) {
module.hot.accept(() => {});
}
可见在import 一个xx.css
文件的时候,style-loader应该需要
- 获取css文本的内容
- 动态像页面插入一个style标签
- css内容改变时,更新style标签的内容
接下来看看style-loader的源码
以默认配置的injectType: styleTag
为例
当配置项开启了hot
之后,会通过getStyleHmrCode
注入热更新代码
实际上就是注册了module.hot
等方法,简化一下getStyleHmrCode
中拼接的代码
if (module.hot) {
module.hot.accept(modulePath, function(){
content = require(${modulePath})
content = content.__esModule ? content.default : content;
update(content);
})
module.hot.dispose(function() {
update()
}
}
可以看到依赖一个update方法,回到loader.pitch
,一层层向上讯号
update = API(content, options);
然后全局搜一下API
方法的定义,发现是在getImportStyleAPICode(esModule,this)
这里
function getImportStyleAPICode(esModule, loaderContext) {
const modulePath = stringifyRequest(
loaderContext,
`!${path.join(__dirname, "runtime/injectStylesIntoStyleTag.js")}`
);
return esModule
? `import API from ${modulePath};`
: `var API = require(${modulePath});`;
}
injectStylesIntoStyleTag
这个文件里面定义就定义了更新style标签的方法,其流程与上面写的简易版本基本一致。
可见,如果期望模块实现热更新,需要模块自己注册module.hot
等HMR接口。
vue-loader
之前写过vue-loader源码分析,这里主要关注一下HRM的实现。
直接查看目录可以发现lib/codegen/hotRealod
方法,查看一下引用
想必这里就是注入热更新代码的地方,看看它的源码
const hotReloadAPIPath = JSON.stringify(require.resolve('vue-hot-reload-api'))
exports.genHotReloadCode = (id, functional, templateRequest) => {
return `
/* hot reload */
if (module.hot) {
var api = require(${hotReloadAPIPath})
api.install(require('vue'))
if (api.compatible) {
module.hot.accept()
if (!api.isRecorded('${id}')) {
api.createRecord('${id}', component.options)
} else {
api.${functional ? 'rerender' : 'reload'}('${id}', component.options)
}
${templateRequest ? genTemplateHotReloadCode(id, templateRequest) : ''}
}
}
`.trim()
}
可以看到,在genHotReloadCode
中,实际上注册了module.hot.accept
,然后通过vue-hot-reload-api
这个包来完成热更新,
- 如果sfc文件的id没有被记录,则调用
api.createRecord(id, component.options)
- 如果有记录,则说明是更新
- 函数组件调用
api.rerender(id, component.options)
- 常规组件调用
api.reload(id, component.options)
- 函数组件调用
追根溯源,看看vue-hot-reload-api
的实现
exports.createRecord = (id, options) => {
if(map[id]) return
let Ctor = null
if (typeof options === 'function') {
Ctor = options
options = Ctor.options
}
// 包装options
makeOptionsHot(id, options)
// 保存sfc文件对应的记录
map[id] = {
Ctor,
options,
instances: []
}
}
makeOptionsHot
需要看一下
function makeOptionsHot(id, options) {
if (options.functional) {
// 函数组件,劫持render
const render = options.render
options.render = (h, ctx) => {
const instances = map[id].instances
if (ctx && instances.indexOf(ctx.parent) < 0) {
instances.push(ctx.parent)
}
return render(h, ctx)
}
} else {
// initHookName 在2.0.0-alpha.7之前的版本是init,之后的是beforeCreate
injectHook(options, initHookName, function() {
const record = map[id]
if (!record.Ctor) {
record.Ctor = this.constructor
}
// 加入一个钩子方法,在组件创建时记录组件实例,后续更新的时候会用到
record.instances.push(this)
})
injectHook(options, 'beforeDestroy', function() {
const instances = map[id].instances
instances.splice(instances.indexOf(this), 1)
})
}
}
最后来看看reload
exports.reload = tryWrap((id, options) => {
const record = map[id]
if (options) {
// 重新包装 options
makeOptionsHot(id, options)
// ...
}
record.instances.slice().forEach(instance => {
// 调用组件的forceUpdate()刷新
if (instance.$vnode && instance.$vnode.context) {
instance.$vnode.context.$forceUpdate()
}
})
})
整理一下流程
- 初始化时,通过
makeOptionsHot
,方便在初始化组件时获得组件实例 - 通过vue-loader,在热更新时插入调用
api.reload
api.reload
中会调用当前文件对应的组件实例,执行foreceUpdate
实现一个最简单版 HMR
后面尝试实现一下
小结
总结一下,热更新实际上是
- 打包工具会提供一些HMR接口,模块可以实现这些接口,在文件变化前后做一些操作,比如保存状态、局部更新应用数据等
- 打包工具启动了一个服务端,在打包文件入口同时注入了ws客户端的代码,这样服务端和浏览器就可以双向通信
- 文件变化后,会推送消息到浏览器,浏览器拉取变化的模块,并用新的模块替换旧的模块
最后留一个问题:为什么import动态模块多了之后热更新就会变慢?
你要请我喝一杯奶茶?
版权声明:自由转载-非商用-保持署名和原文链接。
本站文章均为本人原创,参考文章我都会在文中进行声明,也请您转载时附上署名。