一个长成型的 MV3 扩展,入口比想象中多。这个项目有五个,加上两个注入进页面主世界的脚本:

入口跑在哪管什么够得到宿主 DOM 吗
content script宿主页面的隔离世界(每个 frame 一份)95% 的 UI 与 DOM 逻辑✅
background(Service Worker)扩展自己的上下文轮询、跨域请求、消息中枢❌
options 页chrome-extension://设置❌
popupchrome-extension://账户与凭据录入❌
side panelchrome-extension://浏览器级侧栏❌
注入脚本宿主页面的主世界读页面 JS 对象(fiber、编辑器实例)✅

后三个都够不到宿主 DOM——它们在自己的源上,要读页面内容只能发消息给 content script。这条约束会一路影响架构,细节在内容脚本的三个世界。

manifest 用 TypeScript 写

@crxjs/vite-plugin 让 manifest 变成一个 .ts 文件,于是版本号能直接从 package.json 读——单一真源,不会漂。

src/manifest.ts
import { defineManifest } from '@crxjs/vite-plugin';
import pkg from '../package.json';
export default defineManifest({
manifest_version: 3,
name: '效率工具',
version: pkg.version, // ← 只在 package.json 改一处
// 自托管更新:Chrome 周期性拉这个 XML,里面写着最新版本号 + .crx 地址
update_url: 'https://example.com/updates.xml',
// ★ 固定扩展 ID = 这个公钥对应的那串。见下面那段说明
key: '<你的 .crx 私钥对应的公钥,SPKI DER base64>',
action: {
default_popup: 'src/popup/index.html',
// 工具栏图标显式给 16/24/32:不写的话 Chrome 拿最近的尺寸缩到 16,
// 高 DPI 屏还要再缩一次,线条会发虚
default_icon: { 16: 'icons/icon16.png', 24: 'icons/icon24.png', 32: 'icons/icon32.png' },
},
// ⚠ side_panel 一旦存在,Chrome 强制要求 default_path。
// 空对象 {} 会报「Manifest key is required」直接加载不了。
// 「只在某些标签页显示」靠 background 的 per-tab setOptions 控制,不是靠这里
side_panel: { default_path: 'src/sidepanel/index.html' },
options_ui: { page: 'src/options/index.html', open_in_tab: true },
background: { service_worker: 'src/background/index.ts', type: 'module' },
content_scripts: [ /* 见下 */ ],
// ★ content script 用 fetch 读扩展自带的文件,必须在这里声明,否则被拦
web_accessible_resources: [
{ resources: ['local-auth-whitelist.signed.json', 'icons/*'], matches: ['https://app.example.com/*'] },
],
permissions: ['storage', 'scripting', 'activeTab', 'cookies', 'alarms', 'sidePanel', 'tabs'],
host_permissions: ['https://app.example.com/*'],
});

那个 key 字段别省

不写 key 的话,「加载已解压」得到的是一个随机扩展 ID,而打包成 .crx 装的是私钥算出来的那个 ID。两个 ID 不一致,于是:

  • 自动更新对不上(updates.xml 里的 appid 是 CRX 那个);
  • 任何按扩展 ID 做的白名单、native messaging 配置,开发版和正式版都得配两份。

写死 key(私钥对应的公钥)之后,两种装法拿到的 ID 永远相同。

同理,update_url 定了就别改——改它等于换分发源,所有人都得重装。

content_scripts:四条声明,各有各的理由

content_scripts: [
{
// ① 自愈守卫:热更新 / 自动更新换了 chunk hash 之后,旧标签页的 content 入口
// 动态 import 旧 chunk 会失败并**静默挂掉**(页面上扩展全没了,也不报错)。
// 这个脚本监听该失败并自动刷新一次。必须最早、必须每个 frame
js: ['src/inject/reload-guard.ts'],
run_at: 'document_start',
all_frames: true,
match_about_blank: true,
},
{
// ② 主世界桥:读 React fiber、调页面全局对象
js: ['src/inject/fiber-bridge.ts'],
run_at: 'document_end',
all_frames: true,
match_about_blank: true,
world: 'MAIN',
},
{
// ③ 主入口。表单可能渲在嵌套 iframe / about:blank srcdoc 里,
// 所以 all_frames + match_about_blank 都不能省
js: ['src/content/index.ts'],
run_at: 'document_end',
all_frames: true,
match_about_blank: true,
},
{
// ④ 只需要在顶层帧干活的东西,就明确写 all_frames: false,别靠运行时判断
js: ['src/inject/ws-hook.ts'],
run_at: 'document_start',
all_frames: false,
world: 'MAIN',
},
],

match_about_blank: true 很容易漏。有些框架把表单渲进 srcdoc 或 about:blank 的 iframe,不写这个字段,你的脚本压根进不去那个 frame——而现象是「这个功能在某些页面就是不工作」。

all_frames: true 的代价是另一篇的话题:一个页面五个实例,模块级变量锁不住任何东西。

bootstrap 的顺序是有讲究的

content script 的启动顺序被坑过,现在是固定的:

src/content/index.ts
async function bootstrap(): Promise<void> {
// 1) 第一件事:打构建标记 + 当前在哪个 frame。
// 走 debug 级(默认关)—— 页面 iframe 多,每个 frame 都打一条属于噪音
log.debug(BUILD_TAG, isTopFrame() ? '(top)' : `(iframe ${location.pathname})`);
// 2) 配置必须先就绪:后面所有读配置的地方都假定它已经 init 过
const cfg = await configClient.init();
// 3) ★ 布局相关的东西立刻启动,并且单独 try/catch
// 原来它在最下面,结果被后面某个 await 卡住 → 表现为「面板不挂载」
try { startLayoutTweaks(); } catch (e) { log.error('startLayoutTweaks error:', e); }
// 4) 仅顶层帧:工具栏、监控桥、自动回复……每个都单独 try/catch
if (isTopFrame()) {
try { mountHeaderOrb(); } catch (e) { log.error('mountHeaderOrb error:', e); }
try { startMonitorBridge(); } catch (e) { log.error('startMonitorBridge error:', e); }
try { await initAutoReply(); } catch (e) { log.error('initAutoReply error:', e); }
}
// 5) 键位注册:**所有 frame**。焦点可能落在任意 iframe 里
keybindingManager.register(/* … */);
keybindingManager.install();
}

两条经验:

① 一个 await 能卡住它后面的一切。 第 3 步原来排在最后,某次 initAutoReply 里的一个网络请求变慢,表现出来是「监控面板不挂载」——两件毫不相干的事。把不依赖异步的东西提到前面,这类耦合就没了。

② 每个模块单独 try/catch,而且用 error 级别。 启动失败必须看得见——这是少数几类「调试开关关着时用户也该看到」的日志之一。一个模块挂了不该让其它模块也不启动。

npm run build 通过 ≠ 类型没错

这是这套工具链里最值得单独说的一条。

{
"build": "... && vite build && ...",
"typecheck": "tsc --noEmit"
}

vite build 走 esbuild,而 esbuild 只剥类型、不做类型检查。

所以你可以有一个类型完全错误的项目,npm run build 一路绿灯,打出来的包也能跑(直到运行到那行为止)。

我们曾经攒了 12 处类型错误而毫无察觉,因为本地只跑 build。真正发现是在把提交摘到干净基点上的时候——那时才炸出一串 TS2353 / TS2741。

所以 CI 必须同时 gate 两个:

.github/workflows/build.yml
- run: npm run build
# 类型检查:存量债清零后提升为硬 gate —— 新增类型错误直接 fail CI
- run: npm run typecheck

build 和 typecheck 是两件事。 只跑前者,你的类型系统等于没开。

CI 还有一个小坑:生成物不入库

有个文件是构建脚本生成的、体积大、不入库。但 manifest.ts 把它声明成了 web_accessible_resources——打包时找不到文件会直接 ENOENT,构建失败。

CI 里补一个占位就行,但要让它在运行时是安全的:

- name: ensure placeholder (CI only)
run: |
if [ ! -f public/private-kb.json ]; then
echo '{"docs":[],"chunks":[]}' > public/private-kb.json
fi

占位内容要是一个「合法的空」而不是 {}——运行时读到 chunks: [] 会安全跳过,读到缺字段的对象则可能在解构时抛错。CI 是验证构建的,不该引入一个只在 CI 里存在的运行时形态。

一条命令发版,每一步都有闸

{
"package": "npm run build && node scripts/pack.mjs",
"release": "npm run package && node scripts/deploy.mjs && node scripts/changelog-pb.mjs push && node scripts/announce-pb.mjs release && node scripts/notify-group.mjs release"
}

build 本身也串了几个脚本:配色检查 → README 版本号注入 → vite build → 裁剪包内更新日志。

凡是「漏了会静默出错」的事,都做成闸门。 比如打包时会检查更新日志里有没有当前版本的条目,没有就直接失败——因为漏写更新日志从来不会报错,结果是用户装完看不到「这次改了什么」,而那恰恰是他最该看见的一句话。

目录

src/
manifest.ts # 单一真源,版本从 package.json 读
content/
index.ts # bootstrap
modules/<域>/ # 按功能域分,一个域一个目录
shared/ # 跨域的纯技术助手(DOM、日志、配置、键位)
ui/ # shadow DOM 组件与设计 token
background/
index.ts # 顶层同步注册所有监听器
<域>/
inject/ # 主世界脚本
options/ popup/ sidepanel/
types/config.ts # AppConfig / DEFAULT_CONFIG / CONFIG_VERSION

路径别名 @/ → src/。

按功能域分目录这件事的收益比想象中大:文档也按同样的域拆,一个域一个 docs/<域>.md。改哪个功能就读哪份文档,不用在一个上万行的总文档里找。多人(或多会话)并行时,尽量改不同的域,冲突自然就少。


这套骨架里真正值钱的只有两样:manifest 的单一真源,和 CI 同时 gate build 与 typecheck。

其余的目录结构、模块划分,怎么顺手怎么来——但这两条一旦少了,你会在某个很远的地方以很贵的方式发现。