
这个问题我断断续续折腾了小半年起因特别朴素想给一个在线阅读工具加批注能力但调研了一圈现成的在线编辑器发现它们要么太“重”——为长篇电子书设计不足要么太“轻”——连基本的样式保持都做不到。后来我把目光放到“在线编辑器”这个宽泛的领域里把富文本、Markdown、代码编辑器、电子书阅读器全看了一遍最终意识到对于epub这种格式单纯找一个编辑器库根本解决不了问题真正要做的是把“渲染引擎”和“编辑能力”拼起来。这篇文章就是我完整探索过程的记录虽然标题叫“记一次在线编辑器的探索”但实际落点更多在“epub在线编辑器”这个方向上因为我最后做的就是一个能在浏览器里打开、编辑、重新导出epub的工具。如果你也在纠结“网页上做编辑器到底选什么方案”“epub格式到底怎么在浏览器里渲染”这篇应该能帮你省不少时间。1. 需求推演我要的不是“输入框”而是“能改文档的阅读器”很多人一提在线编辑器第一反应是ContentEditable加一堆工具按钮或者找个现成的富文本库。我一开始也是这么想的结果越做越不对。先把需求摆清楚我手头的东西是一个在线阅读工具用户上传一本epub在网页里翻开就能读现在要加一个能力——用户选中书里的一段文字能做高亮、加批注、改样式最后还能把修改后的内容导出一本新的epub。听起来像是“在浏览器里改文件”但本质上它的复杂度比“做一个输入框”高一个量级因为epub内部不是纯文本它是一堆HTML文件加CSS文件打包在一起的压缩包。所以我对候选方案定了几个硬性指标必须能完整保留原排版。用户在书里看到的字体、颜色、段落间距不能因为进入编辑器就被拍平。要能按书的结构分章节操作。epub一本书可能有几十个xhtml文件你不能让用户在一个无限长的页面上滚动。编辑动作要落到具体节点上。高亮一段文字、替换某个词、调整某个标题样式这些都要能精确映射到DOM节点。导出时要能重新打包回epub格式并且保持zip结构合法。这些条件列出来之后常规编辑器库基本全军覆没。富文本编辑器的数据模型通常是“段落内联样式”根本承载不了epub的多文件结构。Markdown编辑器就更不用想了它本身就是一种降级。那时候我才确定整个方案的架构不能是“找一个编辑器”而应该是“找一个合适的渲染器然后在渲染器上面挂一个编辑层”。想清楚这一点之后我不再纠结“用哪个编辑器”转而开始研究“在线编辑器的技术路线到底有哪几类”以及“epub本身在浏览器里是怎么被读出来的”。2. 编辑器生态摸底富文本、Markdown、代码编辑器各管一段人一旦开始地毯式搜索“在线编辑器”很快就会发现市面上能叫“编辑器”的东西分三大类它们解决的问题完全不同。2.1 三条技术路线的核心差异类型代表项目内容模型适合场景对epub的适配度富文本编辑器Quill、TipTap、SlateJSON或HTML的块级模型博客后台、CMS、文档协作低样式容易被拍平Markdown编辑器Vditor、ByteMD、Toast UIMarkdown源码技术写作、笔记低本质上是对纯文本的增强代码/结构化编辑器CodeMirror、Monaco纯文本语言服务代码编辑极低完全没有排版概念这条线摸下来我发现一个有意思的事**不是哪个编辑器库不好用而是它们默认的“内容模型”就和epub不匹配。**富文本编辑器为什么叫“富文本”因为它把内容抽象成“段落→行内元素→样式”的树结构。你贴一段带复杂CSS的HTML进去它会按照自己的规则重新解析丢掉不支持的部分。Quill甚至默认使用Delta格式你粘贴进去的样式信息基本只剩加粗、斜体、颜色。对于一本正经的电子书来说这种损耗是致命的。2.2 我实际跑过的几个方案我花了两三天时间把主流几个库的示例都跑了一遍结论如下Quill上手确实快但扩展复杂格式很痛苦。它的工具栏、主题、模块系统都是围绕“文章编辑”设计的你要让它显示一本epub的章标题、脚注、图片、段首缩进能用但每个点都需要自定义。TipTap基于ProseMirror数据模型更严谨支持自定义节点和命令。但ProseMirror的schema设计非常反直觉你想让它接受“任意HTML”就得先定义那些HTML怎么映射到schema里。这个学习成本对一个小工具来说太高了。Vditor国产项目对中文支持好支持所见即所得和分屏预览。如果是做笔记类工具我会选它但它同样解决不了“加载一个多文件epub”的问题。Monaco拿来写代码很强做电子书阅读就是个灾难。它的核心是文本缓冲区和语法高亮没有任何排版引擎概念。跑完这些之后我彻底确认了一件事**对epub这种“自带排版”的格式编辑器的核心竞争力不是“编辑”而是“渲染”。**谁能在浏览器里原样渲染epub谁才有资格谈编辑。2.3 为什么现成方案都不直接可用最直接的原因是epub的渲染链路是“文件级”的。一本书不是一个HTML文件而是多个HTML文件通过OPFPackage Document里的spine定义顺序串起来的。你打开书的第一章读的是chapter1.xhtml划到下一页可能就跳到chapter2.xhtml了。普通编辑器根本不会去理解这层结构它只关心“当前这个HTML怎么编辑”。这就逼着我换一条路先把epub的解析和渲染搞清楚再考虑编辑。3. 关键转折吃透epub格式才算摸到编辑的门把手3.1 epub不是一个格式而是一个zip容器这句话我反复和同事强调。epub文件的后缀是.epub但它本质上是zip压缩包内部有一套固定的目录规范mimetype META-INF/ container.xml OEBPS/ content.opf toc.ncx text/ chapter1.xhtml chapter2.xhtml styles/ style.css images/ cover.jpg其中mimetype文件必须存在且必须是无压缩存储Stored这是epub规范里很较真的一条很多导出工具在这里翻车。container.xml的作用是指向content.opf的路径而content.opf是整本书的“总调度”它列出所有资源文件manifest、阅读顺序spine、元数据书名、作者、语言等。要在浏览器里解析epub本质上就是用JSZip解压这个zip → 读container.xml找到OPF → 读OPF拿到spine列表 → 按顺序加载对应的xhtml文件。这一步打通之后后面所有编辑操作都有了地基。3.2 我测试过的两个关键库epub.js和foliate-js这个领域绕不开两个名字。第一个是epub.js很多在线epub阅读器都用它API设计简洁一个rendition对象就能渲染整本书。我一开始以为可以直接用它的rendition加上contentEditable来实现编辑试了一下午发现不行。epub.js的渲染方式是创建iframe把章节内容写进iframe的document里然后通过rendition.on(render)等事件暴露章节窗口。理论上你可以在这个iframe里操作DOM但实际上epub.js的分页机制用的是CSS的columns布局页面切换时会不断重排DOM你的编辑结果可能在某次翻页后被重绘冲掉。第二个是foliate-jsKOReader的作者做的现代版解析器代码风格比epub.js更干净支持OPF解析、目录解析、流式渲染而且它对iframe内部的操作更透明。我最终以它作为解析层自己做渲染和编辑层灵活性大很多。3.3 “编辑”的边界在哪里这里必须澄清一个概念**对epub的“编辑”和给Word文档做编辑不是一回事。**Word文档是“流式排版”的单文件编辑就是改内容。epub是“结构化容器”你要改的不是一个文件而是一组文件的关系和内容。在我做的工具里“编辑”被定义为四类操作高亮/批注选中文字用一个span包起来加class旁边写批注。替换内容把某一个词的文本节点内容换掉。样式调整通过动态注入CSS改变标题颜色、正文字体大小、行距。元数据修改改书名、作者、语言等OPF里的元数据。这四个操作如果抽象出来其实只有两类修改DOM节点和修改CSS规则。不涉及到重新排版章节、拖动图片位置之类的复杂操作。把编辑边界收窄技术难度一下子降低了非常多这也是我踩完坑后最重要的一条经验做在线编辑器先定义清楚“编辑”对你的业务来说到底意味着什么别一开始就想着做成通用工具。4. 最小可行架构渲染容器加编辑层两条腿走路走通了解析链路之后整个工具的大框架就出来了。我没有选择去魔改任何编辑器库而是搭了一个相对干净的架构核心思路是“渲染归渲染编辑归编辑”。4.1 双iframe隔离每个章节单独用一个iframe渲染。这个设计有两个好处第一个好处是样式隔离。epub的CSS经常写得很野什么body { background: black; }、* { margin: 0; }都有如果不放在iframe里这些样式会污染整个宿主页面。放进iframe等于是给每本书盖了个玻璃房里面的风吹不出来。第二个好处是行为隔离。iframe内部的滚动、点击、富文本编辑操作不会冒泡到外层页面。我可以安心地在外层监听消息而不担心事件冲突。4.2 内容加载与路径改写用foliate-js解析完OPF拿到章节地址后关键一步是路径改写。epub内部的CSS和图片引用用的是相对路径比如../images/cover.jpg。如果直接通过blob URL或者Data URL加载xhtml这些相对路径全部失效。我采用的方案是把每个章节文件转成Blob通过URL.createObjectURL生成一个blob URL然后把章节里的src和href属性从相对路径改成绝对URL。具体实现如下const chapterBlob new Blob([chapterContent], { type: application/xhtmlxml }); const blobUrl URL.createObjectURL(chapterBlob); // 解析章节内容中的相对路径资源 const resourceBase getChapterBaseUrl(opf, chapterPath); const absoluteContent rewriteRelativePaths(chapterContent, resourceBase);这里有几个细节要注意rewriteRelativePaths不能只替换src和hrefCSS里的url()引用也得处理。所以我额外写了一个正则把url(../images/xxx.jpg)替换成绝对地址并且还要处理font-face里的src。这一步如果偷懒就会发现电子书里一半图片加载不出来字体全部失效整个排版崩了一半。4.3 核心编辑能力的最小集合编辑层我并没有引入重型编辑器而是基于原生contentEditable外加自定义选区操作。每个章节iframe的文档进入编辑模式后用户选中文字编辑器执行高亮操作function highlightSelection(iframeDoc, color) { const selection iframeDoc.getSelection(); if (!selection.rangeCount) return; const range selection.getRangeAt(0); const span iframeDoc.createElement(span); span.className user-highlight; span.style.backgroundColor color || #ffeb3b; span.dataset.noteId generateId(); try { range.surroundContents(span); } catch (e) { // surroundContents在选区跨越多个元素时会失败退化为包一层 const fragment range.extractContents(); span.appendChild(fragment); range.insertNode(span); } }这里还有个小坑如果用户选中的范围跨越了多个段落surroundContents会直接抛异常。所以必须做降级处理用extractContents把选中内容拿出来包上span再插回去。这个逻辑看起来简单但它是整个编辑器最常用的一条路径值得多花心思。样式调整则更简单动态注入style标签function injectCustomStyles(iframeDoc, cssText) { let styleEl iframeDoc.getElementById(custom-editor-style); if (!styleEl) { styleEl iframeDoc.createElement(style); styleEl.id custom-editor-style; iframeDoc.head.appendChild(styleEl); } styleEl.textContent cssText; }用户调整正文字号、行距、背景色本质都是往这个style标签里追加CSS规则不做复杂的选中定位也不改原书CSS文件只在入口处增加个性化皮肤层。这个设计的好处是导出的时候只要决定“要不要把用户样式写进书里”如果不需要直接丢弃这个style标签原书分毫未动。4.4 导出重建epub的必修课编辑完要导出这是整个链路的收口。原则上导出的epub必须仍然是一个合法的zip容器。我用的JSZip重点注意两点第一mimetype文件必须第一个写入且使用compression: STORE不压缩。这也是epub规范的要求Windows资源管理器、macOS的归档工具都会校验这个头。第二文件路径要和原书保持一致不能因为编辑过程中改了文件名导致OPF里的清单失效。核心代码示意const zip new JSZip(); zip.file(mimetype, application/epubzip, { compression: STORE }); zip.file(META-INF/container.xml, containerXml); zip.file(opfPath, updatedOpf); // 遍历原epub所有文件替换编辑过的章节 for (const filePath of originalFilePaths) { const content editedFiles[filePath] || originalFiles[filePath]; zip.file(filePath, content); } const blob await zip.generateAsync({ type: blob, mimeType: application/epubzip, compression: DEFLATE });这一步做完在线编辑器的闭环就完整了打开→解析→渲染→编辑→导出。虽然每本书的具体情况不一样但主干逻辑跑通后剩下的就是各种边角料。5. 踩坑实录这里面的水比想象中深整个探索过程里我最想分享的是那些“文档里不会写但实际一定会遇到”的坑以下按严重程度排序。5.1 contentEditable和分页布局打架epub阅读器通常有两种翻页模式滚动模式整个章节上下滚和分页模式像纸质书一样一页一页翻。分页模式底层用的是CSS columns或类似的分栏/分页技术内容会被浏览器强行切割成多个视觉区域。一旦你把章节文档设为contentEditable麻烦就来了光标定位在这种分栏布局里经常错位选区的视觉高亮和实际DOM节点不完全对应翻页时自动重排又会把光标丢到别的地方去。我的解决办法很土但很有效编辑模式下强制切成滚动模式。用户想编辑就先从分页模式切换到连续滚动编辑完重新进入阅读模式再恢复分页。这样虽然牺牲了一点“原位编辑”的沉浸感但换来的是稳定可靠的光标和选区行为。5.2 样式隔离是个没有硝烟的战场前面说iframe能隔离样式但有一个地方隔离不了iframe内部页面继承的默认字体、背景、链接颜色会被浏览器默认样式影响。我遇到过一本老书它的CSS只设置了正文字号没设置字体族结果在浏览器里渲染出来是宋体在另一个浏览器里是黑体观感完全不一样。这个不是bug而是不同浏览器默认样式的差异。解决办法是在加载章节时向iframe注入一个基础重置样式html, body { margin: 0 !important; padding: 0 !important; } body { font-family: -apple-system, BlinkMacSystemFont, Segoe UI, PingFang SC, Microsoft YaHei, sans-serif !important; line-height: 1.7 !important; }但注意这句!important很粗暴一个不小心就把原书的排版风格覆盖了。后来我改成只在用户开启“自定义样式”时才注入字体族和行距默认情况下只清margin和padding其他全部保留原书设置。5.3 字体引用的CORS和加载时序问题这是另一个高频坑。epub里面经常有自定义字体通常放在fonts/目录下CSS里通过font-face引用。在本地解压工具里一切正常但在网页里直接用file://协议或者blob URL引用字体大概率会碰到CORS报错。正规做法是把字体文件也转成blob URL并在章节iframe里重写font-face的src。另外一个坑是字体加载时序——章节DOM已经渲染完了字体还没加载完此时浏览器会用默认字体先显示等字体加载完毕再“闪”一下。对读者的阅读体验影响极大对编辑器来说倒是还好但我在探索时花了不少时间排查这个“字体闪烁”问题最后是通过自定义字体加载事件在加载完成前显示一个透明的fallback字体加载完再切换能明显缓解。5.4 中文排版的行内元素包裹问题做高亮操作时最容易出问题的不是英文而是中文的分词和标点。比如用户选中的文字正好是从一个段落的中间开始、到另一个段落的中间结束这个选区跨了两三个段落。我用extractContents降级处理时中文引号、书名号可能被切割开输出结果就变成了未闭合的引号虽然HTML结构上是合法的但阅读时很别扭。这个问题没有特别完美的解法我的策略是让高亮选区的边界自动扩展到“整词”级别。对中文扩展逻辑是选区的开头向左回溯直到遇到空格或标点选区的结尾向右推进直到遇到空格或标点。这样可以避免切到半个词对英文和中文都适用。5.5 导出文件的路径大小写敏感问题这个问题很隐蔽。早期我在做导出测试时用一本从网络上下载的epub在Windows上打开完全正常在Linux服务器上用epubcheck校验却报错资源文件找不到。查了半天才发现那本书内部的CSS引用图片的路径是Images/cover.jpg但实际压缩包里的文件名叫images/cover.jpg大小写不一致。Windows的文件系统不区分大小写所以打开正常Linux区分所以校验失败。在线编辑器对这个问题的处理是在解析阶段做一次路径归一化生成一份“实际路径→引用路径”的映射表导出时按映射表重写所有引用确保大小写严格匹配。不要相信源epub内部路径一定规范这个坑我踩得实实在在。6. 探索之后的方案选型建议走完这一圈我想给后来的人一个可以直接抄的选型清单而不是泛泛而谈。你真正要做的推荐方案理由在线写笔记、写文章样式要求不高Vditor / TipTap数据模型友好中文支持好上手快在线编辑docx或类Office文档不要自己造轮子直接用OnlyOffice / Collabora排版复杂度和电子书完全不是一个量级在线阅读epub并做高亮批注foliate-js 自定义iframe编辑层解析层成熟编辑层完全可控在线编辑epub并重新导出按我上面这套“渲染编辑打包”链路做没有现成库能端到端解决在线编辑代码直接用Monaco或CodeMirror 6这块生态极其成熟跳过很多人问我为什么最后没有直接用开源的epub编辑器比如Sigil的网页版或者Calibre的在线版。诚实地说开源生态里没有一个浏览器端的epub编辑器能达到生产级。大部分阅读器只能看不能改少数能改的也只是改改元数据。这个领域基本是空白所以我只能自己拼装而这篇文章就是拼装过程的完整复盘。如果你和我一样要做的不是“做一个通用的在线编辑器”而是“在某个具体格式、具体业务约束下把编辑能力做进去”那我的核心建议就一句话**先解剖格式再选择库最后定义编辑半径。**顺序反了后面全是坑。