背景

我们团队的小伙伴写了许多的油猴脚本,通过它们的帮助我们减少了许多重复性的工作,让我们可以聚焦更加有意义的事情。但我们团队在油猴脚本上也遇到了一些问题,比如:

  • 使用原生 js 编写代码,不符合现在模块化编程的理念
  • 油猴脚本未放在一个仓库,导致我们需要导入多个油猴脚本

综合以上原因,我们首先考虑的是把现有的脚本统一放在一个仓库内,但是如果要解决第二点的问题,这还不够。我们需要有一套打包方案,把想同功能的脚本代码放在一起,才能让用户不需要启用多个油猴脚本。

是的,通过基于 Webpack 的打包方案我们可以完美解决第一点以及第二点的问题。但有个前提,如果你对油猴脚本熟悉的话,你会了解到油猴脚本有一个 @match 字段。这个字段是用来告诉油猴脚本需要在哪个网页加载这些代码。

那么我们已经可以想到了,我们写的这些油猴脚本不一定都是在同一个网页上运行的。所以就算我们通过 Webpack 聚合了部分想同域名的脚本,其他域名的脚本用户还是会有多次导入的情况,那么我们怎么解决这个问题呢?

本问将讲述我们团队如何解决油猴脚本迁移问题以及 Chrome 扩展开发时的热更新问题。

问题的思考

油猴脚本提供了许多的能力,@match 定义脚本加载的网页,@run-at 定义脚本的插入时机等。这些能力都来源于油猴扩展,油猴扩展可以管理多个油猴脚本。想到这里,我们之前的问题是不是已经迎刃而解了呢?

做一个 Chrome 扩展

是的,最终的方案也就是做一个 Chrome 扩展。

我这里先简单介绍一下 Chrome 扩展:

Chrome 扩展是一个包含了多个文件的压缩包,由 HTML、CSS、JavaScript、图片以及任何你需要的文件组成,目的是为 Chrome 浏览器添加特定的功能。扩展本质上是一个网页,因此可以使用浏览器为网页提供的 API,扩展还可以操作浏览器内置功能,如书签、下载管理等。

HTML、CSS、JavaScript 这三剑客我想你如果是前端工程师,并不会陌生因为我们工作中都会用到这些技术。但 Chrome 扩展由于定位的特殊性,它还需要一个 manifest.json 文件描述 Chrome 扩展的基础信息,比如名称、版本、图标、权限等等。

以下是一份示例文件:

{
  "name": "My Extension",
  "description": "A nice little demo extension.",
  "version": "2.1",
  "manifest_version": 3,
  "icons": {
    "16": "icon_16.png",
    "48": "icon_48.png",
    "128": "icon_128.png"
  },
  "background": {
    "service_worker": "background.js"
  },
  "permissions": ["storage"],
  "host_permissions": ["*://*.example.com/*"],
  "action": {
    "default_icon": "icon_16.png",
    "default_popup": "popup.html"
  }
}

更多 Manifest 介绍,请查看:https://developer.chrome.com/docs/extensions/mv3/manifest/

知道了 Chrome 扩展的定义以及它所需的特殊文件,如何开发呢?

我们可以基于 Webpack 打包我们项目的源代码,生成出一份符合 Chrome 扩展定义的文件夹。既然使用 Webpack 了,想必在开发时有热更新的能力显得尤为重要。

但 Chrome 扩展开发有点特殊,跟平常的网页开发相比操作有点繁琐。首先需要把对应的扩展文件夹放到 chrome://extensions/(记得开启开发者模式) 中。当你修改源代码,重新生成文件夹后还需要点击扩展的刷新按钮,才能真正看到效果(如果是 content scripts 的话还需要刷新一下浏览器)。

抛去必要的第一步(把文件夹放到 chrome://extensions 中),我们需要解决的问题是如何自动完成点击扩展刷新按钮,并刷新浏览器(content scripts 的场景)。

解决 Chrome 扩展开发时的热更新问题

考虑到我们需要对 popup(点击插件时的弹出页面)、options(插件的配置页面) 等页面支持热更新,对 content scripts 支持自动重载扩展以及刷新页面,最终我们的方案是:

express + webpack-hot-middleware

采用这套方案的原因是:通过 express 搭建 Web server 以及中间件的能力,可以让我们解决不同的入口文件热更新需求不一致的问题。

确定这套基础方案后,首先我们需要解决本地开发文件持久化的问题。

在开发普通的网页的时候,Webpack 编译后的产物并没有直接输出到磁盘中,而是放到了计算机内存中,但 Chrome 扩展开发需要一个确定的磁盘文件,所以我们需要在本地开发时把编译产物写入磁盘:

app.use(
  require("webpack-dev-middleware")(compiler, {
    writeToDisk: true,
  })
);

非 content scripts 页面热更新

image3

从控制台报错我们可以看到,加载 _webpack_hmr 资源失败,那这个资源是谁请求的呢?

这个资源请求来源于 webpack-hot-middleware,因为我们期望我们的代码再开发时有热更新的能力,所以我们会把热更新的代码添加到非 content scripts 的 entry 上:

const hotMiddlewareScript = `webpack-hot-middleware/client`;

entry: {
  options: [path.resolve("src", "options"), hotMiddlewareScript],
  popup: [path.resolve("src", "popup"), hotMiddlewareScript],
}

因为我们开发网页使用的协议是 http,所以可以正常访问 _webpack_hmr 资源。

image4

看到这里,我们在 Chrome 扩展里加载 _webpack_hmr 资源失败的问题也有了解法:

const hotMiddlewareScript =
"webpack-hot-middleware/client?path=<http://127.0.0.1:3000/__webpack_hmr>;

content scripts 热更新

非 content scripts 可以使用 webpack-hot-middleware 来集成热更新的功能,那么 content scripts 是否也可以呢?

答案是:不行。

我们来看下 webpack-hot-middleware 是如何实现热更新的:

  1. 页面首次打开,客户端与服务端通过 EventSource 建立通信(收到的信息包括下一次次请求更新文件的 hash 值)
  2. 修改源代码后,Webpack 开始编译,编译成功后,发送消息给客户端
  3. 客户端通过之前的 hash 值依次请求 hot-update.json、hot-update.js 文件,调用网页的 JS 变量更新页面,实现热更新

hot-update.js 示例文件:

image2

通过 self(window) 访问 webpackHotUpdatechrome_extension_boilerplate 重新执行更新后的代码。

因为 content scripts 的特殊性(运行在一个隔离的 JavaScript 执行环境中)导致不能访问网页的 JS 变量,所以 content scripts 无法使用 webpack-hot-middleware 进行热更新。

了解了这个背景后,那我们如何实现 content scripts 的热更新呢?

所以针对 content scripts 我们主要是做一个省略操作的事情。使用原生 JS 开发 Chrome 扩展如果要看到更新后的效果,一般要执行两步:

  1. 刷新扩展
  2. 刷新网页

所以针对 content scripts 我们要做的事情就是用编码实现这两步的自动化。

既然客户端跟服务端可以通过 EventSource 建立通信,那么 content scripts 是否也可以呢?

当然可以。

针对 content scripts 我们需要修改第 3 步。我们需要改成通知 Chrome 扩展的 background 去调用 chrome.runtime.reload,再重载注入了 content scripts 的网页。

为什么选择 background 呢?

因为 background 在 Chrome 扩展中常驻后台运行,并且可以调用绝大部分的 chrome.* API,它同样也可以作为一个我们更新注入 content scripts 网页的桥梁。

因为我们使用 express 做 Web Server 的,所以我们可以用自定义中间件去连接 Chrome 扩展的 background:

const path = require("path");

const debounce = require("lodash/debounce");
const SseStream = require("ssestream").default;

const { SSE_ACTION, SSE_EVENT } = require("../constants");

function AutoReloadContentScript(compiler) {
  return (req, res, next) => {
    const sse = new SseStream(req);

    sse.pipe(res);

    const autoReloadPlugin = debounce((stats) => {
      const { modules } = stats.toJson({ all: false, modules: true });

      const updatedJsModules = modules?.filter(
        (module) =>
          module.type === "module" && module.moduleType === "javascript/auto"
      );

      const shouldReload =
        !stats.hasErrors() &&
        updatedJsModules?.some((module) =>
          module.nameForCondition?.startsWith(
            path.resolve("src", "content-scripts")
          )
        );

      if (shouldReload) {
        sse.write({
          data: {
            action: SSE_ACTION.RELOAD_EXTENSION_CONTENT_SCRIPT,
          },
          event: SSE_EVENT.CONTENT_SCRIPT_COMPILED_SUCCESSFULLY,
        });
      }
    }, 1000);

    compiler.hooks.done.tap("auto-reload-extension", autoReloadPlugin);

    next();
  };
}

module.exports = AutoReloadContentScript;

简单解释下这个中间件的工作流程。

新增一个 express 的路由 auto_reload_extension,通过 ssestream(Sever Side Event)发送 Webpack 编译 content scripts 成功的信令。

background 这边通过往 entry 数组中添加 background-auto-reload 监听 Server 传过来的信令:

const {
  AUTO_RELOAD_URL,
  SSE_EVENT,
  MESSAGE_FROM,
  MESSAGE_ACTION,
  SSE_ACTION,
} = require("../constants");
const log = require("logger").default.getLogger("entry/background-auto-reload");

const source = new EventSource(AUTO_RELOAD_URL);

source.addEventListener("open", () => {
  log.info("The connection has been established.");
});

source.addEventListener(
  SSE_EVENT.CONTENT_SCRIPT_COMPILED_SUCCESSFULLY,
  (event) => {
    const shouldReload =
      JSON.parse(event.data).action ===
      SSE_ACTION.RELOAD_EXTENSION_CONTENT_SCRIPT;

    if (shouldReload) {
      log.info("received the signal to reload chrome extension");

      chrome.tabs.query({}, (tabs) => {
        tabs.forEach((tab) => {
          if (tab.id) {
            chrome.tabs.sendMessage(
              tab.id,
              {
                action: MESSAGE_ACTION.RELOAD_CONTENT_SCRIPT,
                from: MESSAGE_FROM.BACKGROUND,
              },
              (res) => {
                if (chrome.runtime.lastError && !res) return;

                const { from, action } = res;

                if (
                  from === MESSAGE_FROM.CONTENT_SCRIPT &&
                  action === MESSAGE_ACTION.RELOAD_EXTENSION
                ) {
                  log.info("reload extension");
                  chrome.runtime.reload();
                }
              }
            );
          }
        });
      });
    }
  }
);

但这个 AUTO_RELOAD_URL 的地址是以 http 开头。

这跟 Chrome 扩展使用的协议不同,导致我们在使用的时候会有跨域问题:

image1

我们需要给 express 添加跨域能力:

const express = require("express");
const cors = require("cors");

const express = require("express");

app.use(cors());

那么收到 content scripts 更新成功事件后,如何通知注入了 content scripts 的网页呢?

const { MESSAGE_FROM, MESSAGE_ACTION } = require("../constants");
const log = require("logger").default.getLogger(
  "entry/content-scripts-auto-reload"
);

chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
  const shouldReload =
    request.from === MESSAGE_FROM.BACKGROUND &&
    request.action === MESSAGE_ACTION.RELOAD_CONTENT_SCRIPT;

  if (shouldReload) {
    sendResponse({
      action: MESSAGE_ACTION.RELOAD_EXTENSION,
      from: MESSAGE_FROM.CONTENT_SCRIPT,
    });

    // wait for the background script to reload the extension
    setTimeout(() => {
      log.info("reload content script");
      window.location.reload();
    }, 100);
  }
});

在网页我们的刷新策略是:收到更新事件后执行 window.location.reload

迁移已有的油猴脚本

回到做 Chrome 扩展的初衷,使用团队小伙伴编写的油猴脚本。既然油猴脚本可以理解成 content scripts,那么我们如何迁移这些油猴脚本呢?

因为大家写的脚本基本上是以改变原有页面上的 DOM 结构(往页面上新增一些操作按钮等)、访问网页 JS 变量为主。

针对这个情况我们可以用两个 JS 完成油猴脚本的迁移。第一个 index.js:

const s = document.createElement("script");

s.src = chrome.runtime.getURL("go-bus-script.js");

document.head.appendChild(s);

通过 chrome.runtime.getURL() 获取油猴脚本的内容,然后插入到网页中。这属于 content scripts 中的 inject scripts 的内容,因为 inject scripts 可以访问网页的 JS 变量。记得这个 inject scripts 的资源放入 manifest.json 的 web_accessible_resources 中:

"web_accessible_resources": [
  {
    "matches": ["<https://v.ringcentral.com/>*"],
    "resources": ["go-bus-script.js"]
  }
]

基于这个设计,像 **click-to-react-component(点击 React 组件定位到 VsCode)**这个功能也可以轻易集成:

import { ClickToComponent } from "ClickToComponent";
import ReactDOM from "react-dom/client";

const node = document.createElement("div");
const id = "click-to-react-component";

node.id = id;

document.body.appendChild(node);

const root = ReactDOM.createRoot(document.getElementById(id));

root.render(<ClickToComponent />);

这边的话我是用 Webpack resolve 了 ClickToComponent,因为 click-to-react-component 在 NODE_ENV 非 development 的时候会做 tree shaking,但我们的 Chrome 扩展希望打包后仍然有这些代码,所以需要 resolve 到真实的组件:

resolve: {
    alias: {
      ClickToComponent: path.resolve(
        "node_modules",
        "click-to-react-component",
        "src",
        "ClickToComponent"
      ),
    },
},

那么是否所有油猴脚本都做按这样迁移呢?

答案是:不可以。

因为油猴脚本可以使用一些独特的 API,比如 GM_addStyleGM_xmlhttpRequest 等等。我们这个 Chrome 扩展并没有赋予 content scripts 使用这些 API 的能力。

所以如果是使用了这些特殊 API 的油猴脚本,需要进行一定的改写。

总结

Chrome 扩展可以通过 content scripts 的形式收拢多个油猴脚本,让团队的小伙伴既可以使用模块化的方式进行开发,在后续新增脚本的时候也不用在油猴扩展上导入脚本。但 Chrome 扩展开发难度要高于油猴脚本开发,需要理解 popup、background、content scripts 等多个概念,也要处理好各个模块之间事件通讯的规范。

油猴脚本提升了我们的效率,Chrome 扩展把这些油猴脚本联系的更紧密,带给用户的体验也更好,这也是我们迁移油猴脚本脚本的初衷。

参考

本作品系原创,采用《署名-非商业性使用-禁止演绎 4.0 国际》许可协议