从DOM抽取到MV3消息协议:一套浏览器生产力扩展的全栈实践

📅 发布时间:2026/10/20 21:59:41
从DOM抽取到MV3消息协议:一套浏览器生产力扩展的全栈实践
浏览器大概是这个时代最被低估的运行时。Omni Suite 这套六款插件的合集覆盖内容剪藏、标签管理、自动填充、专注拦截、页面批注和密码存储六个方向目标就是把浏览器变成一台真正能干活的生产力主机。支撑它的核心技术栈横跨 DOM 抽取、消息通信、加密存储、声明式网络拦截、Shadow DOM 隔离等一整片扩展工程的关键环节。读到这里你可能会有疑问市面上单功能的效率插件一抓一大把为什么还要做一套六件套因为单点工具解决的是局部问题而“生产力”是一个由采集、整理、录入、检索、防护组成的完整链路。Omni 的设计核心不是把六个独立插件打包而是让每个插件成为同一条流水线上的一道工序共享消息协议、共享数据层、共享设置体系最终把散落在日常浏览里的重复劳动收编成可复用的工序。这篇文章适合正在做浏览器扩展的开发者也适合想自己动手搭一套效率工具的折腾型用户。我不会只讲“能做什么”而是把每个插件的关键技术决策和踩过的坑拆开讲让你看完之后至少能对着源码复现出一个简化版本。1. 整体架构与核心技术栈选型逻辑1.1 为什么是浏览器而不是独立桌面套件先聊一个基础问题既然要做生产力工具为什么选浏览器扩展而不是直接做桌面应用我的判断标准是三个词启动成本、权限边界、分发成本。浏览器扩展不需要安装一个常驻托盘程序用户打开浏览器就能用权限模型是声明式的不会出现桌面软件那种一上来就要管理员权限的尴尬分发走扩展商店更新、版本回退都便宜。对个人开发者和中小团队来说这三条足够构成选型理由。但浏览器环境也不是没有代价。扩展跑在一个高度受限的沙箱里后台脚本随时可能被系统回收跨标签通信要经过消息通道DOM 操作还受页面环境制约。这些限制恰恰决定了后续每一项技术方案的选择。比如我们选 Manifest V3 而不是旧版 Background Page就是因为 MV3 是当前主流浏览器的既定方向虽然 Service Worker 的生命周期更苛刻但长远来看生态和文档都会向它倾斜。后面第 5 章我会专门讲这块的坑。1.2 六款插件如何拼成一张生产力网络逐个介绍之前先把它们的关系捋清楚。Omni Suite 的六个成员并不是“六个独立的开关”而是围绕一条典型工作流展开的Omni Clip负责“收”。看到有价值的文章一键抽取正文转成 Markdown / HTML 快照存进本地库。Omni Tabs负责“管”。把几十个标签页变成可检索、可分组、可恢复的工作区替代“堆标签”这种低效习惯。Omni Fill负责“填”。把测试账号、常用收货信息这类高频录入内容做模板化填充减少重复劳动。Omni Focus负责“守”。在指定时间段屏蔽干扰站点记录专注时长守住注意力。Omni Mark负责“记”。在任何页面上做高亮和批注下次打开还能看到相当于给网页加了一层个人注释。Omni Vault负责“锁”。敏感信息用本机加密存储只在需要时解密填充默认不出本地。这里有个容易被忽视的点单独看每个插件市面都有同类产品但 Omni 的价值在于它们共享同一套消息协议和设置面板。比如你在 Omni Mark 里批注了一篇文章顺手就能把链接推给 Omni Clip 生成剪藏Omni Clip 剪藏过的页面在 Omni Tabs 里会有独立标记。这种联动不靠写死逻辑而是靠事件总线解耦第 3 章会展开讲。1.3 技术底座Manifest V3 与 TypeScript 的选型理由整个项目的基础层只做了三件事统一规范、统一语言、统一构建。Manifest V3 是目前 Chrome、Edge 等主流浏览器默认支持的扩展规范。它把原来的 Background Page 换成了 Service Worker意味着后台代码不能常驻内存状态必须持久化到浏览器存储同时网络请求拦截被限制成声明式规则不能用旧式的 webRequest 无限制监听。这套约束初看是束缚实际上逼着你把所有逻辑拆成可恢复的小任务对长期维护是好事。统一语言选 TypeScript理由是六个插件共享的领域类型太多。比如标签页工作区的结构、剪藏正文的元信息、批注的 Range 坐标、消息请求的 payload这些如果各写各的很容易出现“改一个字段要全局搜索替换”的场面。TypeScript 的 interface 和 discriminated union 能把这些契约固化下来编译期就发现问题。构建层我们用的是 esbuild 直接做多入口打包没有上完整 Vite 全家桶。扩展项目的产物是多个独立入口background、多个 content script、popup、optionsesbuild 的 watch 模式足够快配置也简单。后面第 4 章我会给出一个可以照抄的最小工程配置。2. 六款插件核心功能拆解与技术实现这章是全文的重头戏我会按住每个插件的核心矛盾来讲它到底要解决什么问题技术上怎么选型哪些地方必须踩坑才能过去。2.1 Omni Clip从 DOM 抽取到正文算法Omni Clip 要解决的第一个问题是“如何从一篇乱七八糟的网页里抽出干净的正文”。直接拿 innerHTML 存下来显然不行——产物里全是脚本、样式和无关的侧边栏根本没有二次阅读的价值。我们用的方案是经典的文本密度算法变体。大致思路是遍历 DOM 树给每个元素节点计算一个分数分数由三个因子组成——文本总长度、链接文本占比、标点密度。正文块通常文本长、链接少、标点多导航栏和评论区则是链接多、文本碎片化。分数最高的那块区域被选为主体容器然后在这个容器里继续做清洗删掉 script、style、noscript、隐藏元素以及密集的小图片。下面是简化后的核心逻辑这个量级的代码已经能在多数文章页面上跑出可用结果function scoreNode(node: HTMLElement): number { const text node.innerText || ; const linkText Array.from(node.querySelectorAll(a)) .map(a a.innerText || ) .join(); if (!text.trim()) return -Infinity; const textLen text.length; const linkRatio linkText.length / Math.max(textLen, 1); const puncCount (text.match(/[。,.!?;:]/g) || []).length; return textLen * (1 - linkRatio) puncCount * 2; }抽取正文之后还需要处理图片。原页面里大量是懒加载图片直接读 src 是不行的要去看>const setter Object.getOwnPropertyDescriptor( HTMLInputElement.prototype, value )?.set; setter?.call(input, targetValue); input.dispatchEvent(new Event(input, { bubbles: true })); input.dispatchEvent(new Event(change, { bubbles: true }));最后是模板安全。Omni Fill 坚决不做“自动读取当前页面已有内容”所有模板数据由用户显式录入或者从剪藏内容里导入。填密码场景默认走 Omni Vault不在这里做明文存储。这些边界从设计之初就要划清楚不然很容易变成灰色工具。2.4 Omni Focus拦截干扰源的底层实现“专注”听起来很玄落到技术上其实就是两件事阻止访问干扰站点并且统计“今天到底专注了多久”。屏蔽站点这块MV3 下最优方案是 declarativeNetRequest。它让浏览器内核直接拦截请求扩展的后台 Service Worker 完全不需要参与比旧版的 webRequest 方案省电省内存。配置时要用 DNR 规则去匹配目标域名规则是静态 JSON粒度可以细到 path 级别也支持允许某些子域名放行。定时调度则用 chrome.alarms。MV3 的 Service Worker 随时可能休眠setTimeout 不可靠而 alarms 能由浏览器在指定时间唤醒扩展。这里有个约束alarms 的最小周期是 1 分钟所以不能用它做秒级精度的事。我们对“开始专注”这类需求完全可以接受分钟级误差真正要算时长时用 alarm 触发一次心跳把当前时间戳记到 storage 里就行。还有一个细节容易被忽略屏蔽页面不能只弹一个丑陋的提示页。我们做了两层一层是 DNR 规则直接把请求拦截成 net::ERR_BLOCKED_BY_CLIENT另一层是 content script 在页面顶部覆盖一个专注提示条用户可以在这里临时放行 15 分钟。临时放行的实现是在 storage.local 里写一个过期时间戳DNR 规则动态地允许对应域名到期后由下一次 alarm 清除。2.5 Omni MarkShadow DOM 批注与 IndexedDB 存储给网页做批注最大的技术挑战是“既不污染原页面又能稳定还原”。直接用 DOM 操作在原页面里插高亮节点页面一刷新就全没了而且样式容易被页面自身的 CSS 覆盖。所以 Omni Mark 选择把整个批注图层挂在一个自定义元素内部用 Shadow DOM 隔离const host document.createElement(omni-mark-root); const shadow host.attachShadow({ mode: open }); // shadow 内部自己加载 CSS页面样式无法渗透进来 document.documentElement.appendChild(host);这样做的好处很明显所有高亮颜色、批注气泡、按钮样式都封闭在 shadow 树里不会和页面的样式打架坏处是坐标还原逻辑要自己负责。定位一个文本片段时我们用 TreeWalker 遍历可见文本节点找到匹配文本后记录 Range 的三要素容器节点路径、起始偏移、结束偏移。保存时把节点路径转换成类似/html/body/div[2]/p[1]的 XPath关闭页面后根据路径重新还原。但页面只要稍微改版XPath 就崩了。所以 Omni Mark 在还原时做了两级匹配先按精确 XPath 找找不到就降级为文本模糊匹配取页面中相似度最高的那一段。这个策略失误率还是有的我在使用中最大的体会是批注功能不要承诺“永久有效”把“页面改版后可能丢失部分批注”写在设置页里用户的预期管理比技术完美更重要。数据存储按 hostname 加 path 分桶放进 IndexedDB每条批注存一个 id、Range 引用、高亮颜色和备注文本。IndexedDB 的好处是大数据量下依然有索引查询能力用 keyPath 直接按 URL 拉取当前页面的全部批注比 localStorage 的 JSON 全量读改写高效得多。2.6 Omni VaultWeb Crypto 与安全模型设计密码和敏感信息是另一类问题它不需要好看的界面需要的是可信的安全模型。Omni Vault 的主密码机制是用户第一次输入主密码时用 PBKDF2 把密码和随机盐一起派生出一个 AES-GCM 的对称密钥之后所有敏感条目都用这个密钥加密密文落盘到 chrome.storage.local。主密码本身不存任何地方每次解锁时重新派生派生成功后能解出任一密文就视为验证通过。const keyMaterial await crypto.subtle.importKey( raw, new TextEncoder().encode(masterPassword), PBKDF2, false, [deriveKey] ); const key await crypto.subtle.deriveKey( { name: PBKDF2, salt, iterations: 600000, hash: SHA-256 }, keyMaterial, { name: AES-GCM, length: 256 }, false, [encrypt, decrypt] );选 PBKDF2 而不是 bcrypt/scrypt主要是 Web Crypto API 原生支持不需要额外加密库在扩展环境里少一个依赖就少一块攻击面。迭代次数我建议设置成 60 万次左右老机器解锁时能感受到一点延迟但不至于无法接受。自动填充密码走的是和 Omni Fill 一样的原生 setter 方案但前置条件更严格必须用户点击了工具栏按钮或右键菜单且扩展处于解锁状态。锁定时所有明文 key 从内存中清除密文还留在磁盘上但无法解密。这里提醒一句任何“云同步密码库”的功能都不要加一旦引入服务端同步安全模型就不再是“本地零知识”了审计和背书成本会高一个数量级。3. 消息通道、状态同步与数据持久化设计六个插件放在同一个扩展工程里最怕的就是各自为战。这一章聊整体数据层怎么组织。3.1 content-script 与 background 间的消息协议content script 和 background Service Worker 之间不能直接共享变量只能通过 chrome.runtime.sendMessage 或 chrome.tabs.sendMessage 通信。我们设计了一个统一的 MessageEnvelope 结构所有消息都走这一个格式type OmniMessage { type: string; payload: unknown; source: clip | tabs | fill | focus | mark | vault; requestId: string; };source 字段用来标记消息来源requestId 用来把异步回调包装成 Promise。background 里维护一个简单路由表type 字段的第一段决定交给哪个模块处理。这样设计的好处是新增一个插件或者新增一个消息类型只需要在路由表里加一行不会出现一个 background 函数被越写越长的问题。实际使用中有个体验相关的细节不要在消息里传很大的数据。content script 从页面里抽出来的长文如果直接塞进 message 里序列化和反序列化的开销会肉眼可见。我们约定超过 100KB 的数据一律先写入 IndexedDB消息里只带一个 resourceId接收方再按 id 读取。这个规则在剪藏长文章时特别有用。3.2 数据分域与持久化策略存储选型这块我把规则总结成一张表直接照着选就行用途存储方案原因全局设置、工作区列表chrome.storage.sync可以跨设备同步但容量有限剪藏快照、批注内容IndexedDB容量大支持索引和分页加密后的敏感数据chrome.storage.local避免云同步密文留在本地临时状态、LRU 缓存内存变量高频读写不落盘窗口关闭可丢弃chrome.storage.sync 虽然能同步但单条数据有 8KB 左右的限制别拿它存长文。chrome.storage.local 没有云同步但它也不是给大数据设计的大量二进制数据依然要走 IndexedDB。批注和剪藏放 IndexedDB 之后查询和删除都方便用 hostname 做 keyPath 可以直接捞出一整个域名的所有内容。有一个常见误区是“所有数据都要持久化”。比如标签页的最近访问排序每次切换标签都写一次 storage看起来无害实际上后台 Service Worker 被唤醒的频率会明显上升浪费电不说还容易触发浏览器的性能警告。这类临时状态就放内存浏览器关了重开重新生成一份即可。3.3 六插件之间如何联动联动不是写“Omni Clip 调用 Omni Mark”这种硬编码调用而是通过一个轻量事件总线。每个插件只做两件事发布自己产生的事件、订阅自己关心的事件事件类型在共享的 types.ts 里统一声明。举两个实际发生的例子。你在 Omni Mark 里对一篇长文做了批注Mark 模块发布一个note.upsert事件payload 里带当前页面的 URL。Omni Clip 订阅了这个事件后发现该 URL 还没有剪藏记录就在工具栏图标上亮一个小红点提示“是否把这篇批注过文章一并剪藏”。第二个例子是 Omni Tabs 保存工作区时会发布workspace.save事件Omni Focus 收到后可以问一句“要在这个工作区进入专注模式吗”直接把一批标签页设为白名单。这套机制的实现大概只有两百行代码background 里放一个 Map 存订阅者消息到达后按 type 分发给对应回调回调里再做业务逻辑。事件总线的价值不在于复杂设计而在于让六款插件各自保持独立移除某个模块时其余五个不会崩。4. 工程化落地搭建构建流水线与关键代码前面讲的都是设计这章给一份可以照着搭的工程配置。至少能跑起一个扩展骨架后续往里填业务就行。4.1 脚手架与依赖选型依赖我选了三件TypeScript、esbuild、一个用于生成 zip 包的脚本。没有引入完整脚手架因为扩展工程的结构其实非常固定background、content scripts、popup、options 四个入口esbuild 的多入口配置刚好对得上。目录结构大致是这样omni-suite/ src/ background/ index.ts router.ts content/ clip/ mark/ fill/ popup/ index.html index.ts options/ index.html index.ts shared/ types.ts storage.ts manifest.ts build.ts package.jsonmanifest 用 TS 文件生成的好处是可以直接引用 package.json 里的版本号也可以按环境切换静态规则文件。esbuild 的配置文件很薄主要是定义 entryPoints 和 outdir还有把 public 下的静态资源一并拷贝到 dist。在 extension 场景不怎么需要代码分割单入口打包清晰明了。4.2 关键模块代码展开manifest 这边最要紧的是权限最小化。不要上来就写all_urls尽量用host_permissions声明具体站点这样用户在安装时看到的权限提示会友好很多。下面是一个最小可用的 MV3 manifest{ manifest_version: 3, name: Omni Suite, version: 0.1.0, background: { service_worker: background.js }, action: { default_popup: popup.html }, permissions: [tabs, storage, declarativeNetRequest, alarms], host_permissions: [*://*/*] }service worker 里加一个最基础的消息路由只有几十行但已经能把六款插件需要的消息通道跑通import type { OmniMessage } from ../shared/types; const handlers new Mapstring, (payload: unknown) Promiseunknown(); export function registerHandler(type: string, handler: (payload: unknown) Promiseunknown) { handlers.set(type, handler); } chrome.runtime.onMessage.addListener((msg: OmniMessage, _sender, sendResponse) { const handler handlers.get(msg.type); if (!handler) { sendResponse({ ok: false, error: unknown type: ${msg.type} }); return false; } handler(msg.payload) .then((data) sendResponse({ ok: true, data })) .catch((error) sendResponse({ ok: false, error: String(error) })); return true; // 保持消息通道等待异步响应 });这段代码在真实项目里会被反复用到。注意 onMessage 监听器必须返回 true 才能支持异步 sendResponse否则扩展会在回调执行完就关闭通道异步结果永远送不回去。这是个特别容易踩的坑。4.3 调试、发布与版本发布要点调试阶段用 chrome://extensions 的“加载已解压的扩展程序”指向 dist 目录。esbuild watch 模式改了代码会自动重新编译开发体验接近前端项目。有条件的话加一个 Chrome 的自动化测试脚本用 Playwright 启动带指定扩展的浏览器上下文真的去某个后台表单上跑一遍填充比手工反复操作靠谱得多。发布前有三件事必须检查。第一件权限列表是不是还有冗余项每多一个权限审核和用户信任都打折扣。第二件content script 里有没有直接访问页面全局变量的代码如果有要么用world: MAIN单独配一个脚本要么通过消息传给 background 处理避免和页面互相污染。第三件静态资源有没有被 guard 在扩展自身的 CSP 允许列表内尤其当你打算用 web accessible resources 给 pages 注入 UI 时路径声明必须和 manifest 完全一致。版本发布我用的是一个很简单的 CI 脚本esbuild 构建 → 校验 manifest JSON → 按版本号生成 zip 并上传到商店。扩展商店审核虽然不复杂但每次更新都要走一遍流程所以能合并的修改尽量合并成一个版本发布不要为一个小 bug 频繁发版。5. 常见问题与排查技巧实录最后这部分是我实打实踩过的一些坑按问题类型整理成速查方便直接对照排查。5.1 MV3 迁移与服务 Worker 保活MV3 最大的坑是 background 变成 Service Worker 后不能常驻。以前在 Background Page 里放个全局数组存缓存脚本被挂起再恢复后数据全没了。排查思路也简单把关键状态全部落到 chrome.storage.local启动时先 restore再决定后续逻辑。Service Worker 被系统回收是设计行为不要试图用轮询阻止回收那只会让浏览器更积极地杀它。在后台跑定时任务时chrome.alarms 是最可靠的。我在早期版本用过 setInterval实测在休眠几分钟后定时器会整体丢事件。换成 alarms 之后心跳和时段检查都正常了。记住 alarms 最小间隔是一分钟需要更快频率的只能拆到 content script 里做或者在页面可见时再补一次校验。5.2 性能问题与内存泄漏内容脚本在复杂的页面里最容易出问题的是全局事件监听。比如给 document 加了滚动监听又忘记在页面卸载时移除单页应用内部跳转几次旧页面的事件监听器可能还活着内存曲线直线上升。这类问题用 Chrome 的 Performance 面板看 listener 数量很容易定位养成在 content script 里使用 AbortController 或者统一在 destroy 函数里移除监听的习惯。正文抽取算法也不要在每个页面都跑一遍。只有用户主动点击剪藏按钮或者页面 URL 匹配了预定义规则时才触发。可以在注入脚本里加一个 data 属性标记表示“这个页面的剪藏模块已经就绪”避免多次注入。Shadow DOM 批注多了以后渲染会变慢。批注列表组件保持虚拟滚动或者至少用 IntersectionObserver 按视口懒加载批注气泡。我在实际项目里发现超过两百条批注时全量 append 会让页面明显卡顿换成分段渲染后流畅度提升非常明显。5.3 跨浏览器兼容与权限提示优化不同浏览器对 MV3 的支持程度有差异。比如同样一套 declarativeNetRequest 规则有的浏览器会在静态规则数量超过阈值时拒绝加载有的则直接忽略chrome.tabGroups 在部分浏览器上存在 API 时序问题。最佳策略是写一层 capability 检测启动时查一下chrome.tabGroups是否存在不存在就降级为不恢复分组而不是直接报错。权限提示直接影响安装转化率。如果插件一上来就问“读取所有网站的数据”很多用户会直接关掉。建议把权限拆开核心功能需要的 host_permissions 精确到具体域名其他域名通过 optional_host_permissions 动态申请等用户真正用到了那个功能再授权。比如桌面版的用户很可能不需要剪藏就没必要在所有域名下都保留 content script 的注入权限。调试跨浏览器问题时多装一个其他浏览器开发版并行测试比单浏览器反复改代码再猜原因效率高得多。每改一个 API 调用就把另一个浏览器的错误控制台和 background 日志都打开对照输出能省掉大量排查时间。我就是把 Omni Suite 从零到一搭起来的那个开发者整个项目做下来最大的体会是浏览器扩展真不是一个“小玩具”它更像一个运行在高度受限环境里的分布式系统。每一款插件单独看都不算复杂难的是让它们在一个共享协议上协同工作还要顶着 Service Worker 随时休眠、页面环境不可控、存储容量有限这些约束把体验做好。如果看完这篇你也想搭一套自己的生产力插件我的建议是先不要上来就规划六件套。挑一个你最常用的场景比如剪藏或者标签管理把这一条链路走通跑顺再逐步加第二件、第三件。等模块多到需要互相通信时再回头补消息协议和事件总线也不迟——在业务没成型之前过度设计架构反而会拖慢你。最后分享一个我自己一直在用的小技巧给每个插件模块配一个debug开关在 storage 里存一个布尔值。出问题时打开开关所有模块都会在控制台输出带[omni]前缀的日志配合版本号一起发给测试方能省掉绝大多数“我这里复现不出来”的沟通成本。这套日志体系比任何调试工具都实用。