02 · 运行时、入口与消息总线
浏览器扩展不是一个进程。理解入口、上下文与消息方向,比理解任意单个函数更重要。
一、十个 webpack 入口
webpack.config.js 定义了十个 entry:
| entry | 源文件 | 运行位置 | 主要职责 |
|---|---|---|---|
popup | core/popup.ts | popup / side panel iframe | 主剪藏界面 |
settings | core/settings.ts | options page | 配置与模板管理 |
highlights | core/highlights.ts | extension tab | 高亮库 |
reader-page | core/reader-view.ts | reader.html | 独立阅读页 |
content | content.ts | web page isolated world | DOM 抽取与高亮状态 |
background | background.ts | service worker / background page | 生命周期与路由 |
style | style.scss | popup/settings 等 | 产品 UI 样式 |
highlighter | highlighter.scss | web page | 高亮 UI 样式 |
reader | reader.scss | web page/reader page | 阅读模式样式 |
reader-script | reader-script.ts | 注入 web page | 启动 Reader |
HTML 文件没有内联脚本,符合 MV3 CSP 的 script-src 'self'。JS 与 CSS 由 webpack 产出,再通过 CopyPlugin 组装进扩展目录。
二、background 初始化做了什么
background.ts:234 的 initialize() 是扩展级生命周期入口:
- 注册 tab 激活与更新监听;
- tab 关闭时清理
highlighterModeState与readerModeState; - 重建 context menu;
- 注入 YouTube Innertube 网络规则;
- 根据
openBehavior决定 action 点击打开 popup、embedded 还是 reader。
background 持有的状态主要是“每个 tab 当前处于什么模式”,不是页面内容:
highlighterModeState[tabId] → boolean
readerModeState[tabId] → boolean
popupPorts[tabId] → Runtime.Port
sidePanelOpenWindows → Set<windowId>这些状态属于会话态,tab 关闭即丢弃;真正的高亮数据存进 storage.local。
三、为什么需要 content script generation
扩展更新、开发热加载或强制注入可能让同一页面残留多代 content listener。content.ts 用 window.obsidianClipperGeneration 标记当前代:
新 content script 启动
→ generation + 1
→ listener 收到消息时比较 myGeneration
→ 旧 listener 发现自己不是最新代,直接 yield这是一个很实用的幂等保护。否则一次 getPageContent 可能收到多个 listener 响应,高亮也可能被重复初始化。
四、content script 的消息表
content.ts:114-399 是页面域的消息分发器。按职责可分为四组。
生命周期与容器
| action | 行为 |
|---|---|
ping | 判断 content script 是否已加载 |
toggle-iframe | 打开或关闭 embedded clipper |
close-iframe | 移除容器并清理 resize handler |
内容与输出
| action | 行为 |
|---|---|
getPageContent | 完整抽取并返回 ContentResponse |
extractContent | 执行 CSS selector 变量 |
copyMarkdownToClipboard | 当前页抽取后直接复制 Markdown |
saveMarkdownToFile | 当前页抽取后下载 .md |
高亮
paintHighlights、setHighlighterMode、toggleHighlighter、highlightSelection、highlightElement、clearHighlights、getHighlighterState。
Reader
getReaderModeState 返回 HTML 根节点是否带有 obsidian-reader-active。真正的 toggle 监听由注入的 reader-script.js 注册。
五、background 的路由策略
background 同时监听来自 popup、content、reader 和 extension page 的消息。它主要做四种工作。
1. 获取浏览器能力
例如 getActiveTab、getTabInfo、openOptionsPage、openHighlights。这些 API 不应该由页面脚本直接使用。
2. 把消息送到正确 tab
routeMessageToTab(tabId, message) 会识别 reader page URL。普通页面走 tabs.sendMessage;独立 reader page 则可能在扩展页上下文处理。这个抽象避免每个调用方重复判断页面类型。
3. 注入缺失脚本
ensureContentScriptLoadedInBackground() 先发 ping。若失败,再通过 browser.scripting.executeScript 注入 polyfill、flatten helper 与 content bundle。
Reader 采用更重的注入序列:
insert reader.css
insert highlighter.css(失败可忽略)
execute browser-polyfill.min.js
execute reader-script.js脚本顺序不能交换,因为 reader bundle 假设 polyfill 已经存在。
4. 维护跨 UI 一致状态
content 发出 highlighterModeChanged 后,background:
- 更新 tab 状态;
- 通过 port 通知已打开的 popup;
- debounce 重建 context menu。
所以快捷键、右键菜单、popup 按钮看到的是同一份模式状态。
六、popup 与 side panel 为什么共用一套代码
Chrome manifest 同时声明 action.default_popup 和 side_panel.default_path。side-panel.html 装载与 popup 相同的业务 bundle,差别由页面结构、CSS class 与打开方式吸收。
popup.ts 初始化时先向 background 获取真实 tab,而不是相信 port.sender.tab。原因是 popup、side panel、开发者工具上下文对 sender 的定义并不一致。
初始化主路径:
initializeUI()
→ setup language / browser class
→ getActiveTab
→ initializeExtension(tabId)
→ load settings/templates
→ initialize page content
→ pick trigger template
→ build field skeleton
→ fill compiled values
→ initialize interpreter
→ attach UI / storage / port listenersbuildTemplateFieldsSkeleton() 与 fillTemplateFieldValues() 分开是重要的体验优化:先快速画出稳定结构,再异步填值,减少界面抖动。
七、Reader 的两种运行形态
Reader 有两条入口:
形态 A:原网页内重排
background 注入 reader-script.js,后者调用 Reader.toggle(document)。恢复时需要把原页面 DOM/属性找回。
形态 B:独立 reader.html
reader-view.ts 从 query string 取原 URL,通过 background 的 fetchProxy 拉 HTML,用 Defuddle 抽取,再把结果交给 Reader.apply()。链接可以 SPA 式导航到下一篇文章。
两种形态共享 Reader 类,却在页面归属、fetch 能力和高亮实例上不同。源码通过 Reader.isReaderPage、Reader.preExtractedContent 与 Reader.onNavigate 三个静态接口吸收差异。
八、高亮单实例桥接
一个隐蔽但关键的问题:webpack 的 content.js 与 reader-script.js 都 import highlighter.ts,因此浏览器会产生两份模块状态,各有自己的 highlights[]。
项目在 content.ts 把 API 挂到:
window.__obsidianHighlighter = { ... }reader.ts 的 hl() 优先使用这份 bridge,只有独立 reader page 没有 content script 时才使用本地 import。这保证同一 tab 只有一份高亮真相。
这是 bundler 应用里常见却容易漏掉的状态问题:源码上的“同一模块”不等于不同 bundle 运行时的“同一实例”。
九、快捷键与右键菜单
commands 把用户动作统一汇入 background:
quick_clip:打开 popup,稍后广播triggerQuickClip;toggle_highlighter:确保 content script 后切换;toggle_reader:确保 content + reader script 后切换;- 部分浏览器还支持 copy action。
context menu 根据 readerModeState / highlighterModeState 动态改变标题和项目。使用 debounce 是因为选择、tab 切换和模式变化可能在短时间内连续触发;直接 removeAll + create 会造成重复工作和竞争。
十、错误与降级路径
| 失败点 | 降级策略 |
|---|---|
| content script 未加载 | ping 失败后动态注入 |
| reader toggle 时页面重载 | sendMessage 失败仍返回成功/已退出 |
parseAsync 卡住 | 8 秒后退回同步 parse() |
| flatten shadow DOM 太慢 | 3 秒 race 后继续抽取 |
| 当前窗口查不到 tab | 查询所有 active tabs 并排除 extension URL |
| popup 不可用 | embedded/reader 直接由 action click 处理 |
| 高亮 CSS 注入失败 | 不中止 Reader 主流程 |
这些降级不是附属细节,而是扩展在真实网页环境稳定工作的必要部分。
十一、本章检查点
- 能列出十个 entry 及其运行位置;
- 能解释 background 为什么只存 tab 模式、不存正文;
- 能画出 popup → background → content 的消息方向;
- 能说明 content generation 和高亮 bridge 分别防什么问题;
- 能区分原网页 Reader 与独立 Reader page。