安装量终于破千了!聊聊浏览器扩展开发的相关问题与解决方案

1,552 阅读15分钟

聊聊浏览器扩展开发的相关问题与解决方案

我开发的浏览器扩展安装量终于过千了!在 Firefox AddOns 已经有1.7k+安装,在 Chrome WebStore 已经有1k+安装。实际上在Firefox的扩展市场里是周平均安装量,当天的实际安装量要高出平均值不少,而Chrome的扩展市场在超过1k安装量之后就不精确显示安装量了,实际安装量也会高于1k

实际上在我做扩展之前,我是实现了脚本来处理相关的功能的,脚本在 GreasyFork 上有 2688k+安装量,而实现扩展的主要原因有两个: 一个原因是我也想学习一下扩展的开发,我发现在工作中真的会有应用场景,特别是要突破浏览器限制做一些特殊工作的情况下;另一个原因是我发现有将我打包发布在GreasyFork上的GPL协议的代码直接封装成插件并加入了广告,就这竟然还有400k+安装量。

因此我也基于脚本能力实现了浏览器拓展,并且是主要为了学习的情况下,我从零搭建了整个开发环境,也在处理了很多兼容性方案,接下来我们就来聊聊相关问题与解决方案。项目地址 GitHub ,如果觉得不错,点个star吧 😁 。

扩展打包方案

我们在先前提到了在这里是从零搭建的开发环境,那么我们就需要挑选一个扩展打包工具,在这里我选用的是rspack,当然如果我们使用webpack或者是rollup都是没问题的,只是用rspack比较熟悉且打包速度比较快,无论是哪种打包器都是类似的配置。此外,在这里实际上我们是使用的build级别的打包方式,类似于devserver的方案在v3中目前并不太适用。

那么需要注意的是,在浏览器扩展中我们需要定义多个入口文件,并且需要单文件的打包方案,不能出现单入口多个chunk,包括CSS我们也需要打包为单入口单输出,并且输出的文件名也不要带hash的后缀,防止文件找不到的情况。不过这并不是什么比较大的问题,在配置文件中注意即可。

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生成的文件名都是不一致的,在每次发布后文件名都会更改,包括模拟的事件通信方案也是一致随机名。

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强制性用v3Firefox推荐用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等条件编译指令。

#if VERBOSE >= 2
  print("trace message");
#endif

#ifdef __unix__ /* __unix__ is usually defined by compilers targeting Unix systems */
# include <unistd.h>
#elif defined _WIN32 /* _WIN32 is usually defined by compilers targeting 32 or 64 bit Windows systems */
# include <windows.h>
#endif

那么我们同样也可以将类似的方式借助构建工具来实现,首先C Preprocessor是一个预处理工具,不参与实际的编译时的行为,那么是不是就很像webpack中的loader,而原始文本的直接替换我们在loader中也是完全可以做到的,而类似于#ifdef#endif我们可以通过注释的形式来实现,这样就可以避免深层次的嵌套问题,而字符串替换的相关逻辑是可以直接修改原来来处理,例如不符合平台条件的就可以移除掉,符合平台条件的就可以保留下来,这样就可以实现类似于#ifdef#endif的效果了。此外,通过注释来实现对某些复杂场景还是有帮助的,例如我就遇到过比较复杂的SDK打包场景,对内与对外以及对本体项目平台的行为都是不一致的,如果在不构建多个包的情况下,跨平台就需要用户自己来配置构建工具,而使用注释可以在不配置loader的情况下同样能够完整打包,在某些情况下可以避免用户需要改动自己的配置,当然这种情况还是比较深地耦合在业务场景的,只是提供一种情况的参考。

// #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来实现对于命中模块处理后文件的生成。

// 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
// ...
// 迭代时控制该行是否命中预处理条件
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分支将不同端的代码分别执行即可,当然如果有类似的定义,也可以方便地直接重新定义变量即可。

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函数的调用,而这个函数能够完成很大程度上就需要依赖我这个劫持函数在整个页面是要最先支持的,否则这个函数已经被调用过去了,那么再劫持就没有什么意义了。

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脚本的实现,类似于下面这种方式:

const loadScriptAsync = (url: string) => {
    return new Promise<Event>((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标签级别插入就好了,配合浏览器扩展中backgroundchrome.tabs.executeScript动态执行代码以及Content Script"run_at": "document_start"建立消息通信确认注入的tab,这个方法是不是看起来很简单,但就是这么简单的问题让我思索了很久是如何做到的。

// 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上,我们可以借助打包工具写个插件来完成这件事。

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 Scriptwindow对象上挂了一个随机生成的key,在这里也就是我们之前提到的可能会引起冲突的地方,而内容就是我们实际想要注入到页面的脚本,但是现在虽然我们能够拿到这个函数了,怎么能够让其在用户页面上执行呢,这里实际上是用到了同样的document.documentElement.appendChild创建脚本方法,但是在这里的实现非常非常巧妙,我们通过两个Content Script配合toString的方式拿到了字符串,并且将其作为代码直接注入到了页面,从而做到了真正的document-start

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内容安全策略的限制,可以简单的认为不允许动态地执行代码,所以我们上述的方式就都失效了,于是我们只能写出类似下面的代码。

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并不成熟,很多能力的支持都不到位,所以在后来官方也是做出了一些方案来处理这个问题,但是因为我们并没有什么办法决定用户客户端的浏览器版本,所以很多兼容方法还是需要处理的。

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.registerContentScriptsChrome 111支持了直接在Manifest中声明world: 'MAIN'的脚本,但是这其中的兼容性还是需要开发者来做,特别是如果原来的浏览器不支持world: 'MAIN',那么这个脚本是会被当作Content Script处理的,关于这点我觉得还是有点难以处理。

静态资源处理

设想一下我们的很多资源引用是以字符串的形式处理的,例如在manifest.json中的icons引用,是字符串引用而并不像我们的Web应用中会根据实际路径引用资源,那么在这种情况下资源是不会作为打包工具实际引用的内容的,具体表现是当我们修改资源时并不会触发打包工具的HMR

因此,对于这部分内容我们需要手动将其并入打包的依赖中,此外还需要将相关文件复制到打包的目标文件夹中。这实际上并不是个复杂的任务,只需要我们实现插件来完成这件事即可,在这里我们需要处理的除了图片等静态资源外,还有locales作为语言文件处理。

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注册到打包工具上。此外,还记得之前我们需要兼容的ChromiumGecko嘛,我们在处理manifest.json时同样需要对其进行兼容处理,那么我们肯定不希望有两份配置文件来完成这件事,因此我们可以借助ts-node来动态生成manifest.json,这样我们就可以通过各种逻辑来动态地将配置文件写入了。

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));
    });
  }
};
const __URL_MATCH__ = ["https://*/*", "http://*/*", "file://*/*"];

// Chromium
const __MANIFEST__: Record<string, unknown> = {
  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/workerpopupcontentinjectdevtools等,不同的模块对应着不同的作用,协作构成了插件的扩展功能。那么显然由于存在各种模块,每个模块负责不同的功能,我们就需要完成关联模块的通信能力。

由于整个项目都是由TS构建的,因此我们更希望实现类型完备的通信方案,特别是在功能实现复杂的时候静态的类型检查能够帮我们避免很多问题,那么在这里我们就以PopupContent为例对数据通信做统一的方案,在扩展中我们需要为每个需要通信的模块设计相关的类。

首先我们需要定义通信的key值,因为我们需要通过type来决定本次通信传递的信息类型,而为了防止值冲突,我们通过reduce为我们的key值增加一些复杂度。

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携带的类型对齐,例如我们希望当typeA的时候,TS能够自动推断出来payload的类型是{ x: number },而如果typeB的时候,TS能够自动推断类型为{ y: string },那么这个例子比较简单的声明式方案如下:

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即可。

type Map = {
  A: { x: number };
  B: { y: string };
};

type ToReflectMap<T extends string, M extends Record<string, unknown>> = {
  [P in T]: { type: unknown extends M[P] ? never : P; payload: M[P] };
};

type ReflectMap = ToReflectMap<keyof Map, Map>;

type Tuple = ReflectMap[keyof ReflectMap];

那么我们现在可以将其封装到一个namespace中,以及一些基本的类型数据转换方法来方便我们调用。

export namespace Object {
  export type Keys<T extends Record<string, unknown>> = keyof T;

  export type Values<T extends Record<symbol | string | number, unknown>> = T[keyof T];
}

export namespace String {
  export type Map<T extends string> = { [P in T]: P };
}

export namespace EventReflect {
  export type Array<T, M extends Record<string, unknown>> = T extends string
    ? [type: unknown extends M[T] ? never : T, payload: M[T]]
    : never;

  export type Map<T extends string, M extends Record<string, unknown>> = {
    [P in T]: { type: unknown extends M[P] ? never : P; payload: M[P] };
  };

  export type Tuple<
    T extends Record<string, string>,
    M extends Record<string, unknown>
  > = Object.Values<Map<Object.Values<T>, M>>;
}

type Tuple = EventReflect.Tuple<String.Map<keyof Map>, Map>;

实际上为了方便我们的函数调用,我们也可以对参数做处理,在函数内部将其重新as为需要的参数类型即可。

type Map = {
  A: { x: number };
  B: { y: string };
};

type Args = EventReflect.Array<keyof Map, Map>;

declare function post(...args: Args): null;

post("A", { x: 2 });
post("B", { y: "" });

为了明确我们的类型表达,在这里我们暂时不用函数参数的形式来表达,依然使用对象type -> payload的形式标注类型。那么既然在这里我们已经将请求的类型定义好,我们接着需要将返回响应的数据类型定义出来,为了方便于数据的表达与严格的类型,我们同样将返回的数据表示为type -> payload的形式,当然这里的响应type和请求时的type是一致的。

type EventMap = {
  [POPUP_TO_CONTENT_REQUEST.A]: { [K in PCQueryAType]: boolean };
};

export type PCResponseType = EventReflect.Tuple<String.Map<keyof EventMap>, EventMap>;

接下来我们就来定义整个事件通信的Bridge,由于此时我们是PopupContent发送数据,那么我们就必须要明确向当前的哪个Tab发送数据,所以在这里需要查询当前活跃的Tab。数据通信的方式则是使用的cross.tabs.sendMessage方法,在接收消息的时候则需要cross.runtime.onMessage.addListener。并且由于可能存在的多种通信通道,我们还需要判断这个消息源,在这里我们通过发送的key判断即可。

在这里需要注意的是即使扩展的定义中sendResponse是响应异步数据,但是在实际测试的过程中发现这个函数是不能异步调用的,也就是说这个函数必须要在响应的回调中立即执行,其说的异步指的是整个事件通信的过程是异步的,所以在这里我们就以数据返回响应的形式来定义。

export class PCBridge {
  public static readonly REQUEST = POPUP_TO_CONTENT_REQUEST;

  static async postToContent(data: PCRequestType) {
    return new Promise<PCResponseType | null>(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 ScriptInject Script都使用该事件名通信,就可以防止用户截获方法调用时产生的消息了。

这里需要注意的是,所有传输的数据类型必须要是可序列化的,如果不是可序列化的话在Gecko内核的浏览器中会被认为是跨域的对象,毕竟实际上确实是跨越了不同的Context了,否则就相当于直接共享内存了。

// 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不再允许动态执行代码,那么诸如我们DevServerHMR工具则都无法正常发挥其作用了,但是热更新是我们实际需要的功能,所以只能采用并没有那么完善的解决方案。

我们可以编写一个打包工具的插件,利用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,所以这个能力后续还有可能会被改善。

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");
    });
  }
};
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;
};

Popup多语言

比较有趣的一件事情是,浏览器提供的多语言方案实际上并不好用,我们在locals中存储的文件实际上只是占位,是为了让扩展市场认识我们的浏览器扩展支持的语言,而实际上的多语言则在我们的Popup中自行实现,例如在packages/force-copy/public/locales/zh_CN中的数据如下:

{
  "name": {
    "message": "Force Copy"
  }
}

那么实际上前端的多语言解决方案有很多,在这里因为我们的扩展程序不会有太多需要关注的多语言的内容,毕竟只是一个Popup层,如果需要独立一个index.html的页面的话,那采用社区的多语言方案还是有必要的。不过在这里我们就简单实现即可。

首先是类型完备,在我们的拓展中我们是以英文为基准语言,所以配置也是以英文为基准的设置。而由于我们希望有更好的分组方案,所以在这里可能会存在比较深层次的嵌套结构,因此类型上也必须完整将其拼接出来,用以支持我们的多语言。

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",
  },
};
export type DefaultI18nConfig = typeof en;

export type ConfigBlock = {
  [key: string]: string | ConfigBlock;
};
type FlattenKeys<T extends ConfigBlock, Key = keyof T> = Key extends string
  ? T[Key] extends ConfigBlock
    ? `${Key}.${FlattenKeys<T[Key]>}`
    : `${Key}`
  : never;
export type I18nTypes = Record<FlattenKeys<DefaultI18nConfig>, string>;

紧接着我们定义I18n类以及语言的全局缓存,在I18n类中实现了函数调用、多语言配置按需生成、多语言配置获取的函数,在调用的时候直接实例化new I18n(cross.i18n.getUILanguage());,取i18n.t("Information.GitHub")即可。

const cache: Record<string, I18nTypes> = {};

export class I18n {
  private config: I18nTypes;
  constructor(language: string) {
    this.config = I18n.getFullConfig(language);
  }

  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<string, string> = {};
    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;
  };
}

最后

浏览器扩展的开发还是比较复杂的一件事,特别是在需要兼容v2v3的情况下,很多设计都需要思考是否能够正常在v3上实现,在v3的浏览器扩展上失去了很多灵活性,但是相对也获取了一定的安全性。不过浏览器扩展本质上的权限还是相当高的,例如即使是v3我们仍然可以在Chrome上使用CDP - Chrome DevTools Protocol来实现很多事情,扩展能做的东西实在是太多了,如果不了解或者不开源的话根本不敢安装,因为扩展权限太高可能会造成很严重的例如用户信息泄漏等问题,即使是比如像Firefox那样必须要上传源代码的方式来加强审核,也很难杜绝所有的隐患。