背景
一个已经维护了六年之久的 webpack
+ react
项目。
在升级使用 react-refresh
作为热更新的实现之后,部分人的电脑上出现了热更新失效的问题,但另一些人的电脑上正常,且失效电脑上的表现为:
webpack
打印信息正常,可以看到更新的文件,但是react
组件并没有被重新渲染css
文件热更新正常
伪代码
html
入口
<!-- 立即执行代码 -->
<script src="/javascripts/iife.js"></script>
<!-- 业务入口 -->
<script src="/javascripts/main.js"></script>
webpack
配置
我们系统的 webpack
配置是多入口的,iife
和 main
都是一个单独的 entry
⬇️
module.exports = {
// ......
entry: devEntryFilter({
iife: [
'webpack-hot-middleware/client',
'./iife/index.js',
],
main: [
'webpack-hot-middleware/client',
'./main/index.js',
],
}),
// ......
plugins: [
new ReactRefreshWebpackPlugin({ /* ... */ }),
]
}
开发环境下,我们只需要开发业务,所以在 denEntryFilter
函数里只会把 main
入口打开,iife
会走线上,或者被代理掉,如果开发环境没用的话。
启动文件
const compiler = webpack(config);
app.use(require('webpack-dev-middleware')(compiler, { /* ... */ }));
app.use(require('webpack-hot-middleware')(compiler));
app.use(/\/stylesheets|\/javascripts/, function(req, res) {
res.end();
});
app.use(express.static(path.join(__dirname, 'server', 'public')));
开发环境下我们用了 express
,有兴趣的可以了解一下这个框架。
初步调查
观察表现:webpack
热更新信息正常,但是 react
组件热更新失效,且 css
热更新正常。
这说明热更新是打到前端了的,前端也接受了热更新,并通知到 webpack
更新成功,所以 webpack
提示正常,没有直接刷新页面。
因此先猜测是 react-refresh-webpack-plugin
的问题。
带着这个怀疑,去看了一下正常和异常用户的配置,发现异常用户的 webpack
配置里没有过滤掉 iife
,前端的入口文件里,请求到了开发环境下的 iife
的代码,而正常用户都过滤掉了 iife
,导致请求落到了 ⬇️
app.use(/\/stylesheets|\/javascripts/, function(req, res) {
res.end();
});
没有任何代码会被执行,恰巧这个 iife
是一段不影响业务的代码。而在异常用户里,把 iife
注释掉之后,热更新也表现正常了。
初步结论
先修复了问题,但这一步并不能验证之前的猜测,到底是 react-refresh-webpack-plugin
还是 webpack
的问题,还需要进一步调查。如果是 webpack
的问题,则可以优先怀疑多套 runtime
冲突。
现在已经确定到了触发问题的点,debug 就容易多了。
deep dive
先怀疑是 react-refresh-webpack-plugin
的问题。
如何验证?
在 devServer
里另起一个 compiler
,只打包 iife
,并且去掉 react-refresh/babel
和 react-refresh-webpack-plugin
。
代码:
const compiler = webpack(config);
// iife_config 只有 iife 入口,并且去掉了 react-refresh 相关代码
const compiler2 = webpack(iife_config);
app.use(require('webpack-dev-middleware')(compiler, { /* ... */ }));
app.use(require('webpack-hot-middleware')(compiler));
app.use(require('webpack-dev-middleware')(compiler2, { /* ... */ }));
app.use(require('webpack-hot-middleware')(compiler2));
这样发现热更新没问题,所以可以确定 react-refresh-webpack-plugin
肯定有问题。
疑点出现
我们最近做了一些 webpack
的 runtime
相关的实验,将 runtime
和另外一部分代码从主项目抽离,走单独的入口进行打包,最后在同一个项目里加载,和目前的 iife
与 main
入口的关系很类似,也打包了两份 react-refresh
。但那个试验里并没有热更新问题,为什么呢?
刚开始怀疑是资源加载的顺序问题,于是尝试把 iife
的加载放在 main.js
后试一下。
第一次尝试
把 iife
后置之后,因为 runtime
的问题,热更新直接报错。拆出去 runtime
后再试一下。
拆完之后,runtime
走单独的 script
标签加载,发现如果 runtime
拆出去,那么 iife
无论放在哪个位置热更新都是没问题的。这样可以排除 react-refresh-webpack-plugin
单独作用导致的问题。因此再做出一个大胆的结论:
react-refresh-webpack-plugin
和多套 webpack
runtime
绑定的问题。
deeper dive
先确定目前项目里的 react-refresh-webpack-plugin
是什么情况,接着翻代码,打断点调试。
发现 react-refresh-runtime
里 performReactRefresh
其实是正常调用的,这与大家的反馈一致:模块更新正常。
但是关键的一步:
mountedRootsSnapshot.forEach(function (root) {
var helpers = helpersByRootSnapshot.get(root);
if (helpers === undefined) {
throw new Error('Could not find helpers for a root. This is a bug in React Refresh.');
}
if (!mountedRoots.has(root)) {// No longer mounted.
}
try {
helpers.scheduleRefresh(root, update);
} catch (err) {
if (!didError) {
didError = true;
firstError = err;
} // Keep trying other roots.
}
});
发现 mountedRootsSnapshot
的 size
为 0,按理说我们的应用渲染之后就会被加进这个变量里面的。
再来看 react-refresh-webpack-plugin
注入到入口文件的代码:
const safeThis = require('./utils/safeThis');
if (process.env.NODE_ENV !== 'production' && typeof safeThis !== 'undefined') {
// Only inject the runtime if it hasn't been injected
if (!safeThis.__reactRefreshInjected) {
const RefreshRuntime = require('react-refresh/runtime');
// Inject refresh runtime into global scope
RefreshRuntime.injectIntoGlobalHook(safeThis);
// Mark the runtime as injected to prevent double-injection
safeThis.__reactRefreshInjected = true;
}
}
这是一段防重复注入的机制。其实到这儿就不难猜了:
experimental_request_hook.js
里有一套webpack
的runtime
,并进行了react-refresh
的注入。项目里react
应用渲染时使用的__REACT_DEVTOOLS_GLOBAL_HOOK__
和mountedRoots
正是此时注入的变量。main.js
加载之后,webpack
的runtime
被重新定义,react-refresh
也被加载到新的runtime
里,但是由于react-refresh
的防重复注入机制,劫持__REACT_DEVTOOLS_GLOBAL_HOOK__
相关的代码并没有再次执行。webpack
进行热更新时,因为闭包的原因,mountedRootsSnapshot
这个变量寻找的是之后加载的runtime
里定义的模块里的变量,长度为 0,直接跳过更新阶段,这就是为什么模块更新正常,但是组件没有正常更新的原因。
验证
将 react-refresh
注入到入口文件的代码里的全局变量修改为闭包变量后,main.js
加载时 __REACT_DEVTOOLS_GLOBAL_HOOK__
被重新劫持,热更新即可正常使用。
const safeThis = require('./utils/safeThis');
let __reactRefreshInjected = false;
if (process.env.NODE_ENV !== 'production' && typeof safeThis !== 'undefined') {
// Only inject the runtime if it hasn't been injected
if (!__reactRefreshInjected) {
const RefreshRuntime = require('react-refresh/runtime');
// Inject refresh runtime into global scope
RefreshRuntime.injectIntoGlobalHook(safeThis);
// Mark the runtime as injected to prevent double-injection
__reactRefreshInjected = true;
}
}
到这儿就可以判定是多套 webpack
runtime
和 react-refresh
注入的问题。
之前的疑问
为什么抽离 runtime 后,多套 react-refresh 不会有问题?
而在将 webpack
的 runtime
抽离的情况下,只有一套 runtime
,虽然 experimental_request_hook.js
和 main.js
里有许多相同的模块代码,但 webpack
也有自己的防重复定义机制,这些模块并不会被重复定义,这个时候不论哪个 js 文件在前,mountedRoots
都会指向同一个变量,也就不存在热更新失效的问题了。