业余时间在番茄连载了一本修仙小说,写着写着就想:总得给它配一个体面的阅读站吧?于是有了这个项目——110 章、23.3 万字,Vue 3 + Vite 单页应用,部署在 Cloudflare Pages。
这篇文章复盘整个搭建过程:为什么不用
v-html一把梭,而是在浏览器里把每一章 Markdown 实时打包成 EPUB2;以及一路踩过的坑——EPUB 规范约束、iframe 手势接管、主题切换残留、23 万字的按需加载。
一、起因: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 重写版)渲染。分页、翻页、进度全部由库接管,零手写分页逻辑。
每一章都是一本独立的 EPUB,这个设定带来一个很舒服的副产品:relocated 事件上报的百分比天然就是「本章内位置」——100% 即本章末页,0% 即首页,末页跳下一章的边界检测只需要读这个数字。
章节页成品长这样:顶部章节号配渐变装饰线,正文区交给阅读器,底部是「上一章 / 下一章」和实时页码。
三、浏览器内打包一本「合规」的 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, '&')
坑 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% 切换沉浸模式(隐藏顶栏只留正文)。两侧保证足够的翻页面积,中间留出唤出界面的空间,滑动翻页始终可用。
五、三套主题与「零残留」切换
站点有三套主题:月下(深夜沉浸)、暖白(清爽书房)、宣纸(温和护眼),各自配齐底色、正文色、强调色和首页景图。
主题的唯一来源是 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 始终同色。
六、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 章读到哪里一目了然。
七、视觉细节:印章、4.6KB 的标题字体、favicon
视觉上想做出「书卷气 + 一点仙侠味」,做了三个小东西。
顶栏的朱文印章。 书名「签到修仙」旁边是一枚竖排「修仙」的印章:writing-mode: vertical-rl 竖排、朱砂描边、rotate(-3deg) 模拟手盖的歪斜感。印章色做成了 CSS 变量 --seal-ink,深色底自动提亮一档保证对比。
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 依然是正解。
整个项目已开源(含文中所有实现细节):
- 线上阅读:xiuxian-novel.pages.dev
- 源码仓库:github.com/Xxcool/xiux… (求个 Star ⭐)
如果这篇对你有帮助,或者你也在折腾小说站 / 电子书相关的东西,欢迎评论区交流。
标签建议:
Vue.js前端性能优化JavaScript开源