React 项目中 热更新 的设计取舍
从一次莫名其妙的整页刷新说起
前几天改项目里一个表单页面,字段填了二十多个,然后想微调一下按钮颜色,保存的一瞬间整个页面直接刷新了,填的东西全没了。当时第一反应是热更新挂了,结果打开控制台一看,HMR 明明连着,日志里还写了 update applied。也就是说这次不是“没热更”,而是它主动选择了整页刷新。为什么改一行样式会走到整页刷新,这玩意儿的判断逻辑是什么,我查了一下午,顺手把结论记在这里。
热更新到底在替换什么
先纠正一个我自己的误解。以前我以为热更新就是“保存代码,页面自动变”,其实不是。以 Webpack 的 HMR 为例,它做的事情是:文件变了,重新编译这个模块,然后在运行时把旧模块替换掉。注意是替换模块,不是刷新页面,所以理论上接口返回的数据、页面滚到的位置都应该还在。
但 React 组件有个特殊之处:组件函数每次渲染都会重新执行,光把模块换了没有意义,还得把旧组件内部的 state 搬到新组件上。这个活儿是 React Fast Refresh 干的。它会在编译阶段给每个组件函数打上标记,运行时替换模块过后,找到旧组件对应的 hook 链表,按顺序搬到新组件上。所以你改一个组件的 JSX,保存之后输入框里的字还在,就是这个机制在起作用。Vite 底下也是同一套 Fast Refresh,只是实现换成了 @vitejs/plugin-react 而已。
状态不是想保就能保
Fast Refresh 保 state 是有条件的,而且有些场景它是故意不保的。最典型的是 useEffect:热更新过后 effect 会被清理掉然后重新执行,但 useState 和 useRef 的值会保留。为什么这么设计?因为 effect 里经常有订阅、定时器、请求这类副作用,如果更新过后不重新执行,你看到的界面就是用旧逻辑跑出来的,bug 会藏得更深。所以官方的取舍是:数据可以留,副作用必须重跑。反过来说,这也逼着你把 effect 写得能安全地重复执行,我觉得不算坏事。
另一个容易踩的坑是组件文件里混了非组件的导出,比如这样:
export const MAX_COUNT = 10
export function Counter() {
// ...
}
看起来没什么问题,但 Fast Refresh 没办法确定 MAX_COUNT 有没有被别的模块在初始化时用到,也没办法只更新常量不动组件,于是它直接放弃,整页刷新。我开头那次白屏就是这个原因——那个文件里除了组件还导出了一个常量。解决办法没什么技术含量,把常量挪到单独的文件里就行,但架不住项目里这种文件一抓一大把。
粒度的取舍:barrel file 的代价
我们项目里每个模块都有 index.js 做统一导出,import 写起来确实干净。但这对热更新是灾难性的:你改了 Counter 组件,构建工具沿着依赖找更新边界,发现它是从 index.js re-export 出去的,于是把整个 barrel file 连带它引的所有东西都标记失效,更新范围瞬间扩大,经常直接退化成整页刷新。
这里就有一个很现实的取舍:要么为了 import 语句好看牺牲热更新粒度,要么为了热更新体验放弃 barrel file。我们最后选了后者,深层组件直接写完整路径,配合 IDE 的自动补全,其实也没觉得多麻烦,不然你就等着每次改一行代码都重填一遍表单吧。另外建议装一下 eslint-plugin-react-refresh,把 only-export-components 这条规则打开,谁在组件文件里乱导出就报谁,比等人肉排查强多了:
// .eslintrc.js
{
plugins: ['react-refresh'],
rules: {
'react-refresh/only-export-components': 'warn',
},
}
为什么不能全都保住
写到这儿我一直在想一个问题:既然 state 能搬,为什么遇到不确定的情况就整页刷新,不能无脑全保吗?后来想明白了,这本质上是个正确性和便利性的取舍。热更新最怕的不是状态丢了,而是状态错了——你看到的界面和实际运行的代码对不上,这种 bug 排查起来比重新填一遍表单痛苦十倍。所以 Fast Refresh 的策略是保守的:能确定安全就保,不能确定就刷新,宁可让你重填表单,也不给你一个看起来正常但内部已经错乱的页面。
还有一个没法保的场景是 class 组件,它没有 hook 链表可搬,Fast Refresh 对它只能重挂载,state 直接丢。官方也没打算补这块,毕竟新代码都写函数组件了,与其在旧机制上打补丁,不如把精力放在函数组件的体验上,这大概也是一种取舍。
一些实测下来的建议
最后总结几条踩坑之后觉得值得做的:组件文件里只放组件,常量、工具函数、自定义 hook 都拆出去;能不用 barrel file 就不用,至少组件目录下别用;eslint 那条规则建议项目一开始就配上,等代码堆起来了再治理成本很高;遇到热更新行为不符合预期,先看控制台有没有 [HMR] 相关日志,再检查文件里有没有奇怪的导出,九成是导出的问题。这几条做完之后,不出意外的话,改个样式就不会再整页刷新了。
热更新这东西,平时感觉不到它存在,一旦失效才知道有多依赖它。而且它失效的方式往往不是报错,而是安安静静给你刷个页,你甚至不会意识到这是可以修的。希望我这一下午的排查,能帮你省下这一下午。
评论
还没有评论。
发表评论
提交后评论将经过自动审核,审核通过后公开展示。