Skip to content

09 · 阅读模式与页面重构

Reader 不是“加一份 CSS”,而是提取正文、重建页面、接管导航和媒体交互,并与高亮共享状态。

一、三种页面情形

源码注释把 Reader 所在环境区分为:

  1. 普通页面,尚未启用 Reader;
  2. 普通页面启用 Reader,content.js 已存在;
  3. 独立 reader.html,没有 content.js。

Reader 类必须在三种情况下都工作,尤其是第 2、3 情形的高亮实例来源不同。

二、两个入口,一个核心类

text
原网页模式
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 静态状态

关键字段:

text
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) 可按职责划分:

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

Restore 则逆向清理新增 DOM、observer、class、style、event,并恢复原页面。

五、为什么独立页要 preExtractedContent

reader-view.ts 已经 fetch 并用 Defuddle 得到正文。如果 Reader.apply() 再从 reader.html 本身抽取,会抽到空壳;即使把远端 HTML 插入后再抽,也浪费一次解析。

因此先设置:

ts
Reader.preExtractedContent = { content, title, author, ... }

extractContent() 优先消费并立刻清空,保证这份缓存只用一次。

六、独立阅读页的 fetch 链

reader-view.ts 不直接相信一次 fetch:

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

字段默认边界/行为
fontSize16UI 限制 9–24 px
lineHeight1.61.1–2.0
maxWidth3830–60 em
appearanceautoauto/light/dark
lightThemedefault多套配色
darkThemesame可与 light 分开
defaultFontsystem sans/serif/自定义
blendImagestrue与主题背景混合
colorLinksfalse链接强调
followLinkstrue导航策略
pinPlayertrue媒体 sticky
autoScrolltruetranscript/媒体联动
highlightActiveLinetruetranscript 当前行
customCss用户覆盖

设置存 storage.sync,并即时应用 CSS custom properties。

九、主题模型

Reader 区分 appearance 与 color theme:

text
appearance = auto/light/dark
lightTheme = default/flexoki/ayu/...
darkTheme  = same 或独立 theme

getEffectiveTheme() 先判断当前亮暗模式,再选择对应 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()

  1. 只取 article 内 h2–h6;
  2. 排除 blockquote 内标题;
  3. 少于两个标题则隐藏;
  4. 为无 id 标题生成稳定 id;
  5. 桌面生成侧边 outline;
  6. 移动端复制进 overlay;
  7. 滚动时更新 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

text
fetch new article
  → update URL/title/favicon
  → Reader.updateReaderContent()
  → set highlighter page URL
  → load/reposition highlights

这是一种小型 SPA,不刷新 extension shell,保留主题和交互状态。

十四、Reader 与高亮

Reader apply 后需要:

  1. ensureHighlighterCSS()
  2. 设置高亮对应的原文章 URL,而不是 extension URL;
  3. loadHighlights()
  4. DOM 替换后 invalidateHighlightCache()
  5. applyHighlights() / repositionHighlights()
  6. 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 的三种页面情形和两个入口;
  • 能解释 preExtractedContentonNavigateisReaderPage
  • 能追踪独立页 fetch proxy 与权限降级;
  • 能解释主题二维模型和字体检测;
  • 能列出 Reader/高亮交互的关键步骤;
  • 能说明 restore 为什么必须是显式生命周期阶段。

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