# 浏览器扩展开发的相关问题与解决方案 我开发的浏览器扩展安装量终于过千了!在 [Firefox AddOns](https://addons.mozilla.org/en-US/firefox/addon/force-copy/) 已经有`1.7k+`安装,在 [Chrome WebStore](https://chromewebstore.google.com/detail/force-copy/cceclgeciefpanebkfkogecbjjchmico) 已经有`1k+`安装。实际上在`Firefox`的扩展市场里是周平均安装量,当天的实际安装量要高出平均值不少,而`Chrome`的扩展市场在超过`1k`安装量之后就不精确显示安装量了,实际安装量也会高于`1k`。 实际上在我做扩展之前,我是实现了脚本来处理相关的功能的,脚本在 [GreasyFork](https://greasyfork.org/zh-CN/scripts/405130) 上有 `2688k+`安装量,而实现扩展的主要原因有两个: 一个原因是我也想学习一下扩展的开发,我发现在工作中真的会有应用场景,特别是要突破浏览器限制做一些特殊工作的情况下;另一个原因是我发现有将我打包发布在`GreasyFork`上的`GPL`协议的代码直接封装成插件并加入了广告,就这竟然还有`400k+`安装量。 因此我也基于脚本能力实现了浏览器拓展,并且是主要为了学习的情况下,我从零搭建了整个开发环境,也在处理了很多兼容性方案,接下来我们就来聊聊相关问题与解决方案。项目地址 [GitHub](https://github.com/WindrunnerMax/TKScript) ,如果觉得不错,点个`star`吧 😁 。
浏览器扩展开发系列相关文章 * [从零实现的 Chrome 扩展](./从零实现的Chrome扩展.md) * [从零实现的浏览器 Web 脚本](./从零实现的浏览器Web脚本.md) * [基于 Chrome 扩展的浏览器事件](./基于Chrome扩展的浏览器事件.md) * [从脚本管理器的角度审视 Chrome 扩展](./从脚本管理器的角度审视Chrome扩展.md) * [浏览器扩展开发的相关问题与解决方案](./浏览器扩展开发的相关问题与解决方案.md)
## 扩展打包方案 我们在先前提到了在这里是从零搭建的开发环境,那么我们就需要挑选一个扩展打包工具,在这里我选用的是`rspack`,当然如果我们使用`webpack`或者是`rollup`都是没问题的,只是用`rspack`比较熟悉且打包速度比较快,无论是哪种打包器都是类似的配置。此外,在这里实际上我们是使用的`build`级别的打包方式,类似于`devserver`的方案在`v3`中目前并不太适用。 那么需要注意的是,在浏览器扩展中我们需要定义多个入口文件,并且需要单文件的打包方案,不能出现单入口多个`chunk`,包括`CSS`我们也需要打包为单入口单输出,并且输出的文件名也不要带`hash`的后缀,防止文件找不到的情况。不过这并不是什么比较大的问题,在配置文件中注意即可。 ```js module.exports = { context: __dirname, entry: { popup: "./src/popup/index.tsx", content: "./src/content/index.ts", worker: "./src/worker/index.ts", [INJECT_FILE]: "./src/inject/index.ts", }, // ... output: { publicPath: "/", filename: "[name].js", path: path.resolve(__dirname, folder), }, } ``` 在这里可以发现`INJECT_FILE`的输出文件名是个动态的,在这里由于`inject`脚本是需要注入到浏览器页面上的,由于注入方案的关系,在浏览器页面上就可能发生冲突,所以在这里我们每次`build`生成的文件名都是不一致的,在每次发布后文件名都会更改,包括模拟的事件通信方案也是一致随机名。 ```js const EVENT_TYPE = isDev ? "EVENT_TYPE" : getUniqueId(); const INJECT_FILE = isDev ? "INJECT_FILE" : getUniqueId(); process.env.EVENT_TYPE = EVENT_TYPE; process.env.INJECT_FILE = INJECT_FILE; // ... module.exports = { context: __dirname, builtins: { define: { "__DEV__": JSON.stringify(isDev), "process.env.EVENT_TYPE": JSON.stringify(process.env.EVENT_TYPE), "process.env.INJECT_FILE": JSON.stringify(process.env.INJECT_FILE), // ... } } // ... } ``` ## Chrome与Firefox兼容 `Chrome`一直在强推扩展的`V3`版本,也就是`manifest_version`需要标记为`3`,而在`Firefox`中提交`manifest_version: 3`的版本会得到不建议使用的提示。实际上对于个人而言我也不喜欢使用`v3`版本,限制特别多,很多功能都没有办法正常实现,这点我们后边再聊。那么既然`Chrome`强制性用`v3`,`Firefox`推荐用`v2`,那么我们就需要分别在`Chromium`内核和`Gecko`内核中实现兼容方案。 实际上我们可以发现这是不是很像多端构建的场景,也就是我们需要将同一份代码在多个平台打包。那么在处理一些跨平台的编译问题时,我最常用的的方法就是`process.env`与`__DEV__`,但是在用多了之后发现,在这种类似于条件编译的情况下,大量使用`process.env.PLATFORM === xxx`很容易出现深层次嵌套的问题,可读性会变得很差,毕竟我们的`Promise`就是为了解决异步回调的嵌套地狱的问题,如果我们因为需要跨平台编译而继续引入嵌套问题的话,总感觉并不是一个好的解决方案。 在`C/C++`中有一个非常有意思的预处理器,`C Preprocessor`不是编译器的组成部分,但其是编译过程中一个单独的步骤,简单来说`C Preprocessor`相当于是一个文本替换工具,例如不加入标识符的宏参数等都是原始文本直接替换,可以指示编译器在实际编译之前完成所需的预处理。`#include`、`#define`、`#ifdef`等等都属于`C Preprocessor`的预处理器指令,在这里我们主要关注条件编译的部分,也就是`#if`、`#endif`、`#ifdef`、`#endif`、`#ifndef`、`#endif`等条件编译指令。 ```c #if VERBOSE >= 2 print("trace message"); #endif #ifdef __unix__ /* __unix__ is usually defined by compilers targeting Unix systems */ # include #elif defined _WIN32 /* _WIN32 is usually defined by compilers targeting 32 or 64 bit Windows systems */ # include #endif ``` 那么我们同样也可以将类似的方式借助构建工具来实现,首先`C Preprocessor`是一个预处理工具,不参与实际的编译时的行为,那么是不是就很像`webpack`中的`loader`,而原始文本的直接替换我们在`loader`中也是完全可以做到的,而类似于`#ifdef`、`#endif`我们可以通过注释的形式来实现,这样就可以避免深层次的嵌套问题,而字符串替换的相关逻辑是可以直接修改原来来处理,例如不符合平台条件的就可以移除掉,符合平台条件的就可以保留下来,这样就可以实现类似于`#ifdef`、`#endif`的效果了。此外,通过注释来实现对某些复杂场景还是有帮助的,例如我就遇到过比较复杂的`SDK`打包场景,对内与对外以及对本体项目平台的行为都是不一致的,如果在不构建多个包的情况下,跨平台就需要用户自己来配置构建工具,而使用注释可以在不配置`loader`的情况下同样能够完整打包,在某些情况下可以避免用户需要改动自己的配置,当然这种情况还是比较深地耦合在业务场景的,只是提供一种情况的参考。 ```js // #IFDEF CHROMIUM console.log("IS IN CHROMIUM"); // #ENDIF // #IFDEF GECKO console.log("IS IN GECKO"); // #ENDIF ``` 最开始的时候我想使用正则的方式直接进行处理的,但是发现处理起来比较麻烦,尤其是存在嵌套的情况下,就不太容易处理逻辑,那么再后来我想反正代码都是 一行一行的逻辑,按行处理的方式才是最方便的,特别是在处理的过程中因为本身就是注释,最终都是要删除的,即使存在缩进的情况直接去掉前后的空白就能直接匹配标记进行处理了。这样思路就变的简单了很多,预处理指令起始`#IFDEF`只会置`true`,预处理指令结束`#ENDIF`只会置`false`,而我们的最终目标实际上就是删除代码,所以将不符合条件判断的代码行返回空白即可,但是处理嵌套的时候还是需要注意一下,我们需要一个栈来记录当前的处理预处理指令起始`#IFDEF`的索引即进栈,当遇到`#ENDIF`再出栈,并且还需要记录当前的处理状态,如果当前的处理状态是`true`,那么在出栈的时候就需要确定是否需要标记当前状态为`false`从而结束当前块的处理,并且还可以通过`debug`来实现对于命中模块处理后文件的生成。 ```js // CURRENT PLATFORM: GECKO // #IFDEF CHROMIUM // some expressions... // remove // #ENDIF // #IFDEF GECKO // some expressions... // retain // #ENDIF // #IFDEF CHROMIUM // some expressions... // remove // #IFDEF GECKO // some expressions... // remove // #ENDIF // #ENDIF // #IFDEF GECKO // some expressions... // retain // #IFDEF CHROMIUM // some expressions... // remove // #ENDIF // #ENDIF // #IFDEF CHROMIUM|GECKO // some expressions... // retain // #IFDEF GECKO // some expressions... // retain // #ENDIF // #ENDIF ``` ```js // ... // 迭代时控制该行是否命中预处理条件 const platform = (process.env[envKey] || "").toLowerCase(); let terser = false; let revised = false; let terserIndex = -1; /** @type {number[]} */ const stack = []; const lines = source.split("\n"); const target = lines.map((line, index) => { // 去掉首尾的空白 去掉行首注释符号与空白符(可选) const code = line.trim().replace(/^\/\/\s*/, ""); // 检查预处理指令起始 `#IFDEF`只会置`true` if (/^#IFDEF/.test(code)) { stack.push(index); // 如果是`true`继续即可 if (terser) return ""; const match = code.replace("#IFDEF", "").trim(); const group = match.split("|").map(item => item.trim().toLowerCase()); if (group.indexOf(platform) === -1) { terser = true; revised = true; terserIndex = index; } return ""; } // 检查预处理指令结束 `#IFDEF`只会置`false` if (/^#ENDIF$/.test(code)) { const index = stack.pop(); // 额外的`#ENDIF`忽略 if (index === undefined) return ""; if (index === terserIndex) { terser = false; terserIndex = -1; } return ""; } // 如果命中预处理条件则擦除 if (terser) return ""; return line; }); // ... ``` 那么在实际使用的过程中,以调用注册`Badge`为例,通过`if`分支将不同端的代码分别执行即可,当然如果有类似的定义,也可以方便地直接重新定义变量即可。 ```js let env = chrome; // #IFDEF GECKO if (typeof browser !== "undefined") { env = browser; } // #ENDIF export const cross = env; // ... let action: typeof cross.action | typeof cross.browserAction = cross.action; // #IFDEF GECKO action = cross.browserAction; // #ENDIF action.setBadgeText({ text: payload.toString(), tabId }); action.setBadgeBackgroundColor({ color: "#4e5969", tabId }); ``` ## 先于页面Js代码执行 浏览器扩展的一个重要功能就是`document_start`,也就是浏览器注入的代码要先于网站本身的`Js`代码执行,这样就可以为我们的代码留予充分的`Hook`空间,试想一下如果我们能够在页面实际加载的时候就运行我们想执行的`Js`代码的话,岂不是可以对当前的页面为所欲为了。虽然我们不能够`Hook`自面量的创建,但是我们总得调用浏览器提供的`API`,只要用`API`的调用,我们就可以想办法来劫持掉函数的调用,从而拿到我们想要的数据,例如可以劫持`Function.prototype.call`函数的调用,而这个函数能够完成很大程度上就需要依赖我这个劫持函数在整个页面是要最先支持的,否则这个函数已经被调用过去了,那么再劫持就没有什么意义了。 ```js Function.prototype.call = function (dynamic, ...args) { const context = Object(dynamic) || window; const symbol = Symbol(); context[symbol] = this; args.length === 2 && console.log(args); try { const result = context[symbol](...args); delete context[symbol]; return result; } catch (error) { console.log("Hook Call Error", error); console.log(context, context[symbol], this, dynamic, args); return null; } }; ``` 那么可能我们大家会想这个代码的实现意义在何处,举一个简单的实践,在某某文库中所有的文字都是通过`canvas`渲染的,因为没有`DOM`那么如果我们想获得文档的整篇内容是没有办法直接复制的,所以一个可行的方案是劫持`document.createElement`函数,当创建的元素是`canvas`时我们就可以提前拿到画布的对象,从而拿到`ctx`,而又因为实际绘制文字总归还是要调用`context2DPrototype.fillText`方法的,所以再劫持到这个方法,我们就能将绘制的文字拿出来,紧接着就可以自行创建`DOM`画在别处,想复制就可以复制了。 那么我们回到这个问题的实现上,如果能够保证脚本是最先执行的,那么我们几乎可以做到在语言层面上的任何事情,例如修改`window`对象、`Hook`函数定义、修改原型链、阻止事件等等等等。其本身的能力也是源自于浏览器拓展,而如何将浏览器扩展的这个能力暴露给`Web`页面就是脚本管理器需要考量的问题了。那么我们在这里假设用户脚本是运行在浏览器页面的`Inject Script`而不是`Content Script`,基于这个假设,首先我们大概率会写过动态/异步加载`JS`脚本的实现,类似于下面这种方式: ```js const loadScriptAsync = (url: string) => { return new Promise((resolve, reject) => { const script = document.createElement("script"); script.src = url; script.async = true; script.onload = e => { script.remove(); resolve(e); }; script.onerror = e => { script.remove(); reject(e); }; document.body.appendChild(script); }); }; ``` 那么现在就有一个明显的问题,我们如果在`body`标签构建完成也就是大概在`DOMContentLoaded`时机再加载脚本肯定是达不到`document-start`的目标的,即使是在`head`标签完成之后处理也不行,很多网站都会在`head`内编写部分`JS`资源,在这里加载同样时机已经不合适了,实际上最大的问题还是整个过程是异步的,在整个外部脚本加载完成之前已经有很多`JS`代码在执行了,做不到我们想要的“最先执行”。 那么下载我们就来探究具体的实现,首先是`v2`的扩展也就是在`gecko`内核的浏览器上,对于整个页面来说,最先加载的必定是`html`这个标签,那么很明显我们只要将脚本在`html`标签级别插入就好了,配合浏览器扩展中`background`的`chrome.tabs.executeScript`动态执行代码以及`Content Script`的`"run_at": "document_start"`建立消息通信确认注入的`tab`,这个方法是不是看起来很简单,但就是这么简单的问题让我思索了很久是如何做到的。 ```js // Content Script --> Background // Background -> chrome.tabs.executeScript chrome.tabs.executeScript(sender.tabId, { frameId: sender.frameId, code: `(function(){ let temp = document.createElementNS("http://www.w3.org/1999/xhtml", "script"); temp.setAttribute('type', 'text/javascript'); temp.innerHTML = "${script.code}"; temp.className = "injected-js"; document.documentElement.appendChild(temp); temp.remove(); }())`, runAt, }); ``` 这个看起来其实已经还不错了,能够基本做到`document-start`,但既然都说了是基本,说明还有些情况会出问题,我们仔细看这个代码的实现,在这里有一个通信也就是`Content Script --> Background`,既然是通信那么就是异步处理的,既然是异步处理就会消耗时间,一旦消耗时间那么用户页面就可能已经执行了大量的代码了,所以这个实现会偶现无法做到`document-start`的情况,也就是实际上是会出现脚本失效的情况。 那么有什么办法解决这个问题呢,在`v2`中我们能够明确知道的是`Content Script`是完全可控的`document-start`,但是`Content Script`并不是`Inject Script`,没有办法访问到页面的`window`对象,也就没有办法实际劫持页面的函数,那么这个问题看起来很复杂,实际上想明白之后解决起来也很简单,我们在原本的`Content Script`的基础上,再引入一个`Content Script`,而这个`Content Script`的代码是完全等同于原本的`Inject Script`,只不过会挂在`window`上,我们可以借助打包工具写个插件来完成这件事。 ```js compiler.hooks.emit.tapAsync("WrapperCodePlugin", (compilation, done) => { Object.keys(compilation.assets).forEach(key => { if (!isChromium && key === process.env.INJECT_FILE + ".js") { try { const buffer = compilation.assets[key].source(); let code = buffer.toString("utf-8"); code = `window.${process.env.INJECT_FILE}=function(){${code}}`; compilation.assets[key] = { source() { return code; }, size() { return this.source().length; }, }; } catch (error) { console.log("Parse Inject File Error", error); } } }); done(); }); ``` 这段代码表示了我们在同样的`Content Script`的`window`对象上挂了一个随机生成的`key`,在这里也就是我们之前提到的可能会引起冲突的地方,而内容就是我们实际想要注入到页面的脚本,但是现在虽然我们能够拿到这个函数了,怎么能够让其在用户页面上执行呢,这里实际上是用到了同样的`document.documentElement.appendChild`创建脚本方法,但是在这里的实现非常非常巧妙,我们通过两个`Content Script`配合`toString`的方式拿到了字符串,并且将其作为代码直接注入到了页面,从而做到了真正的`document-start`。 ```js const fn = window[process.env.INJECT_FILE as unknown as number] as unknown as () => void; // #IFDEF GECKO if (fn) { const script = document.createElementNS("http://www.w3.org/1999/xhtml", "script"); script.setAttribute("type", "text/javascript"); script.innerText = `;(${fn.toString()})();`; document.documentElement.appendChild(script); script.onload = () => script.remove(); // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore delete window[process.env.INJECT_FILE]; } // #ENDIF ``` 前边也提到了由于实际上`Chrome`浏览器不再允许`v2`的扩展程序提交,所以我们只能提交`v3`的代码,但是`v3`的代码有着非常严格的`CSP`内容安全策略的限制,可以简单的认为不允许动态地执行代码,所以我们上述的方式就都失效了,于是我们只能写出类似下面的代码。 ```js const script = document.createElementNS("http://www.w3.org/1999/xhtml", "script"); script.setAttribute("type", "text/javascript"); script.setAttribute("src", chrome.runtime.getURL("inject.js")); document.documentElement.appendChild(script); script.onload = () => script.remove(); ``` 虽然看起来我们也是在`Content Script`中立即创建了`Script`标签并且执行代码,而他能够达到我们的`document-start`目标吗,很遗憾答案是不能,在首次打开页面的时候是可以的,但是在之后因为这个脚本实际上是相当于拿到了一个外部的脚本,因此`Chrome`会将这个脚本和页面上其他的页面同样处于一个排队的状态,而其他的脚本会有强缓存在,所以实际表现上是不一定谁会先执行,但是这种不稳定的情况我们是不能够接受的,肯定做不到`document-start`目标。实际上光从这点来看`v3`并不成熟,很多能力的支持都不到位,所以在后来官方也是做出了一些方案来处理这个问题,但是因为我们并没有什么办法决定用户客户端的浏览器版本,所以很多兼容方法还是需要处理的。 ```js export const implantScript = () => { /** RUN INJECT SCRIPT IN DOCUMENT START **/ // #IFDEF CHROMIUM // https://bugs.chromium.org/p/chromium/issues/detail?id=634381 // https://stackoverflow.com/questions/75495191/chrome-extension-manifest-v3-how-to-use-window-addeventlistener if (cross.scripting && cross.scripting.registerContentScripts) { logger.info("Register Inject Scripts By Scripting API"); // https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/scripting/registerContentScripts cross.scripting .registerContentScripts([ { matches: [...URL_MATCH], runAt: "document_start", world: "MAIN", allFrames: true, js: [process.env.INJECT_FILE + ".js"], id: process.env.INJECT_FILE, }, ]) .catch(err => { logger.warning("Register Inject Scripts Failed", err); }); } else { logger.info("Register Inject Scripts By Tabs API"); // https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/tabs/onUpdated cross.tabs.onUpdated.addListener((_, changeInfo, tab) => { if (changeInfo.status == "loading") { const tabId = tab && tab.id; const tabURL = tab && tab.url; if (tabURL && !URL_MATCH.some(match => new RegExp(match).test(tabURL))) { return void 0; } if (tabId && cross.scripting) { cross.scripting.executeScript({ target: { tabId: tabId, allFrames: true }, files: [process.env.INJECT_FILE + ".js"], injectImmediately: true, }); } } }); } // #ENDIF // #IFDEF GECKO logger.info("Register Inject Scripts By Content Script Inline Code"); // #ENDIF }; ``` 在`Chrome V109`之后支持了`chrome.scripting.registerContentScripts`,`Chrome 111`支持了直接在`Manifest`中声明`world: 'MAIN'`的脚本,但是这其中的兼容性还是需要开发者来做,特别是如果原来的浏览器不支持`world: 'MAIN'`,那么这个脚本是会被当作`Content Script`处理的,关于这点我觉得还是有点难以处理。 ## 静态资源处理 设想一下我们的很多资源引用是以字符串的形式处理的,例如在`manifest.json`中的`icons`引用,是字符串引用而并不像我们的`Web`应用中会根据实际路径引用资源,那么在这种情况下资源是不会作为打包工具实际引用的内容的,具体表现是当我们修改资源时并不会触发打包工具的`HMR`。 因此,对于这部分内容我们需要手动将其并入打包的依赖中,此外还需要将相关文件复制到打包的目标文件夹中。这实际上并不是个复杂的任务,只需要我们实现插件来完成这件事即可,在这里我们需要处理的除了图片等静态资源外,还有`locales`作为语言文件处理。 ```js exports.FilesPlugin = class FilesPlugin { apply(compiler) { compiler.hooks.make.tap("FilesPlugin", compilation => { const resources = path.resolve("public/static"); !compilation.contextDependencies.has(resources) && compilation.contextDependencies.add(resources); }); compiler.hooks.done.tapPromise("FilesPlugin", () => { const locales = path.resolve("public/locales/"); const resources = path.resolve("public/static/"); const folder = isGecko ? "build-gecko" : "build"; const localesTarget = path.resolve(`${folder}/_locales/`); const resourcesTarget = path.resolve(`${folder}/static/`); return Promise.all([ exec(`cp -r ${locales} ${localesTarget}`), exec(`cp -r ${resources} ${resourcesTarget}`), ]); }); } }; ``` ## 生成Manifest 在前边提到的处理静态资源的问题上,对于`manifest.json`文件的生成上同样存在,我们也需要将其作为`contextDependencies`注册到打包工具上。此外,还记得之前我们需要兼容的`Chromium`和`Gecko`嘛,我们在处理`manifest.json`时同样需要对其进行兼容处理,那么我们肯定不希望有两份配置文件来完成这件事,因此我们可以借助`ts-node`来动态生成`manifest.json`,这样我们就可以通过各种逻辑来动态地将配置文件写入了。 ```js exports.ManifestPlugin = class ManifestPlugin { constructor() { tsNode.register(); this.manifest = path.resolve(`src/manifest/index.ts`); } apply(compiler) { compiler.hooks.make.tap("ManifestPlugin", compilation => { const manifest = this.manifest; !compilation.fileDependencies.has(manifest) && compilation.fileDependencies.add(manifest); }); compiler.hooks.done.tapPromise("ManifestPlugin", () => { delete require.cache[require.resolve(this.manifest)]; const manifest = require(this.manifest); const version = require(path.resolve("package.json")).version; manifest.version = version; const folder = isGecko ? "build-gecko" : "build"; return writeFile(path.resolve(`${folder}/manifest.json`), JSON.stringify(manifest, null, 2)); }); } }; ``` ```js const __URL_MATCH__ = ["https://*/*", "http://*/*", "file://*/*"]; // Chromium const __MANIFEST__: Record = { manifest_version: 3, name: "Force Copy", version: "0.0.0", description: "Force Copy Everything", default_locale: "en", icons: { 32: "./static/favicon.128.png", 96: "./static/favicon.128.png", 128: "./static/favicon.128.png", }, // ... permissions: ["activeTab", "tabs", "scripting"], minimum_chrome_version: "88.0", }; // Gecko if (process.env.PLATFORM === "gecko") { __MANIFEST__.manifest_version = 2; // ... __MANIFEST__.permissions = ["activeTab", "tabs", ...__URL_MATCH__]; __MANIFEST__.browser_specific_settings = { gecko: { strict_min_version: "91.1.0" }, gecko_android: { strict_min_version: "91.1.0" }, }; delete __MANIFEST__.action; delete __MANIFEST__.host_permissions; delete __MANIFEST__.minimum_chrome_version; delete __MANIFEST__.web_accessible_resources; } module.exports = __MANIFEST__; ``` ## 事件通信方案 在浏览器拓展中有很多模块,常见的模块有`background/worker`、`popup`、`content`、`inject`、`devtools`等,不同的模块对应着不同的作用,协作构成了插件的扩展功能。那么显然由于存在各种模块,每个模块负责不同的功能,我们就需要完成关联模块的通信能力。 由于整个项目都是由`TS`构建的,因此我们更希望实现类型完备的通信方案,特别是在功能实现复杂的时候静态的类型检查能够帮我们避免很多问题,那么在这里我们就以`Popup`到`Content`为例对数据通信做统一的方案,在扩展中我们需要为每个需要通信的模块设计相关的类。 首先我们需要定义通信的`key`值,因为我们需要通过`type`来决定本次通信传递的信息类型,而为了防止值冲突,我们通过`reduce`为我们的`key`值增加一些复杂度。 ```js const PC_REQUEST_TYPE = ["A", "B"] as const; export const POPUP_TO_CONTENT_REQUEST = PC_REQUEST_TYPE.reduce( (acc, cur) => ({ ...acc, [cur]: `__${cur}__${MARK}__` }), {} as { [K in typeof PC_REQUEST_TYPE[number]]: `__${K}__${typeof MARK}__` } ); ``` 如果我们用过`redux`的话,可能会遇到一个问题,就是`type`如何跟`payload`携带的类型对齐,例如我们希望当`type`是`A`的时候,`TS`能够自动推断出来`payload`的类型是`{ x: number }`,而如果`type`是`B`的时候,`TS`能够自动推断类型为`{ y: string }`,那么这个例子比较简单的声明式方案如下: ```js type Tuple = | { type: "A"; payload: { x: number }; } | { type: "B"; payload: { y: string }; }; const pick = (data: Tuple) => { switch (data.type) { case "A": return data.payload.x; // number case "B": return data.payload.y; // string } }; ``` 这么写起来实际上并不优雅,我们可能更希望对于类型的声明可以优雅一些,那么当然我们可以借助范型来完成这件事。不过我们可能并不能一步将其处理完成,需要分开把类型声明做好,首先我们可以实现`type -> payload`的类型`Map`,将映射关系表达出来,之后将其转换为`type -> { type: T, payload: Map[T] }`的结构,然后取`Tuple`即可。 ```js type Map = { A: { x: number }; B: { y: string }; }; type ToReflectMap> = { [P in T]: { type: unknown extends M[P] ? never : P; payload: M[P] }; }; type ReflectMap = ToReflectMap; type Tuple = ReflectMap[keyof ReflectMap]; ``` 那么我们现在可以将其封装到一个`namespace`中,以及一些基本的类型数据转换方法来方便我们调用。 ```js export namespace Object { export type Keys> = keyof T; export type Values> = T[keyof T]; } export namespace String { export type Map = { [P in T]: P }; } export namespace EventReflect { export type Array> = T extends string ? [type: unknown extends M[T] ? never : T, payload: M[T]] : never; export type Map> = { [P in T]: { type: unknown extends M[P] ? never : P; payload: M[P] }; }; export type Tuple< T extends Record, M extends Record > = Object.Values, M>>; } type Tuple = EventReflect.Tuple, Map>; ``` 实际上为了方便我们的函数调用,我们也可以对参数做处理,在函数内部将其重新`as`为需要的参数类型即可。 ```js type Map = { A: { x: number }; B: { y: string }; }; type Args = EventReflect.Array; declare function post(...args: Args): null; post("A", { x: 2 }); post("B", { y: "" }); ``` 为了明确我们的类型表达,在这里我们暂时不用函数参数的形式来表达,依然使用对象`type -> payload`的形式标注类型。那么既然在这里我们已经将请求的类型定义好,我们接着需要将返回响应的数据类型定义出来,为了方便于数据的表达与严格的类型,我们同样将返回的数据表示为`type -> payload`的形式,当然这里的响应`type`和请求时的`type`是一致的。 ```js type EventMap = { [POPUP_TO_CONTENT_REQUEST.A]: { [K in PCQueryAType]: boolean }; }; export type PCResponseType = EventReflect.Tuple, EventMap>; ``` 接下来我们就来定义整个事件通信的`Bridge`,由于此时我们是`Popup`向`Content`发送数据,那么我们就必须要明确向当前的哪个`Tab`发送数据,所以在这里需要查询当前活跃的`Tab`。数据通信的方式则是使用的`cross.tabs.sendMessage`方法,在接收消息的时候则需要`cross.runtime.onMessage.addListener`。并且由于可能存在的多种通信通道,我们还需要判断这个消息源,在这里我们通过发送的`key`判断即可。 在这里需要注意的是即使扩展的定义中`sendResponse`是响应异步数据,但是在实际测试的过程中发现这个函数是不能异步调用的,也就是说这个函数必须要在响应的回调中立即执行,其说的异步指的是整个事件通信的过程是异步的,所以在这里我们就以数据返回响应的形式来定义。 ```js export class PCBridge { public static readonly REQUEST = POPUP_TO_CONTENT_REQUEST; static async postToContent(data: PCRequestType) { return new Promise(resolve => { cross.tabs .query({ active: true, currentWindow: true }) .then(tabs => { const tab = tabs[0]; const tabId = tab && tab.id; const tabURL = tab && tab.url; if (tabURL && !URL_MATCH.some(match => new RegExp(match).test(tabURL))) { resolve(null); return void 0; } if (!isEmptyValue(tabId)) { cross.tabs.sendMessage(tabId, data).then(resolve); } else { resolve(null); } }) .catch(error => { logger.warning("Send Message Error", error); }); }); } static onPopupMessage(cb: (data: PCRequestType) => void | PCResponseType) { const handler = ( request: PCRequestType, _: chrome.runtime.MessageSender, sendResponse: (response: PCResponseType | null) => void ) => { const response = cb(request); response && response.type === request.type && sendResponse(response); }; cross.runtime.onMessage.addListener(handler); return () => { cross.runtime.onMessage.removeListener(handler); }; } static isPCRequestType(data: PCRequestType): data is PCRequestType { return data && data.type && data.type.endsWith(`__${MARK}__`); } } ``` 此外,在`content`中与`inject`通信需要比较特殊的封装,在`Content Script`中的`DOM`和事件流是与`Inject Script`共享的,那么实际上我们就可以有两种方式实现通信: * 首先我们常用的方法是`window.addEventListener + window.postMessage`,只不过这种方式很明显的一个问题是在`Web`页面中也可以收到我们的消息,即使我们可以生成一些随机的`token`来验证消息的来源,但是这个方式毕竟能够非常简单地被页面本身截获不够安全。 * 另一种方式即`document.addEventListener + document.dispatchEvent + CustomEvent`自定义事件的方式,在这里我们需要注意的是事件名要随机,通过在注入框架时于`background`生成唯一的随机事件名,之后在`Content Script`与`Inject Script`都使用该事件名通信,就可以防止用户截获方法调用时产生的消息了。 这里需要注意的是,所有传输的数据类型必须要是可序列化的,如果不是可序列化的话在`Gecko`内核的浏览器中会被认为是跨域的对象,毕竟实际上确实是跨越了不同的`Context`了,否则就相当于直接共享内存了。 ```js // Content Script document.addEventListener("xxxxxxxxxxxxx" + "content", e => { console.log("From Inject Script", e.detail); }); // Inject Script document.addEventListener("xxxxxxxxxxxxx" + "inject", e => { console.log("From Content Script", e.detail); }); // Inject Script document.dispatchEvent( new CustomEvent("xxxxxxxxxxxxx" + "content", { detail: { message: "call api" }, }), ); // Content Script document.dispatchEvent( new CustomEvent("xxxxxxxxxxxxx" + "inject", { detail: { message: "return value" }, }), ); ``` ## 热更新方案 在前边我们一直提到了谷歌强推的`v3`有很多限制,这其中有一个很大的限制是其`CSP - Content Security Policy`不再允许动态执行代码,那么诸如我们`DevServer`的`HMR`工具则都无法正常发挥其作用了,但是热更新是我们实际需要的功能,所以只能采用并没有那么完善的解决方案。 我们可以编写一个打包工具的插件,利用`ws.Server`启动一个`WebSocket`服务器,之后在`worker.js`也就是我们将要启动的`Service Worker`来连接`WebSocket`服务器。然后可以通过`new WebSocket`来链接并且在监听消息,当收到来自服务端的`reload`消息之后,我们就可以执行`chrome.runtime.reload()`来实现插件的重新加载了。 那么在开启的`WebSocket`服务器中需要在每次编译完成之后例如`afterDone`这个`hook`向客户端发送`reload`消息,这样就可以实现一个简单的插件重新加载能力了。但是实际上这引入了另一个问题,在`v3`版本的`Service Worker`不会常驻,所以这个`WebSocket`链接也会随着`Service Worker`的销毁而销毁,是比较坑的一点,同样也是因为这一点大量的`Chrome`扩展无法从`v2`平滑过渡到`v3`,所以这个能力后续还有可能会被改善。 ```js exports.ReloadPlugin = class ReloadPlugin { constructor() { if (isDev) { try { const server = new WebSocketServer({ port: 3333 }); server.on("connection", client => { wsClient && wsClient.close(); wsClient = client; console.log("Client Connected"); }); } catch (error) { console.log("Auto Reload Server Error", error); } } } apply(compiler) { compiler.hooks.afterDone.tap("ReloadPlugin", () => { wsClient && wsClient.send("reload-app"); }); } }; ``` ```js export const onReceiveReloadMsg = () => { if (__DEV__) { try { const ws = new WebSocket("ws://localhost:3333"); // 收到消息即重载 ws.onmessage = () => { try { CWBridge.postToWorker({ type: CWBridge.REQUEST.RELOAD, payload: null }); } catch (error) { logger.warning("SEND MESSAGE ERROR", error); } }; } catch (error) { logger.warning("CONNECT ERROR", error); } } }; export const onContentMessage = (data: CWRequestType, sender: chrome.runtime.MessageSender) => { logger.info("Worker Receive Content Message", data); switch (data.type) { case CWBridge.REQUEST.RELOAD: { reloadApp(RELOAD_APP); break; } // ... } return null; }; ``` 至此我们已经可以实现整个扩展的热更新方案,此时我们就可以配合扩展的`Install`事件在这个时机重新执行`Content/Inject Script`的代码注入,那么这样就可以实现整个扩展的热更新了。当然,我们必须要实现幂等情况的脚本注入,需要注意的是扩展中不存在`Uninstall`事件,因此移除先前注入的副作用需要我们在约定些全局方法调用处理。 ```js const onSetup = () => { cross.tabs.query({}).then(tabs => { for (const tab of tabs) { const tabId = tab && tab.id; cross.scripting.executeScript(/** ... */); cross.tabs.executeScript(tabId, /** ... */) } }); }; cross.runtime.onInstalled.addListener(/** ... */); ``` ## Popup多语言 比较有趣的一件事情是,浏览器提供的多语言方案实际上并不好用,我们在`locals`中存储的文件实际上只是占位,是为了让扩展市场认识我们的浏览器扩展支持的语言,而实际上的多语言则在我们的`Popup`中自行实现,例如在`packages/force-copy/public/locales/zh_CN`中的数据如下: ```js { "name": { "message": "Force Copy" } } ``` 那么实际上前端的多语言解决方案有很多,在这里因为我们的扩展程序不会有太多需要关注的多语言的内容,毕竟只是一个`Popup`层,如果需要独立一个`index.html`的页面的话,那采用社区的多语言方案还是有必要的。不过在这里我们就简单实现即可。 首先是类型完备,在我们的拓展中我们是以英文为基准语言,所以配置也是以英文为基准的设置。而由于我们希望有更好的分组方案,所以在这里可能会存在比较深层次的嵌套结构,因此类型上也必须完整将其拼接出来,用以支持我们的多语言。 ```js export const en = { Title: "Force Copy", Captain: { Modules: "Modules", Start: "Start", Once: "Once", }, Operation: { Copy: "Copy", Keyboard: "Keyboard", ContextMenu: "ContextMenu", }, Information: { GitHub: "GitHub", Help: "Help", Reload: "Reload", }, }; ``` ```js export type DefaultI18nConfig = typeof en; export type ConfigBlock = { [key: string]: string | ConfigBlock; }; type FlattenKeys = Key extends string ? T[Key] extends ConfigBlock ? `${Key}.${FlattenKeys}` : `${Key}` : never; export type I18nTypes = Record, string>; ``` 紧接着我们定义`I18n`类以及语言的全局缓存,在`I18n`类中实现了函数调用、多语言配置按需生成、多语言配置获取的函数,在调用的时候直接实例化`new I18n(cross.i18n.getUILanguage());`,取`i18n.t("Information.GitHub")`即可。 ```js const cache: Record = {}; export class I18n { private config: I18nTypes; constructor(language: string) { this.config = I18n.getFullConfig(language); } public t = (key: keyof I18nTypes, defaultValue = "") => { return this.config[key] || defaultValue || key; }; private static getFullConfig = (key: string) => { if (cache[key]) return cache[key]; let config; if (key.toLowerCase().startsWith("zh")) { config = this.generateFlattenConfig(zh); } else { config = this.generateFlattenConfig(en); } cache[key] = config; return config; }; private static generateFlattenConfig = (config: ConfigBlock): I18nTypes => { const target: Record = {}; const dfs = (obj: ConfigBlock, prefix: string[]) => { for (const [key, value] of Object.entries(obj)) { if (isString(value)) { target[[...prefix, key].join(".")] = value; } else { dfs(value, [...prefix, key]); } } }; dfs(config, []); return target as I18nTypes; }; } ``` ## 总结 浏览器扩展的开发还是比较复杂的一件事,特别是在需要兼容`v2`和`v3`的情况下,很多设计都需要思考是否能够正常在`v3`上实现,在`v3`的浏览器扩展上失去了很多灵活性,但是相对也获取了一定的安全性。不过浏览器扩展本质上的权限还是相当高的,例如即使是`v3`我们仍然可以在`Chrome`上使用`CDP - Chrome DevTools Protocol`来实现很多事情,扩展能做的东西实在是太多了,如果不了解或者不开源的话根本不敢安装,因为扩展权限太高可能会造成很严重的例如用户信息泄漏等问题,即使是比如像`Firefox`那样必须要上传源代码的方式来加强审核,也很难杜绝所有的隐患。 ## 每日一题 - ## 参考 - - -