背景
一个已经维护了六年之久的 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 都会指向同一个变量,也就不存在热更新失效的问题了。