给 23 万字修仙小说造个阅读站:浏览器内打包 EPUB 分页阅读 + 三主题实战

2 阅读9分钟

业余时间在番茄连载了一本修仙小说,写着写着就想:总得给它配一个体面的阅读站吧?于是有了这个项目——110 章、23.3 万字,Vue 3 + Vite 单页应用,部署在 Cloudflare Pages。

这篇文章复盘整个搭建过程:为什么不用 v-html 一把梭,而是在浏览器里把每一章 Markdown 实时打包成 EPUB2;以及一路踩过的坑——EPUB 规范约束、iframe 手势接管、主题切换残留、23 万字的按需加载。

线上地址:xiuxian-novel.pages.dev | 源码:github.com/Xxcool/xiux…

home-night.jpg

一、起因:v-html 渲染小说,差在哪?

最省事的方案当然是:Markdown 交给 marked 转 HTML,v-html 一塞,完事。

但小说阅读和普通文章页不太一样——读者一坐下来就是几十分钟,对「书感」很敏感:

  • 滚动 vs 分页:网页是滚动的,但纸质书是翻页的。滚动阅读长篇时,眼睛对「读到哪了」的感知很弱;
  • 进度估不准:滚动进度条按像素算,跟内容结构没关系;翻到手机横竖屏一切换,位置就飘了;
  • 手势没有书的隐喻:左右滑动在阅读场景里天然应该是「上一页 / 下一页」,而不是页面平移。

而这些能力,电子书生态早就解决得很好——真分页排版、CFI 级别的精确定位、成熟的翻页手势。所以问题变成:网页能不能直接复用电子书生态?

答案是可以,而且不需要服务端参与。

二、选型:让专业阅读库干专业的事

对比一下三条路线:

方案分页进度精度手势工作量
v-html 滚动❌像素估算自己写最小
手写 CSS 分栏分页⚠️ 易碎自己算自己写巨大
打包 EPUB + 阅读库✅ 库接管✅ CFI✅ 库 + 少量接管中

第三条路线的思路是:把单章 Markdown 渲染成 HTML,在浏览器里用 JSZip 打包成一个标准的 EPUB2 文件(Blob),再交给阅读库(我选了 @likecoin/epub-ts,epub.js 的 TS 重写版)渲染。分页、翻页、进度全部由库接管,零手写分页逻辑。

flow.png

每一章都是一本独立的 EPUB,这个设定带来一个很舒服的副产品:relocated 事件上报的百分比天然就是「本章内位置」——100% 即本章末页,0% 即首页,末页跳下一章的边界检测只需要读这个数字。

章节页成品长这样:顶部章节号配渐变装饰线,正文区交给阅读器,底部是「上一章 / 下一章」和实时页码。

chapter-night.jpg

三、浏览器内打包一本「合规」的 EPUB2

EPUB 本质上是个 ZIP 包,最小可用结构长这样:

mimetype                  ← 必须首个写入,且不压缩
META-INF/container.xml    ← 指向 content.opf
OEBPS/
  ├── content.opf         ← 包描述(metadata + manifest + spine)
  ├── chapter.xhtml       ← 正文(严格 XHTML)
  └── toc.ncx             ← EPUB2 目录

结构不难,难的是规范约束——以下三个坑全是真调出来的:

坑 1:mimetype 必须是 ZIP 里第一个文件,且不能压缩。 这是 EPUB 规范的铁律,JSZip 里要显式指定:

// 压缩方式必须是 STORE(仅存储),且必须是第一个 file() 调用
zip.file('mimetype', 'application/epub+zip', { compression: 'STORE' })

坑 2:HTML5 void 元素不自闭合,XML 解析直接报错。 marked 产出的是 HTML5,<hr>、<br> 这类标签不闭合。EPUB 的 chapter.xhtml 按严格 XML 解析,会报 Opening and ending tag mismatch: hr。所以需要一个 toXhtml() 规整步骤:

// void 元素自闭合:<hr> → <hr />、<br> → <br />
const voidRe = new RegExp(`<(${VOID_TAGS.join('|')})\\b([^>]*?)(/?)>`, 'gi')
out = html.replace(voidRe, (m, tag, attrs, slash) =>
  slash ? m : `<${tag}${attrs} />`
)
// 顺手处理游离 &:只转义不是合法实体引用的 &
out = out.replace(/&(?!(?:#\d+|#x[0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]*);)/g, '&amp;')

坑 3:content.opf 漏声明 xmlns:opf,manifest 会被静默截断。 这是最阴险的一个——不报错,但解析结果莫名缺内容。<metadata> 上必须同时声明 xmlns:dc 和 xmlns:opf 两个命名空间。

三个坑修完,buildEpub() 就稳定了:入参 { title, html },出参 Blob,全程几十毫秒。

四、iframe 里的手势,阅读库管不到

阅读库把内容渲染进一个 sandbox 的 srcdoc iframe——隔离性好,但也带来一个隐蔽问题:epub-ts 内置的 Touch 管理器监听的是 iframe 外层容器,根本收不到 iframe 内部的触摸事件。

现象是:移动端滑动翻页「时灵时不灵」,桌面端点击翻页偶尔失效。

解法是自己上:等 iframe 文档就绪后,直接在 iframe.contentDocument 上挂手势监听。三个细节值得注意:

// touchstart 必须用 capture:确保起点坐标一定拿得到
doc.addEventListener('touchstart', onStart, { passive: true, capture: true })
// touchend 里统一判定:滑动 / 轻触
doc.addEventListener('touchend', onEnd, { passive: true })
// 桌面 click 要跳过触摸后的合成 click(500ms 内),否则连翻两页
const onClick = (e) => {
  if (Date.now() - lastTouchAction < 500) return
  handleTap(e.clientX)
}

配套还有两个 renderTo 参数必须手动设置:

  • snap: false——关闭库内置的滑动翻页,否则它和自挂手势互相抢事件,这是「时灵时不灵」的另一半原因;
  • allowScriptedContent: true——不开的话,浏览器会拦截 srcdoc iframe 内的脚本(Blocked script execution in 'about:srcdoc'),主题注入直接失效。

最后是点击热区:左右各 28% 翻页、中间 44% 切换沉浸模式(隐藏顶栏只留正文)。两侧保证足够的翻页面积,中间留出唤出界面的空间,滑动翻页始终可用。

home-mobile.jpg

五、三套主题与「零残留」切换

站点有三套主题:月下(深夜沉浸)、暖白(清爽书房)、宣纸(温和护眼),各自配齐底色、正文色、强调色和首页景图。

themes-compare.jpg

主题的唯一来源是 theme.js:一个 THEME_LIST 定义基础色,SHELL 定义每套主题的浮层、描边、阴影,全部以 CSS 变量注入 documentElement。外壳部分这样就很干净了,但EPUB 正文住在另一个文档(iframe)里,它不认外层的 CSS 变量。

这里有两个隐蔽的「颜色记忆」问题:

记忆 1:iframe 内的主题规则按切换顺序追加,旧规则会抢优先级。 表现为从暖白切回月下时,外层已经变暗,正文还是浅色。解法是切换时把正文背景和文字色用 override 强制写入,第三个参数 true 表示覆盖库内置样式的优先级:

function applyReaderColors(key) {
  const { bg, text } = getTheme(key)
  rendition.themes.override('background', bg, true)
  rendition.themes.override('color', text, true)
}

记忆 2:页面底部的残留色。 index.html 里有一段防闪脚本,在 CSS 加载前往 <html> 上写内联 backgroundColor(避免首屏白闪)。但内联样式优先级最高、又不会随 CSS 变量自动更新——切换主题后,正文之外的区域(短页面底部、overscroll)会一直残留首次加载的主题色。解法是 applyDom() 里每次切换都同步改写它:

el.style.backgroundColor = t.bg  // 与防闪脚本同一处,切换时必须跟着改

这两个修完,五步往返切换(月下→暖白→宣纸→暖白→月下)外壳与 iframe 始终同色。

chapter-paper.jpg

六、23 万字的加载策略

23 万字、849KB 的 Markdown 源文件,如果用 import.meta.glob 的 eager: true 全量内联,整本书会被塞进首屏 JS。所以改成 lazy:glob 只建立「路径 → 动态 import 函数」的映射,每章编译成独立 chunk(110 个),访问时才下载,首屏零章节请求。

再配合相邻章预取——读者读完当前章大概率点下一章,提前把相邻两章拉下来,翻页即秒开。

预取还附带了一个很典型的 bug:点「下一章」报 TypeError: R.then is not a function,然后卡在「载入中」。

排查发现是缓存契约问题。缓存里同时存两种值:进行中的 Promise(首次加载)和已解析的正文字符串(预取完成后回写)。命中字符串时,调用方的 p.then(...) 拿到的不是 Promise,直接抛错。修复很简单——命中缓存也统一包一层:

// getChapterRaw 的契约恒为 Promise<string>:'' 表示章节不存在
const cached = cache.get(key)
if (cached !== undefined) return Promise.resolve(cached)  // 字符串也包成 Promise

教训记一句:缓存里混存 Promise 和已解析值时,取用侧必须统一包装,否则调用方一定会踩到。

阅读体验的外围也顺手做了几件事:章节目录按每 20 章折叠分组,窄屏时目录收进右侧抽屉(区间 tab 可横向滑动、滚动条隐藏);阅读进度记录在 localStorage,首页会出现「继续阅读」卡片,一键回到上次读到的章节;已读章节带轻量徽章,110 章读到哪里一目了然。

drawer-toc.jpg

七、视觉细节:印章、4.6KB 的标题字体、favicon

视觉上想做出「书卷气 + 一点仙侠味」,做了三个小东西。

顶栏的朱文印章。 书名「签到修仙」旁边是一枚竖排「修仙」的印章:writing-mode: vertical-rl 竖排、朱砂描边、rotate(-3deg) 模拟手盖的歪斜感。印章色做成了 CSS 变量 --seal-ink,深色底自动提亮一档保证对比。

nav-seal.png

4.6KB 的毛笔楷书标题字体。 系统楷体在 Windows 上不可靠,网络大字体又太重。解法是用 Google Fonts 的开源毛笔楷书「马善政」(OFL 协议),只把标题用到的 10 个字(签到修仙从废柴到万古一帝)子集化:

pyftsubset MaShanZheng-Regular.ttf \
  --text="签到修仙从废柴到万古一帝" \
  --flavor=woff2 --output-file=xiuxian-display.woff2
# 5.8MB → 4.6KB

再配 unicode-range 限定,只有命中这 10 个字才会下载字体,未命中的字符回退系统楷→隶→宋字体栈。任何设备打开都是毛笔字效果,加载成本约等于零。

favicon 三件套。 用 Pillow 脚本画的同一枚印章:favicon.svg 矢量版 + favicon.ico(16px 只画单字「修」,双字在这个尺寸会糊成一团)+ 180px 的 apple-touch-icon。

八、值得吗?

最后 Honest 一下这套方案的收益和代价:

收益代价
排版真分页,阅读库接管,零手写分页多一层「打包 EPUB」环节(几十毫秒)
进度CFI 级精确定位,天然按章隔离需要维护 EPUB2 规范约束
手势跨端行为统一,边界跳章顺滑iframe 内手势必须自己接管
兜底渲染失败自动降级为滚动阅读iframe 带来一系列隔离问题

我的结论:如果做的是「让人长时间阅读」的产品,这套绕路非常值——它把最难做好的分页和进度外包给了电子书生态,自己只需要写薄薄一层集成。反过来,如果只是普通文章页,v-html 依然是正解。

整个项目已开源(含文中所有实现细节):

如果这篇对你有帮助,或者你也在折腾小说站 / 电子书相关的东西,欢迎评论区交流。


标签建议:Vue.js 前端 性能优化 JavaScript 开源