react 应用中的模块热更新

前言

图片来源:Live-editing React app without refresh

当我们开发一个 React 应用时,修改一个组件源码保存后,页面自动更新成最新的效果。在这个功能背后,你是否有过这样的疑问:

  1. 页面自动更新的过程有时是重载整个页面,有时没有重载,这是为什么呢?
  2. 没有重载时,我们修改的组件是如何完成源码更新的?

如果你恰好有以上疑问,请继续阅读,本文将阐述本地开发时, Webpack 中的模块热替换是如何运作的,以及 React 组件是如何完成更新的。

Webpack 模块热替换

Webpack 模块热替换是什么呢?我们看看 Webpack 对其的定义是:

模块热替换(HMR – hot module replacement)功能会在应用程序运行过程中,替换、添加或删除模块,而无需重新加载整个页面。主要是通过以下几种方式,来显著加快开发速度:

  • 保留在完全重新加载页面期间丢失的应用程序状态。
  • 只更新变更内容,以节省宝贵的开发时间。
  • 在源代码中 CSS/JS 产生修改时,会立刻在浏览器中进行更新,这几乎相当于在浏览器 devtools 直接更改样式。

后文以「HMR」指代模块热替换。

总结一下:

  1. HMR 会在源码产生变更时,替换、添加或删除变更的模块,并保留应用程序状态。
  2. HMR 只在浏览器中更新变更内容。
  3. 基于以上两点,能显著加快开发速度。

这里解释一下什么是模块:这里的模块指程序片段,它可以是一个 JavaScript 模块(比如 ES Module 或者 Commonjs 模块等等)、WebAssembly 模块以及通过 Loader 支持的多种语言和预处理器语法编写的模块(比如 Less 模块、图片)等。

接下来,我们进一步了解 HMR 中的四个角色。

HMR 中的四个角色

上图描述了HMR Server、HMR Runtime、编译器、模块四个角色的关系,首先我们来看看 HMR Server 是什么。

HMR Server

HMR Server 运行在服务端,也就是我们本地的 Webpack Dev Server 中,它主要负责和编译器、 HMR Runtime 进行交互。

当 Webpack Dev Server 启动时,会在本地创建一个 WebSocket 服务器,HMR Server 会通过 Web Socket 跟 HMR Runtime 进行通信。总的来说,HMR Sever 会干这几件事:

  1. 当模块有变更时,通知 HMR Runtime 检查更新。
  2. HMR Runtime 收到通知后,异步地下载更新,然后通知 HMR Server。
  3. 通知 HMR Runtime 应用更新。
  4. HMR Runtime 收到通知后,同步的应用更新。

检查更新和同步更新分别对应了 HMR Runtime 中的 check 和 apply 方法。在 HMR Runtime 小节会说到,现在我们只需要关心 HMR Server 做了什么。

编译器

编译器的工作是:当文件发生变更,编译器重新进行编译,产出最新的 mainfest 和需要更新的模块信息。

mainfest 包含了新的 complilation hash(官方文档中没有解释,这里可以理解为本次编译产出的 hash 值),还有应用中模块的标识符和模块的映射关系。

模块

我们可以在模块中实现 HMR 接口,通过实现接口,可以自定义处理模块更新后的逻辑。如果更新的模块中没有实现 HMR 接口,更新会进行冒泡,当一个模块的更新冒泡到模块树顶端(应用的入口文件)而没被接收的话,则会引起页面的刷新。

通常,我们不用关心实现 HMR 接口,Webpack Loader 会帮我们处理。比如 style-loader 会为样式模块追加补丁实现 HMR 接口,完成新旧样式的替换。

HMR Runtime

HMR Server 和 HMR Runtime 是模块热替换的两个核心,HMR server 运行在服务端(Webpack Dev Server),HMR Runtime 运行在客户端(浏览器)。

在 Runtime 中,会请求 HMR server 的 WebSocket Server 建立 socket 连接,当模块有更新时通知 Runtime。

Runtime 中有两个重要的方法:

  • check 方法:发送一个请求来更新 manifest ,如果请求失败,说明没有可用更新。如果请求成功,则会将响应中的需要更新的模块列表与当前已经加载的模块列表进行比较,每个已经加载的模块都会下载响应的最新的 模块。当所有需要更新的模块下载完成时,调用 apply 方法。

  • apply 方法:apply方法的逻辑比较复杂,我们现在只需要关心三件事:

    1. 对需要更新的模块进行冒泡。
    2. 对更新后无效的模块进行解除。
    3. 更新当前 hash(complilation hash)并调用所有的 accept 回调。

至此,再回头看上面的图片,你能解释从头到尾叙述四个角色是如何协作的吗?

对 HMR 有了认识之后,接下来,我们将进入本文章的第二个子主题:React 组件是如何完成更新的。

React 应用中组件的热更新

我们会通过四个例子一步步解开 React 组件热更新的面纱。

提示:打开 demo 地址,按照 README.md 的操作步骤进行测试。

例子 1:在纯 JavaScript 应用中,实现 HMR 接口

在线地址:https://stackblitz.com/edit/webpack-webpack-js-org-ghfuh5

在这个例子中,我们知道模块的依赖关系是:

image.png

当我们修改 message.js 中的 message 变量值为 hello~12345并保存,此时,Webpack Dev Server 监听到文件内容变更,通知编译器重新编译。

同时,HMR Server 通过 WebSocket 告知 HMR Runtime 需要检查更新,HMR Runtime 发起一个 HTTP 请求,获取变更的模块信息,如下图所示:

image.png

此时我们在响应中得知 appruntime 需要更新,HMR Runtime 异步下载需要更新的模块。

之所以 runtime 会有更新是因为应用的 HASH(complilation hash)发生了变更,需要更新,这里可以忽略,我们重点看看 app.xxx.hot-update.js 的内容:

image.png

可以看到,只有 message 模块的内容需要更新,message 的值为 hello~12345

一切准备就绪后,HMR Server 通过 WebSocket 通知 HMR Runtime 可以同步应用更新。HMR Runtime 将最新的 message 模块进行替换,将老的 message 模块解绑,最后更新应用 Hash,并开始以更新的模块为起点向上进行冒泡。

冒泡会找到最近注册了 HMR 接口的模块。因为 print.js 模块没有注册 HMR 接口,则继续向上冒泡,index.js 中注册了 HMR 接口,调用 accept 回调。

image.png

回调中,重新创建了元素并进行了替换,此时我们再点击按钮,就会 alert hello~12345

总结:在第一个例子中,我们以实际的例子体会了 HMR 中四个角色协作的过程,并实现了 HMR 接口,实现了无刷新更新的效果。

你还可以通过 README.md 中的第 5、6 步体会更新是如何冒泡的。接下来,我们来实现 React 组件的热更新。

例子 2:在 React 应用中,实现 HMR 接口

在线地址:https://stackblitz.com/edit/webpack-webpack-js-org-katj1d

在例子 2 中,我们发现虽然我们实现了 React 组件的热更新,但是输入框的状态丢失了,这是为什么呢?

在例子 1 中,我们知道 HMR Runtime 异步加载最新的模块之后,会进行模块的替换,然后才会进行更新的冒泡,调用 App.jsx 中的 accept 回调。

image.png

accept 回调中,App 组件已经更新,此时 App 组件这个函数的引用和更新前的 App 组件的函数引用已经不相等,调用 ReactDOM.render 方法后,最后会进入 React Diff 的单节点对比,如图:

image.png

可以看到更新前的 App 对应的 Fiber 节点的 type 值和更新后的 App 对应的 JSX element type 值虽然都是函数,但是引用不相等。因此会将老的 App 卸载,挂载更新后的 App 组件,导致状态丢失。

那么如何避免组件状态丢失呢,我们来看例子 3。

例子 3:如何避免 React 组件状态丢失

在线地址:https://stackblitz.com/edit/webpack-webpack-js-org-rbvkv1

从例子 2 中,我们知道模块更新后,组件的引用发生了变更,导致组件状态丢失,那么反过来想,想要状态不丢失,我们至少得让组件不被卸载。

在例子 3 的 hot-update.js 中,我们实现了一个代理组件的 HOC,变量 app 用来存放被代理的组件。

image.png

当我们更新 App.jsx 中的 this is title: title: ,模块更新后,accept 回调被调用,app.current 被设置为更新后的 App 组件,在 React Diff 阶段,因为 child.elmentTyleelement.type 同为 InnerComp 组件,InnerComp 组件没有被卸载,并重新调用了 app.current(...arg),也就是更新的 App 组件(函数)被执行,最终,this is title: 这个 TextNode 被更新为 title:

Kapture 2022-10-31 at 10.45.53.gif

可以看到,输入框右侧的文案发生变化,而输入框的值没有丢失。

以上,我们就避免了 React 组件状态的丢失。

例子 4: React Hot Loader 是如何实现组件更新的

在线地址:https://stackblitz.com/edit/webpack-webpack-js-org-zxeyke

在例子 4 中,我们在 webpack.config.js 的 loader 中增加了 react-hot-loader 来观察它是如何实现的组件状态保持的。我们修改 App.jsx 中的内容,在 React Diff 阶段我们看到

image.png

老的 Fiber 节点的 elementType 和新的 JSX element type 属性同指向 AppContainer 这个组件,其实现复杂很多,事实也是如此,如 Dan 在 My Wishlist for Hot Reloading 中所说,React 的热更新有许多关注点、原则乃至给开发者的反馈需要考虑和衡量,此文不再赘述。

申明:本文例子 4 中使用的 React-Hot-Loader 目前已不是最新的 React 热更新库,想要了解最新的库请移步 React Fast Refresh

参考

  • https://webpack.js.org/concepts/hot-module-replacement
  • https://overreacted.io/my-wishlist-for-hot-reloading/
  • https://medium.com/@dan_abramov/hot-reloading-in-react-1140438583bf
  • https://blog.bitsrc.io/webpacks-hot-module-replacement-feature-explained-43c13b169986
  • https://gaearon.github.io/react-hot-loader
  • https://codeburst.io/react-hot-loader-considered-harmful-321fe3b6ca74
© 版权声明
THE END
喜欢就支持一下吧
点赞5 分享
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

取消
昵称表情代码图片

    暂无评论内容