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 时,禁用默认的行为:

1
2
// `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

1
2
3
4
5
6
7
8
9
10
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,得到入口文件:

1
2
3
{
"main": "lib/index.js"
}

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

1
2
3
4
5
6
7
8
9
10
11
12
// 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,查看后发现如下代码:

1
2
3
4
5
6
7
8
9
10
11
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 发现:

1
this.options = normalizeOptions(options);

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

1
2
3
4
5
6
7
8
9
n(options, 'overlay', (overlay) => {
/** @type {import('../types').NormalizedErrorOverlayOptions} */
const defaults = {
entry: require.resolve('../../client/ErrorOverlayEntry'),
// ...
};

// ...
});

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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 的逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
// 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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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):

1
2
3
4
5
6
7
8
9
10
11
12
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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
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):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
/* 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 注入:

1
2
3
4
5
6
7
8
9
10
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 的开发者工具来保持脚本的活跃)。


webpack开发Chrome扩展时的热更新解决方案
https://tomzhu.site/2022/06/25/webpack开发Chrome扩展时的热更新解决方案/
作者
Tom Zhu
发布于
2022年6月26日
许可协议