用 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 const server = new webpackDevServer ({ hot : false , client : false }, compiler);
在配置 chunk 时,则需要手动加上 HMR modules。同时,由于 Chrome 扩展的页面都是直接通过 Chrome 访问(均为 chrome://
开头),并不通过 webpack-dev-server
访问,我们还需要手动指定 hostname
和 port
:
1 2 3 4 5 6 7 8 9 10 const config = { entry : [ 'webpack/hot/dev-server.js' , `webpack-dev-server/client/index.js?hostname=${env.HOST} &port=${env.PORT} &hot=true` , './src/index.js' , ], };
react-refresh 用 @pmmmwh/react-refresh-webpack-plugin
这个包就可以在 webpack 中使用 react-refresh
了。
根据官方文档 ,需要先启用 webpack-dev-server
的 hot
选项。由于前面我们已经有选择性地对 chunk 启用了 HMR,我们可以跳过这个步骤。
但是如何有选择性地对 chunk 添加 react-refresh
modules 呢?可以知道的是,是 ReactRefreshPlugin
自动注入了相关 modules,因此我们查看 @pmmmwh/react-refresh-webpack-plugin
的 package.json
,得到入口文件:
1 2 3 { "main" : "lib/index.js" }
打开 lib/index.js
,发现如下代码:
1 2 3 4 5 6 7 8 9 10 11 12 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 = [require .resolve ('../../client/ReactRefreshEntry' ), ];const overlayEntries = [ 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 ) => { 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' ; }const queryString = querystring.stringify (resourceQuery, undefined , undefined , { encodeURIComponent (string ) { return string; }, });
可以知道默认是从 webpack-dev-server
配置中读取选项,由于我们没有配置,则需要在 query string 中手动指定。查看 sockets/utils/getSocketUrlParts.js
可以知道,sockPath
和 sockProtocol
两个选项采用默认值即可。我们只需要补全 sockHost
和 sockPort
。
因此,首先我们注释掉 lib/index.js
中注入 modules 的逻辑:
按照官方文档 ,加入 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 : [ 'webpack/hot/dev-server.js' , `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} ` , './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 ) => { 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 (); 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.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 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..." ); setTimeout (() => { window .location .reload (); }, 1000 ); } });console .log ("[crx-helper] Started to listen for content script updates." );
这样就可以实现自动重载扩展和刷新页面了(不过可能需要打开 background 的开发者工具来保持脚本的活跃)。