背景

一个已经维护了六年之久的 webpack + react 项目。

在升级使用 react-refresh 作为热更新的实现之后,部分人的电脑上出现了热更新失效的问题,但另一些人的电脑上正常,且失效电脑上的表现为:

  • webpack 打印信息正常,可以看到更新的文件,但是 react 组件并没有被重新渲染
  • css 文件热更新正常

伪代码

html 入口

<!-- 立即执行代码 -->
<script src="/javascripts/iife.js"></script>
<!-- 业务入口 -->
<script src="/javascripts/main.js"></script>

webpack 配置

我们系统的 webpack 配置是多入口的,iifemain 都是一个单独的 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/babelreact-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 肯定有问题。

疑点出现

我们最近做了一些 webpackruntime 相关的实验,将 runtime 和另外一部分代码从主项目抽离,走单独的入口进行打包,最后在同一个项目里加载,和目前的 iifemain 入口的关系很类似,也打包了两份 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-runtimeperformReactRefresh 其实是正常调用的,这与大家的反馈一致:模块更新正常。

但是关键的一步:

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.

  }
});

发现 mountedRootsSnapshotsize 为 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;
  }
}

这是一段防重复注入的机制。其实到这儿就不难猜了:

  1. experimental_request_hook.js 里有一套 webpackruntime,并进行了 react-refresh 的注入。项目里 react 应用渲染时使用的 __REACT_DEVTOOLS_GLOBAL_HOOK__mountedRoots 正是此时注入的变量。
  2. main.js 加载之后,webpackruntime 被重新定义,react-refresh 也被加载到新的 runtime 里,但是由于 react-refresh 的防重复注入机制,劫持 __REACT_DEVTOOLS_GLOBAL_HOOK__ 相关的代码并没有再次执行。
  3. 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 runtimereact-refresh 注入的问题。

之前的疑问

为什么抽离 runtime 后,多套 react-refresh 不会有问题?

而在将 webpackruntime 抽离的情况下,只有一套 runtime,虽然 experimental_request_hook.jsmain.js 里有许多相同的模块代码,但 webpack 也有自己的防重复定义机制,这些模块并不会被重复定义,这个时候不论哪个 js 文件在前,mountedRoots 都会指向同一个变量,也就不存在热更新失效的问题了。