webpack开发Chrome扩展时的热更新解决方案

用 webpack 开发 Chrome 扩展时,我们会遇到一些热更新(HMR)的问题。碍于 Chrome 的安全策略限制,background 和 content script 无法使用常规的 HMR 方案。如果直接启用 HMR,会给不适用的 chunk 也加入 HMR 的 modules,造成不必要的报错信息。因此,我们需要有选择性地给 chunk 启用 HMR。另外,我们还需要给 background 和 content script 启用一个 HMR 的替代方案。

webpack-dev-server

官网上有说明,启动 webpack-dev-server 时,禁用默认的行为:

// `hot` and `client` options are disabled because we added them manually
const server = new webpackDevServer({ hot: false, client: false }, compiler);

在配置 chunk 时,则需要手动加上 HMR modules。同时,由于 Chrome 扩展的页面都是直接通过 Chrome 访问(均为 chrome:// 开头),并不通过 webpack-dev-server 访问,我们还需要手动指定 hostnameport

const config = {
  entry: [
    // Runtime code for hot module replacement
    'webpack/hot/dev-server.js',
    // Dev server client for web socket transport, hot and live reload logic
    `webpack-dev-server/client/index.js?hostname=${env.HOST}&port=${env.PORT}&hot=true`,
    // Your entry
    './src/index.js',
  ],
};

react-refresh

@pmmmwh/react-refresh-webpack-plugin 这个包就可以在 webpack 中使用 react-refresh 了。

根据官方文档,需要先启用 webpack-dev-serverhot 选项。由于前面我们已经有选择性地对 chunk 启用了 HMR,我们可以跳过这个步骤。

但是如何有选择性地对 chunk 添加 react-refresh modules 呢?可以知道的是,是 ReactRefreshPlugin 自动注入了相关 modules,因此我们查看 @pmmmwh/react-refresh-webpack-pluginpackage.json,得到入口文件:

{
  "main": "lib/index.js"
}

打开 lib/index.js,发现如下代码:

// Inject react-refresh context to all Webpack entry points.
// This should create `EntryDependency` objects when available,
// and fallback to patching the `entry` object for legacy workflows.
const addEntries = getAdditionalEntries({
    devServer: compiler.options.devServer,
    options: this.options,
});
if (EntryPlugin) {
    // ...
} else {
    // ...
}

可以知道是由 getAdditionalEntries() 函数返回了需要注入的 modules。可以得知 getAdditionalEntries() 函数定义位于 lib/utils/getAdditionalEntries.js,查看后发现如下代码:

const prependEntries = [
// React-refresh runtime
require.resolve('../../client/ReactRefreshEntry'),
];

const overlayEntries = [
// Error overlay runtime
options.overlay &&
    options.overlay.entry &&
    `${require.resolve(options.overlay.entry)}${queryString ? `?${queryString}` : ''}`,
].filter(Boolean);

可以知道注入了两个 modules:一个是主功能,路径为 client/ReactRefreshEntry.js;另一个是错误显示,路径为 options.overlay.entry,并带有 query string。这里的 options 是调用 getAdditionalEntries() 时传入的,回到 lib/index.js 发现:

this.options = normalizeOptions(options);

normalizeOptions() 函数位于 lib/utils/normalizeOptions.js,查看代码发现:

n(options, 'overlay', (overlay) => {
  /** @type {import('../types').NormalizedErrorOverlayOptions} */
  const defaults = {
    entry: require.resolve('../../client/ErrorOverlayEntry'),
    // ...
  };

  // ...
});

可以得知默认情况下,错误显示 module 的路径为 client/ErrorOverlayEntry。不过,它还需要 query string,这是在 getAdditionalEntries() 函数中定义的,查看发现如下代码:

if (devServer) {
  const { sockHost, sockPath, sockPort, host, path, port, https, http2 } = devServer;

  (sockHost || host) && (resourceQuery.sockHost = sockHost ? sockHost : host);
  (sockPath || path) && (resourceQuery.sockPath = sockPath ? sockPath : path);
  (sockPort || port) && (resourceQuery.sockPort = sockPort ? sockPort : port);
  resourceQuery.sockProtocol = https || http2 ? 'https' : 'http';
}

// ...

// We don't need to URI encode the resourceQuery as it will be parsed by Webpack
const queryString = querystring.stringify(resourceQuery, undefined, undefined, {
  /**
   * @param {string} string
   * @returns {string}
   */
  encodeURIComponent(string) {
    return string;
  },
});

可以知道默认是从 webpack-dev-server 配置中读取选项,由于我们没有配置,则需要在 query string 中手动指定。查看 sockets/utils/getSocketUrlParts.js 可以知道,sockPathsockProtocol 两个选项采用默认值即可。我们只需要补全 sockHostsockPort

因此,首先我们注释掉 lib/index.js 中注入 modules 的逻辑:

// Inject react-refresh context to all Webpack entry points.
// This should create `EntryDependency` objects when available,
// and fallback to patching the `entry` object for legacy workflows.
// const addEntries = getAdditionalEntries({
//   devServer: compiler.options.devServer,
//   options: this.options,
// });
// if (EntryPlugin) {
//   // ...
// } else {
//   // ...
// }

按照官方文档,加入 plugin 和 loader:

module.exports = {
  mode: isDevelopment ? 'development' : 'production',
  module: {
    rules: [
      {
        test: /\.[jt]sx?$/,
        exclude: /node_modules/,
        use: [
          {
            loader: require.resolve('babel-loader'),
            options: {
              plugins: [isDevelopment && require.resolve('react-refresh/babel')].filter(Boolean),
            },
          },
        ],
      },
    ],
  },
  plugins: [isDevelopment && new ReactRefreshWebpackPlugin()].filter(Boolean),
};

然后给适用的 chunk 手工加入 modules(别忘了 webpack-dev-server 的 modules):

const config = {
  entry: [
    // Runtime code for hot module replacement
    'webpack/hot/dev-server.js',
    // Dev server client for web socket transport, hot and live reload logic
    `webpack-dev-server/client/index.js?hostname=${env.HOST}&port=${env.PORT}&hot=true`,
    "@pmmmwh/react-refresh-webpack-plugin/client/ReactRefreshEntry",
    `@pmmmwh/react-refresh-webpack-plugin/client/ErrorOverlayEntry?sockHost=${env.HOST}&sockPort=${env.PORT}`,
    // Your entry
    './src/index.js',
  ],
};

这样就可以了!

background 和 content script 的替代方案

由于是 content script 无法连接 devServer,采用 devServer -> background -> content script 的方案。

webpack-dev-server 增加一个 middleware:

const server = new WebpackDevServer({
  setupMiddlewares: (middlewares, devServer) => {
    middlewares.push({
      name: "crx-helper",
      path: "/crx_helper",
      middleware: (req, res) => {
        // SSE implementation from https://stackoverflow.com/a/59041709.
        res.setHeader("Cache-Control", "no-cache");
        res.setHeader("Content-Type", "text/event-stream");
        res.setHeader("Access-Control-Allow-Origin", "*");
        res.setHeader("Connection", "keep-alive");
        res.flushHeaders(); // flush the headers to establish SSE with client

        let closed = false;
        devServer.compiler.hooks.done.tap("crx-helper", (stats) => {
          if (closed) {
            return;
          }
          const compiledNames = stats
            .toJson({ all: false, modules: true })
            .modules
            .filter(i => i.name !== undefined)
            .map(i => i.name);
          const compiledChunks = stats
            .toJson()
            .modules
            .filter(i => compiledNames.includes(i.name))
            .map(i => i.chunks)
            .reduce((previousValue, currentValue) => previousValue.concat(currentValue), []);
          const isBackgroundUpdated = !stats.hasErrors() && compiledChunks.some((chunk) => entriesOfBackground.includes(chunk));
          const isContentScriptsUpdated = !stats.hasErrors() && compiledChunks.some((chunk) => entriesOfContentScripts.includes(chunk));
          let data;
          if (isBackgroundUpdated) {
            data = { type: "backgroundUpdates" };
          } else if (isContentScriptsUpdated) {
            data = { type: "contentScriptUpdates" };
          }
          if (data) {
            res.write(`data: ${JSON.stringify(data)}\n\n`); // res.write() instead of res.send()
            // Call `res.flush()` to actually write the data to the client.
            // See https://github.com/expressjs/compression#server-sent-events for details.
            res.flush();
          }
        });
        res.on("close", () => {
          closed = true;
          res.end();
        });
      }
    });
    return middlewares;
  }
}, compiler);

给 background 注入(可能需要提供 query string):

/* global __resourceQuery */
const options = {
  protocol: "http",
  host: "localhost",
  port: 3000,
  path: "/crx_helper"
};
if (__resourceQuery) {
  const querystring = require("querystring");
  const overrides = querystring.parse(__resourceQuery.slice(1));
  overrides.protocol && (options.protocol = overrides.protocol);
  overrides.host && (options.host = overrides.host);
  overrides.port && (options.port = overrides.port);
  overrides.path && (options.path = overrides.path);
}

const source = new EventSource(`${options.protocol}://${options.host}:${options.port}${options.path}`);
source.addEventListener("open", () => {
  console.log("[crx-helper] Connected to devServer.");
});
source.addEventListener("error", () => {
  console.error("[crx-helper] Failed to connect to devServer.");
});
source.addEventListener("message", (event) => {
  const data = JSON.parse(event.data);
  if (data.type === "backgroundUpdates") {
    console.log("[crx-helper] Detected background updates, reloading extension...");
    source.close();
    chrome.runtime.reload();
  } else if (data.type === "contentScriptUpdates") {
    console.log("[crx-helper] Detected content script updates, reloading pages...");
    chrome.tabs.query({ active: true }, (tabs) => {
      tabs.forEach((tab) => {
        chrome.tabs.sendMessage(tab.id, { type: "contentScriptUpdates" });
      });
      console.log("[crx-helper] Reloading extension...");
      source.close();
      chrome.runtime.reload();
    });
  }
});

给 content script 注入:

chrome.runtime.onMessage.addListener((message) => {
  if (message.type === "contentScriptUpdates") {
    console.log("[crx-helper] Detected content script updates, reloading pages...");
    // Wait until extension is reloaded
    setTimeout(() => {
      window.location.reload();
    }, 1000);
  }
});
console.log("[crx-helper] Started to listen for content script updates.");

这样就可以实现自动重载扩展和刷新页面了(不过可能需要打开 background 的开发者工具来保持脚本的活跃)。