09 · 阅读模式与页面重构
Reader 不是“加一份 CSS”,而是提取正文、重建页面、接管导航和媒体交互,并与高亮共享状态。
一、三种页面情形
源码注释把 Reader 所在环境区分为:
- 普通页面,尚未启用 Reader;
- 普通页面启用 Reader,content.js 已存在;
- 独立
reader.html,没有 content.js。
Reader 类必须在三种情况下都工作,尤其是第 2、3 情形的高亮实例来源不同。
二、两个入口,一个核心类
原网页模式
background.injectReaderScript(tabId)
→ reader-script.ts
→ Reader.toggle(document)
独立阅读页
reader.html?url=...
→ core/reader-view.ts
→ fetchWithRedirects(url)
→ Defuddle.parseAsync()
→ Reader.preExtractedContent = result
→ Reader.apply(document)共享的 Reader 类约 2,805 行,承担抽取后的所有页面重构与交互。
三、Reader 静态状态
关键字段:
hasApplied / isActive 生命周期
programmaticScroll 区分代码滚动与用户滚动
isReaderPage 当前是否独立页
onNavigate 独立页 SPA 导航回调
preExtractedContent 避免重复 Defuddle
settings 排版/主题/媒体/自定义 CSS
settingsBar / lightbox UI 引用
images / currentImageIndex 图片浏览
observers theme/highlighter 等观察器使用 static 说明一个 document 只预期一个 Reader 实例。它更像 namespace + 状态机,而不是可多实例组件。
四、Apply 的高层阶段
Reader.apply(doc) 可按职责划分:
load settings + initialize i18n
→ save original page state
→ extract content(或消费 preExtractedContent)
→ build reader DOM shell
→ inject styles/settings/navigation
→ transform article details
→ wire code/images/footnotes/transcript/media
→ generate outline
→ bridge/apply highlights
→ announce readerModeChangedRestore 则逆向清理新增 DOM、observer、class、style、event,并恢复原页面。
五、为什么独立页要 preExtractedContent
reader-view.ts 已经 fetch 并用 Defuddle 得到正文。如果 Reader.apply() 再从 reader.html 本身抽取,会抽到空壳;即使把远端 HTML 插入后再抽,也浪费一次解析。
因此先设置:
Reader.preExtractedContent = { content, title, author, ... }extractContent() 优先消费并立刻清空,保证这份缓存只用一次。
六、独立阅读页的 fetch 链
reader-view.ts 不直接相信一次 fetch:
fetchWithRedirects(url)
→ proxyFetch(url)
→ background fetchProxy
→ response { text, finalUrl }
→ detect HTML meta/script redirect
→ 最多按策略继续为什么走 background?extension page 对任意站点请求会遇到 CORS/host permission,background 更适合做代理。
Firefox 若没有 host permission,会返回 CORS_PERMISSION_NEEDED,UI 再触发权限请求。Safari fetch 失败可进入 native messaging/URLSession 降级。
七、页面重构而非原位清洗
Reader 创建自己的:
- article 容器;
- 标题、byline、元数据;
- settings/nav bar;
- outline 与移动端 overlay;
- 图片 lightbox;
- 媒体 player controls;
- 字体、主题、自定义 CSS 变量。
把清洗后 content 放进受控 shell,可以统一布局和交互,不受原站 CSS selector 竞争影响。
八、排版设置
ReaderSettings:
| 字段 | 默认 | 边界/行为 |
|---|---|---|
fontSize | 16 | UI 限制 9–24 px |
lineHeight | 1.6 | 1.1–2.0 |
maxWidth | 38 | 30–60 em |
appearance | auto | auto/light/dark |
lightTheme | default | 多套配色 |
darkTheme | same | 可与 light 分开 |
defaultFont | 空 | system sans/serif/自定义 |
blendImages | true | 与主题背景混合 |
colorLinks | false | 链接强调 |
followLinks | true | 导航策略 |
pinPlayer | true | 媒体 sticky |
autoScroll | true | transcript/媒体联动 |
highlightActiveLine | true | transcript 当前行 |
customCss | 空 | 用户覆盖 |
设置存 storage.sync,并即时应用 CSS custom properties。
九、主题模型
Reader 区分 appearance 与 color theme:
appearance = auto/light/dark
lightTheme = default/flexoki/ayu/...
darkTheme = same 或独立 themegetEffectiveTheme() 先判断当前亮暗模式,再选择对应 theme。OS prefers-color-scheme 变化由 MediaQuery listener 处理;DOM class 变化又由 MutationObserver 同步图标。
这种两维模型比单一 theme 字段更灵活,但设置 UI 与应用逻辑也更复杂。
十、字体检测的浏览器差异
Reader 支持用户自定义本地字体,但 Safari/Firefox 可能“farble” canvas text metrics,导致经典 canvas 字体探测不可靠。
源码在这些浏览器退回 Font Loading API,并显示 readerFontUnavailable notice。即使字体访问被隐私策略阻止,Reader 也只是回退系统字体,不阻断阅读。
十一、Outline 与滚动
generateOutline():
- 只取 article 内 h2–h6;
- 排除 blockquote 内标题;
- 少于两个标题则隐藏;
- 为无 id 标题生成稳定 id;
- 桌面生成侧边 outline;
- 移动端复制进 overlay;
- 滚动时更新 active item。
programmaticScroll 用于防止点击 outline 后,scroll observer 在动画过程中反复改 active 状态。滚动采用短 easing 动画并考虑 sticky player offset。
十二、导航按钮的显隐
Reader 顶部 nav 在向下滚动后淡出,向上滚动或 hover 时出现。移动设备额外设置 visibility/pointer-events,避免透明按钮仍挡住触摸。
当 settings dropdown、clip menu 或 outline 打开时,不执行自动隐藏。媒体 floating toggles 与 nav 同步显隐。
十三、链接导航
原网页模式
根据 followLinks 决定是否让链接正常导航。高亮模式还可能临时禁止 link click,防止选择文字时跳走。
独立页模式
Reader.onNavigate(url) 交给 reader-view.ts:
fetch new article
→ update URL/title/favicon
→ Reader.updateReaderContent()
→ set highlighter page URL
→ load/reposition highlights这是一种小型 SPA,不刷新 extension shell,保留主题和交互状态。
十四、Reader 与高亮
Reader apply 后需要:
ensureHighlighterCSS();- 设置高亮对应的原文章 URL,而不是 extension URL;
loadHighlights();- DOM 替换后
invalidateHighlightCache(); applyHighlights()/repositionHighlights();- nav 按钮通过 body class 同步 active state。
独立页尤其要覆盖 page URL,否则高亮会错误存到 moz-extension://.../reader.html key。
十五、媒体与 transcript
Reader 不只处理文章,还识别视频/音频/transcript 场景。reader-transcript.ts 负责:
- 将 transcript 行与媒体时间关联;
- 当前播放行高亮;
- 点击文本 seek;
- auto scroll;
- pinned player 控制。
YouTube 还需要 background 的 Innertube/embed 网络规则与 fetch proxy,这是 UI 功能跨到平台层的典型案例。
十六、Restore 为什么困难
原网页 Reader 要可逆。Restore 需要清理:
- reader root class 与注入 DOM;
- settings bar、outline、lightbox;
- injected stylesheet;
- document title/viewport/样式;
- observers/listeners;
- highlighter overlays;
- resize handlers;
- 模式状态通知。
任何遗漏都会在多次 toggle 后累积。hasApplied/isActive 防止重复 apply,cleanup 函数保证幂等。
十七、设计精华与代价
精华
- 同一核心类覆盖原位与独立两种形态;
- pre-extracted content 避免重复工作;
- 高亮 bridge 保证单一真相;
- settings 映射为 CSS variables,更新廉价;
- fetch 权限/原生能力封装在 background。
代价
- static 大类承载大量交互状态;
- DOM 与 UI 逻辑紧密,单元测试难度高;
- 每个新增媒体/主题功能都可能影响 restore;
- 跨浏览器字体、fetch、fullscreen 行为需要持续兼容。
十八、本章检查点
- 能区分 Reader 的三种页面情形和两个入口;
- 能解释
preExtractedContent、onNavigate、isReaderPage; - 能追踪独立页 fetch proxy 与权限降级;
- 能解释主题二维模型和字体检测;
- 能列出 Reader/高亮交互的关键步骤;
- 能说明 restore 为什么必须是显式生命周期阶段。