热更新(HMR)原理分析及 Hippy Vue & React 中的实践
相信开发过 Hippy 的同学最苦恼的痛点是 UI 布局难以快速修改和响应,本文将通过热更新来拯救你的开发效率!
一、背景
Hippy 是腾讯开源的原生跨端框架,他抹平了 Andorid 和 iOS 双端的差异,提供了接近 Web 端的开发体验,前端开发者可以将前端代码转换为终端的原生指令,进行原生终端 App 的开发。Hippy 在开发体验上,从最初的手动刷新页面,到支持监听文件编辑自动
live-reload
页面,已经有了很大的提升,但和我们 Web 端熟悉的
Hot Module Replacement(HMR)
还有些差距,后者可以实现
组件级别的保留状态刷新
,能够最大化地提升开发效率。
Hippy Vue & React
中的解决方案。
二、Webpack 热更新原理分析
2.1. Webpack 模块化机制
模块化 是整个 webpack 框架的基石,要扩展 webpack 的任一环节都需要了解其模块化机制,因此,本文将会对其做简要介绍。
模块(module)
定义为如下结构,对应于我们所写的一个源码文件,其 exports 属性的值就是我们源码中导出的属性:
var module = installedModules[moduleId] = {
i: moduleId,
l: false,
exports: {},
parents: null,
children: []
};
webpack 打包后的代码本质上是一个以模块列表为入参的立即执行函数,根据
webpack.config.js
中的
entry
列表解析出入口模块并执行,其依赖的子模块通过模块加载机制来加载。
(function(modules) { // webpackBootstrap
var installedModules = {};
var installedChunks = {
"index": 0
};
// webpack 模块加载实现
function __webpack_require__(moduleId) {/* ... */}
// webpack 异步模块加载实现
__webpack_require__.e = function requireEnsure(chunkId) {};
// Load entry module and return exports
return hotCreateRequire(1)(__webpack_require__.s = 1);
})({
'moduleId_1': (function(module, __webpack_exports__, __webpack_require__) {
// module content
module.exports.foo = '';
module.exports.bar = function() {/* ... */};
}),
'moduleId_2': (function(module, __webpack_exports__, __webpack_require__) {})
})
webpack 的模块加载机制分为从主包加载和从分包加载(异步懒加载)两种方式,下文详细介绍一下:
从
主包加载
时,先从模块缓存(
installedModules
)中查找是否存在模块,如存在则直接返回。如不存在,则根据 module 结构创建一个新的模块,然后执行模块代码,将其返回值赋值给
module.exports
,然后将此模块注册到模块缓存中,并返回
module.exports
。
从
分包加载
时,webpack 先判断该分包 chunk 是否已经注册到
installedChunks
中,如已注册,说明此 chunk 已经加载过了,直接从模块列表(
modules
)中返回该模块的导出项。如未注册,则先通过
jsonp
的形式,创建一个
script
标签来加载 chunk 资源,在 script
onload
回调中注册该 chunk 到 installedChunks。通过 jsonp 加载时,会立即执行 chunk 的代码逻辑,将分包的模块列表合并到主包模块列表内,这样确保
modules
对象保存的永远是完整的模块列表。最后根据
moduleId
返回该模块的导出项。
webpack 的模块化机制带来了诸多好处:
-
避免命名冲突(不占全局命名空间); -
便于依赖管理(无须手动组织 JS 文件顺序); -
利于性能优化(异步模块加载); -
提高可维护性; -
利于代码复用;
2.2. Webpack 热更新原理
2.2.1 Webpack 热更新框架
热更新整体框架由 PC 端的
webpack dev server
和 App 端的运行时
js bundle
两部分组成,两者通过 WebSocket 协议建立消息通道,传输热更新补丁版本的 hash 值,然后通过 HTTP 协议加载补丁文件,运行时替换过期模块,并通过
vue-loader
或
react-plugin
实现 UI 的重绘。
2.2.2. 热更新补丁
开启热更新时,每次文件变更后,webpack 会编译出热更新补丁,包括两类文件:
-
清单文件(
[hash].hot-update.json) :其文件内容包括热更新的hash,以及chunk列表 -
热更新 chunk(
[chunkname].[hash].hot-update.js) :其文件内容包括补丁modules列表,通过调用主包中的webpackHotUpdate接口,将分包中的模块合并进入到主包,这样模块列表(modules)及已安装的模块(installedModules)都是最新的。
2.2.3. 热更新详细流程
上图表明了完整的热更新流程(留意标红的模块,是和 Web 平台相关的实现,后续我们替换其实现即可迁移至其他运行环境),其流程可以归纳总结为以下几步:
-
编译阶段向
bundle注入热更新代码。这些代码片段及其作用分别为:
-
webpack/hot:解析模块依赖关系,暴露两个热更新钩子,accept钩子可以注册 UI 刷新逻辑,dispose钩子可以注册过期代码的副作用清理逻辑; -
webpack-dev-server/client:与webpack-dev-server建立热更新消息通道(WebSocket); -
vue-hot-reload-api:注册热更新钩子,负责vue的保留状态刷新; -
react-refresh-webpack-plugin:注册热更新钩子,负责react的保留状态刷新;
check
阶段:通过 XHR 下载清单文件(
[hash].hot-update.json
),检查其 hash 值,如果和旧的 hash 值不同,则表明代码已经过期,需要进行热更新;
prepare
阶段:加载热更新补丁:通过 jsonp 下载热更新 chunk(
[chunkname].[hash].hot-update.js
);
ready
阶段:解析受影响的模块;
dispose
阶段:清理过期模块的副作用。如过期样式表的卸载,移除其对应的 style 标签;
apply
阶段:更新 modules 缓存,触发
accept
钩子;
-
vue:调用
$forceUpdate接口重新 render 组件(触发 vue 的变化检测,保证 view 层和 view-model 层一致); -
react:通过
fast-refresh插件重新 render 组件;
idle
阶段,等待下一轮热更新。
2.2.4. 热更新模块的依赖解析
时序图中的 ready 阶段,对于热更新模块的依赖解析较为复杂,本文以 Vue 为例,讲述其依赖解析方式。
如下图所示,从叶节点向上查找
dependencyModule
(通过
accept('moduleId', callback)
注册的模块,vue 中为 template 和 style 模块)和
selfAcceptModule
(通过
accept()
注册的模块,vue 中为距叶节点最近的组件),遍历到
selfAcceptModule
,
selfAcceptModule
或根节点时停止向上遍历。下面举两个例子:
(1). 如果修改 fight-rank.vue 组件的 template 模块,则
hotUpdateModule
为vue-loader!scope-loader!fight-rank.vue?type=template,
selfAcceptModule
不存在,
dependencyModule
为 fight-rank.vue?type=template,只会触发 rerender 回调,此时组件实例的状态会保留。
(2). 如果修改 fight-rank-item.vue 组件依赖的 util 模块,则
hotUpdateModule
为 util.ts,
selfAcceptModule
为 fight-rank-item.vue 模块,
dependencyModule
不存在,只会触发 fight-rank-item.vue 组件的重新 require(包括子模块的 require ),并 reload 组件的所有实例,此时组件实例保存的状态将会丢失,如果使用了 vuex,则 store 中的数据状态会保留下来。
fddc454a98b79fc5dcad.hot-update.json
)文件内容:
{"h":"0a550a1bd5b0220906a0","c":{"index":true}}
(2)热更新chunk(
index.0a550a1bd5b0220906a0.hot-update.js
)文件内容,其中有变更的module是
"../../packages/hippy-vue-css-loader/dist/index.js!./node_modules/vue-loader/lib/index.js?!./node_modules/scope-loader/index.js!./src/components/demos/demo-button.vue?vue&type=style&index=0&id=26278b5d&scoped=true&lang=css&",可以从命名上看出是经过各种loader处理后的 module
index.bundle 中可以看到上面 module 的引用关系为:
-
在css module中引用 loader 处理后的module
-
在 vue module 中引用 css module,并注册热更新钩子。下图中对 style 的钩子 repaint 是自定义实现
三、Hippy Vue & React 中如何实现
3.1. Webpack 如何兼容
时序图中标红模块表示和 web 平台相关的能力,在 Hippy 中分别做以下实现:
-
通过
@hippy/debug-server以 API 的方式启动webpack-dev-server,并对webpack-dev-server做以下定制修改:
-
webpack-dev-server/client:
-
修改 HMR 的失败降级处理方案:调用 Hippy 中对应的 native 接口(
global.Hippy.bridge.callNative('DevMenu', 'reload');) -
移除 overlay 实现:overlay 用于在页面上层加蒙层提示热更新状态、进度、及编译错误信息,严重依赖 document 接口,Hippy 中可以直接移除; -
将
webpack/hot下的运行时代码 copy 到这里,做定制修改(如上面的降级处理逻辑),这样不用修改到 webpack npm 包中的代码;
-
webpack-dev-server/server:
-
通过 @hippy/debug-server启动,默认 端口为 38988
-
修改注入的 webpack entry:
-
注入
webpack/hot -
注入
webpack-dev-server/client
-
编译结束打印 bundle 链接及二维码(携带编译hash值):远程调试时扫码或输入 bundle 链接打开
新增 webpack 热更新插件(
@hippy/hippy-hmr-plugin
),负责注入 HMR runtime,修改
[hash].hot-update.json
,
[hash].hot-update.js
的加载逻辑:
-
由于热更新资源默认是通过 JsonpMainTemplatePlugin加载,所以需要在 Hippy HMR 插件中覆盖内置的JsonpMainTemplatePlugin的逻辑,然后注入 Hippy 平台的补丁加载接口: -
chunk 的加载:使用 Hippy 平台的
global.dynamicLoad接口替换 -
manifest 的加载:使用 fetch接口替换 -
异常处理,调用reload接口
global.Hippy.bridge.callNative('DevMenu', 'reload');
hippy-vue
:ElementNode 新增
repaint
接口,通过
renderToNativeWithChildren
接口重新渲染
@hippy/vue-loader
: 注册 style 热更新逻辑,重绘组件下的所有节点
3.2. Hippy Vue 热更新
3.2.1. 热更新 Hook
在 web 端,Vue 的热更新 Hook 是通过
vue-loader
注入的,vue-loader 首先将单文件结构拆分为
script
,
template
,
style
三部分,然后在 vue 模块中分别对
script
及
template
子模块注册
accept
钩子,在子模块有更新时重新 render 该组件。这部分逻辑在 Hippy 环境可以直接复用。
3.2.2. 热更新样式的应用
对于样式的 Hook,web 端是通过
vue-css-loader
注册的,对于热更新样式直接创建 style 标签即可,对于过期样式则通过 remove 对应的 style 标签实现。
Hippy Vue 与 Web 端在 CSS 的实现上有较大差异,Hippy 将样式表全部存储在全局对象 CssMap 中,在
renderNode
时,通过
getCssMap().query(targetNode)
查询其样式表,然后再调用终端接口重绘节点。所以
vue-css-loader
中的 Hook 不可以复用,本文通过在
@hippy/vue-loader
中注册 style 模块的 repaint 钩子实现。
具体在实现样式的热更新时踩了很多坑,最初方案是递归遍历组件实例下的所有
ElementNode
,通过
getCssMap
获取最新的样式表,然后通过
setNativeProps
接口修改样式属性,但是这样存在诸多问题:
-
样式分为数据绑定的样式和 style 中的样式,上面的方法会把数据绑定的样式给覆盖掉。那么 web 中为何不会出现这种问题呢,猜想是数据绑定生成的样式是内联样式,优先级高于 style 中的样式。这样的话 hippy 中又如何调高数据绑定的样式的优先级呢?我首先想到的是再次执行组件的
render函数,从而覆盖掉 style 中的样式,实测通过调用父组件的$forceUpdate()接口,确实是能解决这个问题。 -
setNativeProps接口会修改ElementNode的 style 属性,为节点新增内联样式,那么对于通过数据绑定修改className的节点,会发现className变更后样式未更新(优先级低于内联样式)。
卡在这个问题上了两天,最后尝试了一下直接调用
renderToNative/renderToNativeWithChildren
接口,意外发现竟然成功了!我们到源码里看具体的实现,
targetNode.style
为内联样式,通过
matchedSelectors
计算出来的 style 为style标签的样式,这样按优先级合并两者就不存在第一个问题了,另外也不用调用组件的
$forceUpdate()
接口。同时在更新样式时
renderToNative
并没有把所有样式都设为内联样式,这样两个问题就都解决了。
3.2.3. 过期样式的卸载
热更新样式虽然生效了,但是当重新编辑样式文件后,存在过期样式并未卸载的情况。我们先了解下 web 中是如何做的,web 中会通过 css selector 查询到过期样式的 style 标签,然后将其从 DOM 中 remove 掉,操作相对来说比较简单。
Hippy 中不同的是,分包的样式表都是通过
global[GLOBAL_STYLE_NAME]
传递给
getCssMap
接口,然后不断地向全局样式表(CssMap)中
append
实现的,那么我们开发阶段不断触发 HMR 时,一方面会导致整个
cssMap
占用内存不断增大,另一方面会导致过期样式未卸载的问题。所以本文在
hippy-vue
sdk 中新增了样式选择器的
remove
接口,针对各种 id、class、tag、以及复合选择器分别实现删除接口。然后在
hippy-vue-css-loader
中注入
dispose
钩子,在 chunk 卸载时将要过期的样式表 id 缓存起来,然后在下一次调用
getCssMap
时触发过期样式表的卸载。
3.2.4. 热更新策略
-
组件为热更新最小粒度,组件树之外的 js 不支持热更新,降级为热重载; -
对于 script 模块,会 reload 该 module,保证该 module 子树的代码为最新版,这种情况组件状态将会丢失。如果有配合使用 vuex,则 store 中的状态可以保留; -
对于 style 模块,会 repaint 该组件,通过调用 renderToNative 和 updateNode 来更新节点样式,组件状态可以保留; -
对于 template 模块,会调用 vue 的 $forceUpdate 接口,重新 render 组件(触发 vue 的变化检测,保证 view 层和 view-model 层一致),组件状态可以保留。
3.3. Hippy React 热更新
我们在实现
hippy-react
的热更新前,先了解下 web 端 react 的热更新方案,主要有 react-hot-loader 和 fast-refresh 两种:
react hot loader
通过在组件上加一层代理,热更新时保留数据状态,替换组件逻辑来实现。目前
react hot loader
官方已经声明 deprecated 了,建议统一使用 react native 的
fast refresh
方案。所以下文主要介绍
fast refresh
的方案。
3.3.1. Fast Refresh 的热更新原理
fast refresh
是 react 在 sdk 接口层提供的热更新方案,对于自定义 renderer(如 RN, Hippy React)完全兼容
,下面我们探究一下其实现原理:
-
react-refresh-webpack-plugin/loader:在每个 module 源码后注入代码,调用
executeRuntime接口,检查是否首次注册组件,如非首次注册,说明是热更新模块,需要 rerender 或 remount,将组件放在pendingUpdate列表里。然后调用performReactRefresh接口
-
performReactRefresh 是
react-refresh的组件刷新逻辑,他会将pendingUpdate组件分类updatedFamilies和staleFamilies,前者会重新渲染,能够保留组件数据状态,后者会执行重新挂载,会丢失数据状态。组件刷新需要获取到 root fiber 对象,那么root fiber又是如何拿到的呢? -
react-reconciler 里其实已经帮我们实现了,通过
injectIntoDevTools接口暴露了一些hooks给renderer(如react-dom和react-native)。 -
对于自定义 renderer(如 hippy-react),想使用fast-refresh功能,只需要在render函数中调用injectIntoDevTools接口即可
3.3.2. Hippy React 热更新策略
-
对于函数式组件,可以保留状态 -
对于类组件不能保留状态,会进行组件粒度的刷新 -
如果编辑的模块在组件树内,则会只刷新组件 -
如果编辑的模块在组件树之外,HMR 会降级成热重载
四、效果和接入
Hippy Vue 实现效果:
接入方式请参考 Hippy 官网的指引:HMR & Live-Reload 能力。
五、总结和展望
本文详细分析了 Webpack 的热更新实现原理,通过分析其与 web 平台强相关的实现模块,并在 Hippy 运行环境下寻找替换解决方案,实现了 Hippy Vue & React 的热更新,相信可以极大地提高你的开发效率。本文的方案还存在一些优化空间,未来将在以下几方面进一步完善:
-
开发流程较为繁琐,需要启动 npm run hippy:dev,npm run hippy:debug两步才行,未来将通过远程调试服务替代第二个命令; -
更细粒度、覆盖面更广的热更新,如
store层面的保留状态更新; -
热更新错误、警告、进度等信息通过
overlay覆盖在页面上面,并可以通过插件配置选项开启; -
远程热更新:由于公司的网络隔离策略,iOS 真机调试时,需要切换体验网,开发上较为麻烦,可以通过将
HMR server部署公网来解决这个问题。
最后,给
Hippy
及
Tencent Dynamic Framework(TDF)
的调试工具打个广告,欢迎下载使用
@hippy/debug-server-next
,
支持
UI Inspector
,
Source
,
Log
,
Memory
等调试能力,支持使用 chrome 调试 iOS,保证两端有一致的调试体验
。大家有任何 Hippy 调试相关的问题可以联系我们来优化解决。