Skip to content

03 · 构建系统与多浏览器产物

同一份 TypeScript 最终要成为 Chrome MV3 service worker、Firefox background scripts 和 Safari Web Extension。构建系统承担的是“能力裁剪与资源组装”。

一、脚本矩阵

package.json 把构建分为三类:

text
扩展开发  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。配置先计算三个布尔量:

text
isFirefox
isSafari
isProduction

再由它们派生:

模式ChromeFirefoxSafari
developmentdev/dev_firefox/dev_safari/
productiondist/dist_firefox/dist_safari/
zip 名称...-chrome.zip...-firefox.zip...-safari.zip

三、产物组装流程

text
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/*.zip

CopyPlugin 把浏览器专属 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 把:

text
./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
  • sidePaneldeclarativeNetRequest 权限;
  • 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
storagesync 设置、local 高亮和历史
contextMenus保存、阅读、高亮右键动作
commands快捷键
clipboardWriteMarkdown/文本复制
sidePanelChrome 内嵌剪藏面板
declarativeNetRequestChrome YouTube 请求规则
webRequest*Firefox 的网络头/请求控制
nativeMessagingSafari 原生 fetch 桥

权限不是越少越好,而应能从源码反查每一项的必要性。这里大多数权限都有明确调用点。

九、静态资源与 CSP

web_accessible_resources 暴露 reader CSS/JS、side panel HTML、polyfill、flatten helper 和 highlighter CSS。它们需要能被网页上下文或注入脚本通过 runtime.getURL() 访问。

扩展页 CSP 为:

text
script-src 'self'; object-src 'self'

这意味着:

  • 不允许远程脚本;
  • 不允许 inline script/eval;
  • AI Interpreter 可以 fetch API,但供应商返回的文本不能当脚本执行;
  • SVG/HTML 注入必须经过显式 DOM 构造或清理。

十、CLI 与 API 的 esbuild 边界

API

scripts/build-api.mjssrc/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 漏改,商店包版本与文件名会不一致。

十二、构建检查清单

修改入口或资源时应检查:

  1. 新文件是否被 entry 或 CopyPlugin 收录;
  2. 页面脚本是否满足 CSP,无 inline/eval;
  3. 动态注入时依赖顺序是否正确;
  4. 三份 manifest 是否都声明了所需权限/资源;
  5. Firefox 是否需要 optional host permission;
  6. Safari 是否有 native messaging 降级;
  7. production 是否会被 DEBUG_MODE 消除必要代码;
  8. 三套扩展目录与三个 zip 是否都能生成。

基于 Obsidian Web Clipper 1.7.1 源码快照的独立学习笔记