背景
我们团队的小伙伴写了许多的油猴脚本,通过它们的帮助我们减少了许多重复性的工作,让我们可以聚焦更加有意义的事情。但我们团队在油猴脚本上也遇到了一些问题,比如:
- 使用原生 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 页面热更新
从控制台报错我们可以看到,加载 _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 资源。
看到这里,我们在 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 是如何实现热更新的:
- 页面首次打开,客户端与服务端通过 EventSource 建立通信(收到的信息包括下一次次请求更新文件的 hash 值)
- 修改源代码后,Webpack 开始编译,编译成功后,发送消息给客户端
- 客户端通过之前的 hash 值依次请求 hot-update.json、hot-update.js 文件,调用网页的 JS 变量更新页面,实现热更新
hot-update.js 示例文件:
通过 self(window) 访问 webpackHotUpdatechrome_extension_boilerplate 重新执行更新后的代码。
因为 content scripts 的特殊性(运行在一个隔离的 JavaScript 执行环境中)导致不能访问网页的 JS 变量,所以 content scripts 无法使用 webpack-hot-middleware 进行热更新。
了解了这个背景后,那我们如何实现 content scripts 的热更新呢?
所以针对 content scripts 我们主要是做一个省略操作的事情。使用原生 JS 开发 Chrome 扩展如果要看到更新后的效果,一般要执行两步:
- 刷新扩展
- 刷新网页
所以针对 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 扩展使用的协议不同,导致我们在使用的时候会有跨域问题:
我们需要给 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_addStyle、GM_xmlhttpRequest 等等。我们这个 Chrome 扩展并没有赋予 content scripts 使用这些 API 的能力。
所以如果是使用了这些特殊 API 的油猴脚本,需要进行一定的改写。
总结
Chrome 扩展可以通过 content scripts 的形式收拢多个油猴脚本,让团队的小伙伴既可以使用模块化的方式进行开发,在后续新增脚本的时候也不用在油猴扩展上导入脚本。但 Chrome 扩展开发难度要高于油猴脚本开发,需要理解 popup、background、content scripts 等多个概念,也要处理好各个模块之间事件通讯的规范。
油猴脚本提升了我们的效率,Chrome 扩展把这些油猴脚本联系的更紧密,带给用户的体验也更好,这也是我们迁移油猴脚本脚本的初衷。
参考
- https://github.com/ericclemmons/click-to-component
- https://zhuanlan.zhihu.com/p/103072251
- https://developer.chrome.com/docs/extensions/
本作品系原创,采用《署名-非商业性使用-禁止演绎 4.0 国际》许可协议