03 · 构建系统与多浏览器产物
同一份 TypeScript 最终要成为 Chrome MV3 service worker、Firefox background scripts 和 Safari Web Extension。构建系统承担的是“能力裁剪与资源组装”。
一、脚本矩阵
package.json 把构建分为三类:
扩展开发 dev:chrome / dev:firefox / dev:safari
扩展发布 build:chrome / build:firefox / build:safari / build
npm 出口 build:cli / build:api / prepublishOnly扩展走 webpack,CLI/API 走 esbuild 脚本。原因很直接:扩展需要多入口、复制静态资产和按浏览器替换 manifest;库与 CLI 更适合单文件/少文件快速打包。
二、webpack 的环境开关
构建命令传入 --env BROWSER=chrome|firefox|safari 与 --mode。配置先计算三个布尔量:
isFirefox
isSafari
isProduction再由它们派生:
| 模式 | Chrome | Firefox | Safari |
|---|---|---|---|
| development | dev/ | dev_firefox/ | dev_safari/ |
| production | dist/ | dist_firefox/ | dist_safari/ |
| zip 名称 | ...-chrome.zip | ...-firefox.zip | ...-safari.zip |
三、产物组装流程
TypeScript ── ts-loader ───────────┐
SCSS ─────── sass-loader/css-loader├─ webpack entries → *.js / *.css
│
manifest.<browser>.json ───────────┤
HTML / icons / locales ─ CopyPlugin├─ output directory
polyfill / flatten helper ─────────┤
└─ ZipPlugin → builds/*.zipCopyPlugin 把浏览器专属 manifest 统一改名为 manifest.json。运行时因此不需要知道自己来自哪份源 manifest。
四、为什么保留多个独立 bundle
配置没有把所有 entry 合并成共享 SPA。每个运行域拿自己的 bundle:
- content script 不应携带 settings UI;
- background 不应依赖 DOM;
- reader-script 必须可按需注入;
- extension pages 各自受 CSP 和生命周期约束;
- 不同 bundle 允许浏览器只加载当前功能。
代价是共享模块可能被重复打包,highlighter.ts 的双实例问题就是例子。项目通过 runtime bridge 处理状态,而不是强制共享 chunk,因为 content/reader 的加载边界更重要。
五、TypeScript 与 polyfill alias
webpack resolve 把:
./utils/browser-polyfill
../utils/browser-polyfill都 alias 到 webextension-polyfill.min.js。这让源码统一写 Promise 风格的 browser.*,不必散布 chrome.* 回调分支。
为什么还把 polyfill 复制成独立文件?manifest content script 与动态 injection 都要求它在业务 bundle 前执行;显式资产比依赖 webpack chunk 顺序更可控。
六、生产压缩的特殊选择
Terser 配置值得逐项看:
mangle: false:不混淆标识符,便于扩展商店排查与堆栈阅读;passes: 2:做两轮压缩;global_defs.DEBUG_MODE:生产构建把 debug 分支静态消除;keep_classnames/keep_fnames: true:保留函数与类名;ascii_only: true:减少部分浏览器/打包渠道字符问题;comments: false:产物不保留注释。
这是偏向可诊断性的生产策略:压缩体积,但不牺牲堆栈中的语义名字。
七、三份 Manifest 的关键差异
Chrome
- MV3
service_worker; sidePanel与declarativeNetRequest权限;side_panel.default_path;quick_clip快捷键;- content script 自动匹配 HTTP/HTTPS。
Firefox
- MV3,但 background 使用
scripts数组; webRequest/webRequestBlocking;- 额外
optional_host_permissions; - Gecko ID、最低版本与
data_collection_permissions: none; - 没有 side panel 和 quick clip 声明。
Safari
- MV3 service worker;
nativeMessaging权限;- 不声明 default locale;
- action/快捷键文案是直接字符串,而不是
__MSG_*__; - 原生宿主可提供 URLSession fetch 降级。
八、权限如何对应代码
| 权限 | 使用点 |
|---|---|
activeTab | 查当前 tab、临时操作页面 |
scripting | 动态注入 content/reader JS 与 CSS |
storage | sync 设置、local 高亮和历史 |
contextMenus | 保存、阅读、高亮右键动作 |
commands | 快捷键 |
clipboardWrite | Markdown/文本复制 |
sidePanel | Chrome 内嵌剪藏面板 |
declarativeNetRequest | Chrome YouTube 请求规则 |
webRequest* | Firefox 的网络头/请求控制 |
nativeMessaging | Safari 原生 fetch 桥 |
权限不是越少越好,而应能从源码反查每一项的必要性。这里大多数权限都有明确调用点。
九、静态资源与 CSP
web_accessible_resources 暴露 reader CSS/JS、side panel HTML、polyfill、flatten helper 和 highlighter CSS。它们需要能被网页上下文或注入脚本通过 runtime.getURL() 访问。
扩展页 CSP 为:
script-src 'self'; object-src 'self'这意味着:
- 不允许远程脚本;
- 不允许 inline script/eval;
- AI Interpreter 可以 fetch API,但供应商返回的文本不能当脚本执行;
- SVG/HTML 注入必须经过显式 DOM 构造或清理。
十、CLI 与 API 的 esbuild 边界
API
scripts/build-api.mjs 把 src/api.ts 打成 ESM dist/api.mjs。它要求调用方传入 DocumentParser,从而不把浏览器 DOM 或 Node DOM 写死在核心里。
CLI
scripts/build-cli.mjs 打成 CommonJS dist/cli.cjs,并用 linkedom 提供 DOM。CLI 还需要 shebang、Node 内置模块与 fetch。
二者使用同一 clip() 核心,但交付形态不同:API 给程序组合,CLI 给 shell 管道。
十一、版本一致性
版本同时出现在 package.json 和三份 manifest 中。scripts/bump-version.sh 的存在说明版本更新应被视为一个原子操作。ZipPlugin 直接读取 package version 命名产物;若 manifest 漏改,商店包版本与文件名会不一致。
十二、构建检查清单
修改入口或资源时应检查:
- 新文件是否被 entry 或 CopyPlugin 收录;
- 页面脚本是否满足 CSP,无 inline/eval;
- 动态注入时依赖顺序是否正确;
- 三份 manifest 是否都声明了所需权限/资源;
- Firefox 是否需要 optional host permission;
- Safari 是否有 native messaging 降级;
- production 是否会被
DEBUG_MODE消除必要代码; - 三套扩展目录与三个 zip 是否都能生成。