Skip to content

02 · 运行时、入口与消息总线

浏览器扩展不是一个进程。理解入口、上下文与消息方向,比理解任意单个函数更重要。

一、十个 webpack 入口

webpack.config.js 定义了十个 entry:

entry源文件运行位置主要职责
popupcore/popup.tspopup / side panel iframe主剪藏界面
settingscore/settings.tsoptions page配置与模板管理
highlightscore/highlights.tsextension tab高亮库
reader-pagecore/reader-view.tsreader.html独立阅读页
contentcontent.tsweb page isolated worldDOM 抽取与高亮状态
backgroundbackground.tsservice worker / background page生命周期与路由
stylestyle.scsspopup/settings 等产品 UI 样式
highlighterhighlighter.scssweb page高亮 UI 样式
readerreader.scssweb page/reader page阅读模式样式
reader-scriptreader-script.ts注入 web page启动 Reader

HTML 文件没有内联脚本,符合 MV3 CSP 的 script-src 'self'。JS 与 CSS 由 webpack 产出,再通过 CopyPlugin 组装进扩展目录。

二、background 初始化做了什么

background.ts:234initialize() 是扩展级生命周期入口:

  1. 注册 tab 激活与更新监听;
  2. tab 关闭时清理 highlighterModeStatereaderModeState
  3. 重建 context menu;
  4. 注入 YouTube Innertube 网络规则;
  5. 根据 openBehavior 决定 action 点击打开 popup、embedded 还是 reader。

background 持有的状态主要是“每个 tab 当前处于什么模式”,不是页面内容:

text
highlighterModeState[tabId] → boolean
readerModeState[tabId]      → boolean
popupPorts[tabId]           → Runtime.Port
sidePanelOpenWindows        → Set<windowId>

这些状态属于会话态,tab 关闭即丢弃;真正的高亮数据存进 storage.local

三、为什么需要 content script generation

扩展更新、开发热加载或强制注入可能让同一页面残留多代 content listener。content.tswindow.obsidianClipperGeneration 标记当前代:

text
新 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

高亮

paintHighlightssetHighlighterModetoggleHighlighterhighlightSelectionhighlightElementclearHighlightsgetHighlighterState

Reader

getReaderModeState 返回 HTML 根节点是否带有 obsidian-reader-active。真正的 toggle 监听由注入的 reader-script.js 注册。

五、background 的路由策略

background 同时监听来自 popup、content、reader 和 extension page 的消息。它主要做四种工作。

1. 获取浏览器能力

例如 getActiveTabgetTabInfoopenOptionsPageopenHighlights。这些 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 采用更重的注入序列:

text
insert reader.css
insert highlighter.css(失败可忽略)
execute browser-polyfill.min.js
execute reader-script.js

脚本顺序不能交换,因为 reader bundle 假设 polyfill 已经存在。

4. 维护跨 UI 一致状态

content 发出 highlighterModeChanged 后,background:

  1. 更新 tab 状态;
  2. 通过 port 通知已打开的 popup;
  3. debounce 重建 context menu。

所以快捷键、右键菜单、popup 按钮看到的是同一份模式状态。

六、popup 与 side panel 为什么共用一套代码

Chrome manifest 同时声明 action.default_popupside_panel.default_pathside-panel.html 装载与 popup 相同的业务 bundle,差别由页面结构、CSS class 与打开方式吸收。

popup.ts 初始化时先向 background 获取真实 tab,而不是相信 port.sender.tab。原因是 popup、side panel、开发者工具上下文对 sender 的定义并不一致。

初始化主路径:

text
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 listeners

buildTemplateFieldsSkeleton()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.isReaderPageReader.preExtractedContentReader.onNavigate 三个静态接口吸收差异。

八、高亮单实例桥接

一个隐蔽但关键的问题:webpack 的 content.jsreader-script.js 都 import highlighter.ts,因此浏览器会产生两份模块状态,各有自己的 highlights[]

项目在 content.ts 把 API 挂到:

ts
window.__obsidianHighlighter = { ... }

reader.tshl() 优先使用这份 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。

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