做了个 EPUB 阅读器,被「阅读进度同步」折磨了一周,总结 4 个血泪教训
你做过"打开一本书,自动回到上次阅读位置"这个功能吗?
听起来很简单对吧——存个页码,下次打开翻过去就行。我一开始也是这么想的,直到在 Web EPUB 阅读器上被反复打脸。
这篇文章不讲理论框架,直接讲:我在实现 Web/Mobile 阅读进度同步时踩过的每一个坑,以及为什么"存页码"这条路从一开始就是死的。
一句话结论
进度 = 内容位置(Anchor),页面 = 当前设备的渲染结果。
只要你不存页码,Web 双页 / Mobile 单页 / 字体可调 / 阅读器大小可调 / 多端同步,全部迎刃而解。
为什么不能存页码?
同一本 EPUB,79 章,30 万字:
| 环境 | 页数 |
|---|---|
| PC 双页 (319px/页) | 1165 页 |
| iPad 横屏 (500px/页) | 约 750 页 |
| iPhone 竖屏 (350px/页) | 约 1400 页 |
| 调大字号 (20px) | 约 1800 页 |
用户在 PC 上读到第 142 页,存下来。手机打开,翻到第 142 页——内容完全对不上,可能差了好几章。
页码是渲染结果,不是内容属性。 它取决于字体、字号、行高、容器宽高、双页/单页模式。换任何一个参数,页码就变了。
Anchor 锚点设计
数据结构
interface ReadingAnchor {
chapterIndex: number; // 第 11 章
blockIndex: number; // 章内第 17 个段落
charOffset: number; // 段内第 0 个字符
textSnippet: string; // "尤里身体前倾,像是在敦促她"
}
每个字段都是内容属性——和设备无关、和字体无关、和屏幕宽度无关。
textSnippet 是保险:万一书的内容更新导致 blockIndex 偏移,还能用文字片段做模糊匹配(Kindle 也是这么做的)。
存储格式
anchor:11:17:0|snippet:尤里身体前倾,像是在敦促她|char:25000
char:25000 是全局字符偏移,供旧客户端降级。一个字符串,三层 fallback,向前兼容。
多端同步流程
手机端退出 → 保存 anchor → 后端存储
↓
PC 端打开 → 请求 anchor → 当前设置下重新分页 → 定位到锚点所在页
后端只做一件事:存最新的 anchor。"翻到哪一页"这个问题完全由前端根据当前设备环境实时计算。
前端分页:CSS 多列布局测量
EPUB 分页的核心是 CSS column-width。将章节 HTML 注入一个隐藏容器,浏览器自动按列排布,scrollWidth / columnWidth 就是页数。
// 隐藏测量容器
measureEl.innerHTML = `
<div class="epub-measure-container" style="
width: ${pageWidth}px;
height: ${pageHeight}px;
column-width: ${pageWidth}px;
column-gap: 0px;
column-fill: auto;
font-size: ${fontSize}px;
line-height: ${lineHeight};
">${chapter.html}</div>
`;
const scrollW = contentEl.scrollWidth;
const pageCount = Math.ceil(scrollW / pageWidth);
同时,遍历每个块级元素,记录它在第几列(第几页),构建 blockMap:
// 用 getBoundingClientRect 计算元素所在列
const containerRect = containerEl.getBoundingClientRect();
for (const el of leafElements) {
const elRect = el.getBoundingClientRect();
const relativeLeft = elRect.left - containerRect.left;
const pageInChapter = Math.floor(relativeLeft / columnWidth);
// 记录:blockIndex → pageInChapter
}
有了 blockMap,锚点 → 页码的转换就是一次查表:range.startPage + block.pageInChapter。
四个真实的坑
坑 1:测量 CSS ≠ 渲染 CSS → 定位偏移
这是最隐蔽的 Bug。测量容器和实际渲染的 CSS 差了几条规则:
/* 渲染容器有,测量容器漏了 */
h1, h2, h3 { margin-top: 0.5em; margin-bottom: 0.3em; }
blockquote { text-indent: 0 !important; }
a { color: inherit; text-decoration: underline; }
一个标题的 margin 差了 0.5em(≈ 8px),在 319px 宽的手机屏幕上,就足以让后续段落的列分配偏移一整页。79 章累积下来,锚点可以偏差几十页。
结论:测量 CSS 和渲染 CSS 必须完全一致,差一个属性就可能出错。
坑 2:offsetLeft 在多列布局中不可靠
最初用 el.offsetLeft / columnWidth 判断元素在哪一列。但 offsetLeft 的语义是"相对于 offsetParent",在 CSS 多列布局中,不同浏览器的实现有差异。
换成 getBoundingClientRect() 后解决。它返回元素的实际视觉位置,跨浏览器一致:
// ❌ 不可靠
const page = Math.floor(el.offsetLeft / columnWidth);
// ✅ 可靠
const rect = el.getBoundingClientRect();
const page = Math.floor((rect.left - containerRect.left) / columnWidth);
坑 3:字体设置变更 → 用旧数据算出错误页码
用户调整字号 → settingsFingerprint 变化 → 触发重新分页。但 React 中多个 Hook 的状态更新有时差:
Effect 看到:新的 settingsFingerprint ✓
旧的 blockMaps ✗ (分页 Hook 还没完成重新测量)
用旧的 blockMaps + 新设置去算 anchorToPage,结果必然是错的。
解决方案:两阶段导航。
// 第一阶段:检测到设置变更,标记等待,不导航
if (isSettingsChange) {
pendingSettingsNavRef.current = true;
return; // 等分页重新测量
}
// 第二阶段:分页完成后,用新 blockMaps 安全导航
if (pendingSettingsNavRef.current) {
pendingSettingsNavRef.current = false;
const newPage = anchorToPage(anchor, newRanges, newBlockMaps);
navigateTo(newPage);
}
坑 4:渐进加载 + 翻页库事件竞态
79 章的书不会一次加载完。第一次分页只有 17 章精确测量 + 62 章估算。当更多章节加载后,avgCharsPerPage 从 135 变成 129,所有估算章节的 startPage 集体偏移,锚点对应的全局页码从 132 变成 142。
但阅读器还停在 132 页,因为初始化后没有"自动修正"逻辑。
更麻烦的是,尝试用 setSettingsKey 重新挂载 flipbook 来修正时,翻页库在 mount 时会发射一个 onFlip({page: 0}) 的伪事件。这个事件把 currentPageRef 污染成 0,导致后续自动修正全部失效。
解决方案:两个机制配合。
门控机制:flipbook 初始化阶段忽略 onFlip 事件。
const flipReadyRef = useRef(false);
// mount 后 300ms 才标记就绪
setTimeout(() => { flipReadyRef.current = true; }, 300);
// handleFlip 中门控
if (!flipReadyRef.current) return; // 忽略伪事件
直接导航:渐进加载修正时用 turnToPage 而不是重新挂载,从根本上避免竞态。
if (!userHasFlippedRef.current && startPage !== currentPageRef.current) {
flipBookRef.current?.pageFlip()?.turnToPage(startPage);
}
最终架构
┌───────────────────────────────────┐
│ 后端:只存 anchor 字符串 │ POST /api/library/progress
├───────────────────────────────────┤
│ 前端解析层:anchor ↔ 页码转换 │ anchorToPage / pageToAnchor
├───────────────────────────────────┤
│ 前端测量层:CSS columns 精确测量 │ buildBlockMap → blockMaps
├───────────────────────────────────┤
│ 前端渲染层:flipbook 翻页 UI │ react-pageflip
└───────────────────────────────────┘
核心原则:
- 后端不分页,只存内容位置
- 页码纯前端算,根据当前设备环境实时计算
- 锚点与设备无关,同一个锚点在任何设备上都能定位
-
转换方向:永远是
anchor → page(打开时),page → anchor(保存时)
写在最后
实现这个功能的过程让我深刻理解了一件事:看似简单的需求,难点往往不在算法设计,而在工程细节的一致性。
CSS 差一条规则、React Effect 的执行时序差一帧、第三方库的一个初始化事件——这些"微小"的不一致累积起来,就是"打开书发现位置完全不对"的用户体验灾难。
如果你也在做类似的阅读器产品,记住这个原则:
永远不要存页码。存内容位置,让前端去算页码。
这一个决策,能帮你避开 80% 的坑。