腾讯VATeam

热更新(HMR)原理分析及 Hippy Vue & React 中的实践

相信开发过 Hippy 的同学最苦恼的痛点是 UI 布局难以快速修改和响应,本文将通过热更新来拯救你的开发效率!


一、背景

Hippy 是腾讯开源的原生跨端框架,他抹平了 Andorid 和 iOS 双端的差异,提供了接近 Web 端的开发体验,前端开发者可以将前端代码转换为终端的原生指令,进行原生终端 App 的开发。Hippy 在开发体验上,从最初的手动刷新页面,到支持监听文件编辑自动 live-reload 页面,已经有了很大的提升,但和我们 Web 端熟悉的 Hot Module Replacement(HMR) 还有些差距,后者可以实现 组件级别的保留状态刷新 ,能够最大化地提升开发效率。

本文将探讨 Webpack 的 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 的模块化机制带来了诸多好处:

  1. 避免命名冲突(不占全局命名空间);
  2. 便于依赖管理(无须手动组织 JS 文件顺序);
  3. 利于性能优化(异步模块加载);
  4. 提高可维护性;
  5. 利于代码复用;

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 会编译出热更新补丁,包括两类文件:

  1. 清单文件( [hash].hot-update.json ) :其文件内容包括热更新的 hash ,以及 chunk 列表

  2. 热更新 chunk( [chunkname].[hash].hot-update.js ) :其文件内容包括补丁 modules 列表,通过调用主包中的 webpackHotUpdate 接口,将分包中的模块合并进入到主包,这样模块列表(modules)及已安装的模块(installedModules)都是最新的。

2.2.3. 热更新详细流程


上图表明了完整的热更新流程(留意标红的模块,是和 Web 平台相关的实现,后续我们替换其实现即可迁移至其他运行环境),其流程可以归纳总结为以下几步:

  1. 编译阶段向 bundle 注入热更新代码。这些代码片段及其作用分别为:
  • webpack/hot :解析模块依赖关系,暴露两个热更新钩子, accept 钩子可以注册 UI 刷新逻辑, dispose 钩子可以注册过期代码的副作用清理逻辑;
  • webpack-dev-server/client :与 webpack-dev-server 建立热更新消息通道(WebSocket);
  • vue-hot-reload-api :注册热更新钩子,负责 vue 的保留状态刷新;
  • react-refresh-webpack-plugin :注册热更新钩子,负责 react 的保留状态刷新;
  • 运行时阶段,建立消息通道:通过 WebSocket 与 webpack dev server 通信。本地启动编译时向 webpack 添加钩子,在新的编译结束后发送补丁 hash 值到客户端;
  • check 阶段:通过 XHR 下载清单文件( [hash].hot-update.json ),检查其 hash 值,如果和旧的 hash 值不同,则表明代码已经过期,需要进行热更新;
  • prepare 阶段:加载热更新补丁:通过 jsonp 下载热更新 chunk( [chunkname].[hash].hot-update.js );
  • ready 阶段:解析受影响的模块;
  • dispose 阶段:清理过期模块的副作用。如过期样式表的卸载,移除其对应的 style 标签;
  • apply 阶段:更新 modules 缓存,触发 accept 钩子;
  • UI 刷新:不同前端框架各自处理自己的刷新逻辑
    • 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 中的数据状态会保留下来。


    下面从打包代码层面看一个实际的例子,看下热更新的变更内容: (1)热更新清单( 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 中分别做以下实现:

    1. 通过 @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 接口修改样式属性,但是这样存在诸多问题:

    1. 样式分为数据绑定的样式和 style 中的样式,上面的方法会把数据绑定的样式给覆盖掉。那么 web 中为何不会出现这种问题呢,猜想是数据绑定生成的样式是内联样式,优先级高于 style 中的样式。这样的话 hippy 中又如何调高数据绑定的样式的优先级呢?我首先想到的是再次执行组件的 render 函数,从而覆盖掉 style 中的样式,实测通过调用父组件的 $forceUpdate() 接口,确实是能解决这个问题。

    2. 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. 热更新策略

    1. 组件为热更新最小粒度,组件树之外的 js 不支持热更新,降级为热重载;
    2. 对于 script 模块,会 reload 该 module,保证该 module 子树的代码为最新版,这种情况组件状态将会丢失。如果有配合使用 vuex,则 store 中的状态可以保留;
    3. 对于 style 模块,会 repaint 该组件,通过调用 renderToNative 和 updateNode 来更新节点样式,组件状态可以保留;
    4. 对于 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)完全兼容 ,下面我们探究一下其实现原理:
    1. react-refresh-webpack-plugin/loader:在每个 module 源码后注入代码,调用 executeRuntime 接口,检查是否首次注册组件,如非首次注册,说明是热更新模块,需要 rerender 或 remount,将组件放在 pendingUpdate 列表里。然后调用 performReactRefresh 接口


    2. performReactRefresh 是 react-refresh 的组件刷新逻辑,他会将 pendingUpdate 组件分类 updatedFamilies 和 staleFamilies ,前者会重新渲染,能够保留组件数据状态,后者会执行重新挂载,会丢失数据状态。组件刷新需要获取到 root fiber 对象,那么root fiber又是如何拿到的呢?

    3. react-reconciler 里其实已经帮我们实现了,通过 injectIntoDevTools 接口暴露了一些hooks给 renderer (如 react-dom 和 react-native )。

    4. 对于自定义 renderer(如 hippy-react ),想使用fast-refresh功能,只需要在render函数中调用 injectIntoDevTools 接口即可

    3.3.2. Hippy React 热更新策略

    1. 对于函数式组件,可以保留状态
    2. 对于类组件不能保留状态,会进行组件粒度的刷新
    3. 如果编辑的模块在组件树内,则会只刷新组件
    4. 如果编辑的模块在组件树之外,HMR 会降级成热重载

    四、效果和接入

    Hippy Vue 实现效果:

    Hippy React 实现效果:


    接入方式请参考 Hippy 官网的指引:HMR & Live-Reload 能力。

    五、总结和展望

    本文详细分析了 Webpack 的热更新实现原理,通过分析其与 web 平台强相关的实现模块,并在 Hippy 运行环境下寻找替换解决方案,实现了 Hippy Vue & React 的热更新,相信可以极大地提高你的开发效率。本文的方案还存在一些优化空间,未来将在以下几方面进一步完善:

    1. 开发流程较为繁琐,需要启动 npm run hippy:dev , npm run hippy:debug 两步才行,未来将通过远程调试服务替代第二个命令;
    2. 更细粒度、覆盖面更广的热更新,如 store 层面的保留状态更新;
    3. 热更新错误、警告、进度等信息通过 overlay 覆盖在页面上面,并可以通过插件配置选项开启;
    4. 远程热更新:由于公司的网络隔离策略,iOS 真机调试时,需要切换体验网,开发上较为麻烦,可以通过将 HMR server 部署公网来解决这个问题。

    最后,给 Hippy 及 Tencent Dynamic Framework(TDF) 的调试工具打个广告,欢迎下载使用 @hippy/debug-server-next , 支持 UI Inspector , Source , Log , Memory 等调试能力,支持使用 chrome 调试 iOS,保证两端有一致的调试体验 。大家有任何 Hippy 调试相关的问题可以联系我们来优化解决。