1. 先别急着调优:先搞清Word公式在UEditor里到底慢在哪
在在线教育平台里,最典型的使用场景就是老师从Word里把带公式的题目整段复制,直接粘贴到网页编辑器里。这里说的UEditor,就是互联网圈里常说的那个开源富文本编辑器,很多老项目都还在用。使用频率一高,问题就集中成一个很具体的诉求:Word公式粘贴进来之后,渲染速度太慢,页面卡顿、公式半天出不来。
我见过不少团队遇到这个问题后,第一反应是换公式插件、加服务器,结果折腾了一圈,慢的环节根本没定位对。实际上,从Word复制公式到页面最终显示,要经过四段路径:粘贴解析、公式转换与上传、编辑器DOM生成、页面浏览时渲染。每一段都可能成为瓶颈,而且彼此互相牵连。公式若是被降级成图片,粘贴和上传就会吃掉大量时间;公式若走结构化文本渲染,瓶颈又会转移到浏览器渲染层。所以第一步不是调优,而是先把慢的位置搞清楚。
1.1 Word公式进入UEditor前,剪贴板里藏了什么
很多人以为公式在Word里是一个独立对象,复制过来就自动变成一张图片。实际情况复杂得多。从Word复制一段含公式的内容时,剪贴板里的HTML片段包含了好几种不同形态的东西。
Word会把公式以Office Math Markup Language(简称OMML)的形式放在HTML里,也就是一串以m:oMath开头的XML标签,里面用结构化的方式描述分数、上下标、根式、矩阵这些元素。这是消息最完整、最有价值的部分,因为它不是一张图,而是可以转换回公式语言的原始数据。
除了OMML,这段HTML里通常还带有一段VML矢量描述,用来在老版本浏览器里兜底显示;偶尔还会附带一张低分辨率的位图,用于完全不支持公式的环境。再算上Word自己生成的大量行内样式、字体声明、表格嵌套标签,整个粘贴内容常常是几十KB起步的"脏HTML"。
UEditor默认的粘贴过滤规则是按白名单清标签的,面对这种带命名空间的复杂结构,结果通常是OMML被直接丢弃,幸运的话只剩一张低清图片,不幸的话公式整段消失。这就是慢的第一个根源:Word公式在粘贴环节被降级成了图片,后续所有渲染都要围绕一张张又大又模糊的base64图片展开。
1.2 四段链路逐个卡点,才知道瓶颈在哪个环节
在动手改代码之前,我习惯先在浏览器Performance面板里录一段粘贴操作,把时间分布看清楚。这里整理成一个速查表,方便你对号入座。
| 阶段 | 典型瓶颈 | 用户可感知的表现 |
|---|---|---|
| 粘贴解析 | 大段HTML字符串处理、正则替换、白名单过滤 | 粘贴后页面卡顿1到3秒,光标转圈 |
| 公式转换与上传 | OMML识别失败、图片转base64、上传接口耗时 | 一直等待上传完成才能继续编辑 |
| 编辑器DOM生成 | 大批量节点插入、样式重算、UEditor过滤链处理 | 输入变卡,滚动明显掉帧 |
| 页面浏览渲染 | MathJax启动加载、字体闪烁、图片懒加载缺失 | 首屏公式空白,滚动时才一片片蹦出来 |
这四个阶段并不是每次都全走。如果公式能被识别为OMML,就可以绕开"上传图片"这个最重的环节;如果识别不了,只能当图片处理,那优化重点就变成图片压缩和异步上传。所以一个核心结论是:先建立公式识别逻辑,再谈渲染优化,否则后面做的都是白工。
2. 核心思路:把“贴图”改成“贴公式”,渲染速度才能真正上去
2.1 图片方案为什么慢:体积、清晰度、可复用性三连击
默认的图片方案看起来省事,实际上整个链路都背上了重包袱。公式图片要转base64、上传、存储、再下载展示,一条题目里有几十个公式时,页面上挂几百KB甚至几MB图片非常常见。网络状态稍差,渲染速度就完全失控。
图片方案还有个容易被忽略的坑:Word复制出来的公式图片分辨率通常只有96dpi,在现在的Retina屏幕上会明显发虚。老师看到公式"糊了",第一反应就是平台不行,这个体验伤害很大。
更长远地看,图片里的公式没法被搜索,没法被题库系统复用,后续想做公式相似题推荐、公式查重之类的功能,基本上要从头再来。所以无论是从性能角度还是从数据资产角度,图片方案都不该是长期选项。
2.2 新链路设计:OMML转LaTeX,渲染交给KaTeX与服务端
我们把整条链路改成这样:
Word剪贴板里的OMML → LaTeX文本 → 编辑器内以公式标记存储 → 页面渲染时用KaTeX生成HTML,或由服务端预渲染成SVG
公式从"一张图片"变成"一段结构化文本"之后,体积小几个数量级,可搜索、可复用,而且渲染速度比图片快得多。LaTeX文本存进数据库,将来哪天要换渲染方案,数据完全不用迁移,这是隐藏在背后的最大收益。
这条链路里,OMML到LaTeX的转换是核心难点,剩下的都是成熟技术。转换不是为了炫技,而是因为只有拿到了结构化的LaTeX,后面的快渲染才有意义。
2.3 渲染器选型对比:MathJax、KaTeX、服务端预渲染怎么挑
不同方案的取舍,我直接做成表格说清楚。
| 方案 | 编辑体验 | 渲染速度 | 实现复杂度 | 适用场景 |
|---|---|---|---|---|
| 全部转图片上传 | 所见即所得最好 | 慢,依赖网络 | 低 | 老浏览器兼容、临时过渡 |
| MathJax前端渲染 | 较好 | 中等,启动和字体加载偏慢 | 中 | 公式复杂且对兼容性要求高 |
| KaTeX前端渲染 | 好 | 快,同步渲染,字体可控 | 中 | 大多数在线教育平台的首选 |
| 服务端预渲染SVG/HTML | 较好 | 最快,前端几乎零计算 | 高 | 高并发分享页、静态页、SEO |
我在实际项目里最终采用的是"KaTeX前端渲染 + 服务端SVG兜底"的组合。编辑器和实时预览页用KaTeX,生成分享页或静态页时用服务端预渲染的SVG,两者互补,兼顾速度和兼容性。
3. 实操第一步:改造UEditor的Word公式粘贴链路
3.1 拦截粘贴事件并识别OMML公式
给UEditor的编辑区挂一个paste监听,重点不是拿到字符串就完事,而是判断HTML里是否包含OMML命名空间。有就走"公式流程",没有才走默认的Word过滤逻辑。
editor.ready(function () { var body = editor.body || document.getElementById(editor.key); body.addEventListener('paste', function (e) { var cd = e.clipboardData; if (!cd || !cd.getData) return; var html = cd.getData('text/html'); if (!html || html.indexOf('schemas.openxmlformats.org/officeDocument/2006/math') === -1) { return; // 不是Word公式,交给UEditor默认粘贴逻辑 } e.preventDefault(); e.stopPropagation(); var latex = extractWordFormulaLatex(html); if (latex) { insertFormulaLatex(latex); } else { // 转换失败时回退,把原始HTML交给UEditor插进去 editor.execCommand('insertHtml', html); } }); });这里有个经验要提一下:不要依赖editor.execCommand('paste')来恢复默认粘贴,部分浏览器会直接忽略这个命令。安全做法是保存原始HTML,失败时走insertHtml,虽然效果和原生粘贴略有差异,但至少数据不丢。
3.2 OMML转LaTeX:前端轻量转换与服务端转换怎么选
前端轻量转换的思路是用DOMParser解析出所有m:oMath节点,先转成MathML,再用MathML转LaTeX的库处理。
function extractWordFormulaLatex(html) { var doc = new DOMParser().parseFromString(html, 'text/html'); var mathNodes = doc.querySelectorAll('m\\:oMath, oMath'); if (!mathNodes.length) return null; var parts = []; mathNodes.forEach(function (node) { var mathml = omml2mathml(node); // 参考OMML2MML.XSL的移植实现 var latex = mathml2latex(mathml); // 用现成开源库,别自己造轮子 parts.push('[' + latex + ']'); }); return parts.join(' '); }OMML转MathML,可以直接参考微软公开的OMML2MML.XSL模板,社区里有大量语言移植版本,拿Node版或Java版改改就能用。MathML转LaTeX的库也有不少,但质量参差不齐,建议先跑一批含矩阵、分段函数、大括号的样例再上线。
服务端转换则更省心。前端把OMML片段POST到后端,由转换服务统一处理,产出LaTeX后返回。服务端的好处是可以用Pandoc这类成熟工具,转换准确率高,复杂公式处理得更稳;坏处是多一次网络往返。我的建议是编辑场景做成"先乐观插入占位符,转换完成后替换",用户体感基本无感,不会因为等待接口而卡住。
async function convertOMMLToLatex(ommlXml) { const res = await fetch('/api/formula/convert', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ omml: ommlXml }) }); const data = await res.json(); return data.latex; }3.3 把LaTeX公式插入编辑器的正确姿势
不要在编辑器里直接插入LaTeX字符串。UEditor会把[、\、{这些字符当作普通文本过滤或转义,存库时也可能被干扰。正确做法是注册一个自定义公式节点,用span做占位,把LaTeX放在>UE.plugins['formula'] = function () { var editor = this; editor.commands['formula'] = { execCommand: function (latex) { var range = editor.selection.getRange(); var span = document.createElement('span'); span.className = 'formula-box'; span.setAttribute('data-latex', encodeURIComponent(latex)); span.innerHTML = '[公式]'; range.insertNode(span); range.setStartAfter(span); range.collapse(true); editor.selection.setRange(range); editor.undoManager.add(); editor.fireEvent('contentchange'); } }; };
这里有两个容易踩的坑。
第一,自定义标签一定要加入UEditor的过滤白名单,否则getContent()取出来存库时,公式节点会被清理掉。排查时可以打开控制台看UE.getEditor('id').getContent()的输出,占位标签不见了,基本就是过滤规则在作妖。
第二,要记得调用undoManager.add()手动记录撤销点。UEditor的撤销栈是按命令记录的,自定义插件如果不加这一步,用户按Ctrl+Z时公式节点会停留在页面上,留下难以解释的脏状态。
4. 实操第二步:用KaTeX替换慢渲染,并做懒加载
4.1 KaTeX为什么比MathJax快,快在哪里
MathJax是异步渲染,公式多的时候需要用MathJax.typesetPromise()批量刷新,而且启动时要加载字体、解析配置,第一次出公式往往要几百毫秒。页面里公式一多,还会出现"先看到一堆乱码,再慢慢变公式"的中间态,观感很差。
KaTeX则采用同步渲染,DOM插入完成的同时公式就已经呈现在页面上。字体通过CSS加载,没有整套解析等待的过程,出公式的速度几乎感觉不到延迟。对在线教育平台来说,一个题目页面动辄几十条公式,KaTeX产出的HTML节点也比MathJax紧凑得多,DOM节点压力小了,滚动自然就流畅。
如果项目里公式复杂度集中在初高中和大学基础数学,KaTeX的宏支持完全够用。真要遇到特别冷门的符号,再考虑局部降级到MathJax,没必要全站承担MathJax的启动成本。
4.2 KaTeX自动渲染接入配置与定界符避坑
页面引入KaTeX的CSS、核心库和auto-render扩展,然后对文章容器执行一次渲染即可。
renderMathInElement(document.getElementById('question-content'), { delimiters: [ { left: '\\[', right: '\\]', display: true }, { left: '$$', right: '$$', display: true }, { left: '\\(', right: '\\)', display: false }, { left: '$', right: '$', display: false } ], throwOnError: false, errorColor: '#cc0000', macros: { '\\RR': '\\mathbb{R}' } });这里必须提醒一句:$...$作为行内公式定界符,会跟货币符号、模板字符串里的美元符冲突。教育平台如果涉及价格展示、代码描述,强烈建议只保留\(...\)作为行内定界符,避免把普通文本误渲染成公式。我们上线后遇到过一次金额显示异常,排查下来就是定界符冲突,后来把$定界符去掉才彻底解决。
宏配置也值得提前梳理。数学老师习惯用\bm、\boldsymbol、\mathrm{d}这类宏,KaTeX自带一部分,但业务自定义符号需要提前整理成宏表,否则公式会出现红色报错。整理宏表这件事看着琐碎,却是保证公式稳定渲染的关键前置工作。
4.3 用IntersectionObserver实现公式按需渲染
如果每道题都预先渲染,公式量动辄几百条的题库列表页照样会卡。更合理的做法是"可见才渲染":公式节点先保留>var observer = new IntersectionObserver(function (entries) { entries.forEach(function (entry) { if (!entry.isIntersecting) return; var box = entry.target; if (box.dataset.rendered) return; katex.render(decodeURIComponent(box.dataset.latex), box, { displayMode: false }); box.dataset.rendered = '1'; observer.unobserve(box); }); }, { rootMargin: '200px' }); document.querySelectorAll('.formula-box').forEach(function (box) { observer.observe(box); });
这个方案的好处是首屏渲染时间几乎与总公式数无关,只和首屏可见公式数相关。列表页、试卷页这类"公式很多但大部分在屏幕外"的场景,体感提升最明显。rootMargin: '200px'是提前预渲染的余量,可以避免滚动到边缘时才临时渲染的闪烁感。
5. 实操第三步:服务端预渲染与资源层提速
5.1 公式预渲染SVG,让分享页零依赖
编辑和实时预览用KaTeX没问题,但分享页、打印页这种只读场景,更好的做法是服务端预先渲染成HTML或SVG,前端直接嵌入,不再加载任何公式渲染库。
Node服务里用一行就行:
const katex = require('katex'); const html = katex.renderToString(latex, { displayMode: true, throwOnError: false });渲染结果按LaTeX原文做哈希命名,存CDN或直接存库。内容是公式,原文不变,渲染结果就不应该变,所以可以配很激进的缓存。前端输出时直接插入HTML片段,页面加载速度和纯文本页面几乎没有差别。
这种方案对高并发分享场景特别有价值。分享页流量往往是脉冲式的,一条题目突然被大量转发,如果每次都让前端跑KaTeX,服务器可能要承接大量静态资源请求;服务端预渲染之后,所有压力都被CDN吸收。
5.2 历史图片公式压缩与CDN缓存策略
即便上了结构化公式,历史数据里还是会有大量图片公式。建议做一个离线批量任务,把老图片统一处理:PNG转WebP,宽高限制到适合阅读的最大尺寸,再用CDN替换原始地址。
公式图片跟普通照片不一样,文字边缘对压缩非常敏感。质量参数不要调太低,我实测下来0.85左右比较稳妥,再低会出现明显的锯齿和发虚。图片处理时还要注意透明背景问题,白底公式图转JPEG前要先把透明区域填充成白色,否则公式周围会带一圈黑边。
缓存策略可以配得比较激进。公式是不可变资源,LaTeX不变,渲染结果就不变,所以图片和SVG都用Cache-Control: public, max-age=31536000, immutable;KaTeX的字体、CSS、JS同样走CDN并用长期缓存;内容页HTML则用短缓存加协商缓存,保证新发布的题目能及时看到。
5.3 转换服务异步化,避免高峰假死
粘贴场景对转换接口的延迟非常敏感,不能因为转换服务处理不过来,就把整个编辑页面拖死。我的做法是把"OMML转LaTeX"做成异步任务:前端先把占位符插入光标位置,转换请求进消息队列,完成后通过轮询或WebSocket把结果回推给编辑器。
这里有一个我实际踩过的坑。初版方案是同步转换,上线后遇到一次教师集中组卷的高峰,几十个并发粘贴请求把转换服务直接打满,整个编辑模块出现"保存题目一直转圈"的现象,值班同事一度以为是数据库出了问题。改成异步队列之后,单次转换耗时没有变,但毛刺完全消失,用户最坏情况只是占位符多停留一两秒,编辑流程永远不会被堵死。
6. 现场实录:常见问题与排查技巧速查
6.1 公式变成空白或乱码
九成是UEditor的过滤规则把自定义公式节点清理了。排查路径很固定:先在浏览器控制台执行UE.getEditor('id').getContent(),看存库内容里formula-box标签是否存在。如果标签没了,就去检查自定义插件是否注册成功,以及过滤白名单是否声明了该标签。这类问题大多不是渲染器的问题,而是编辑器数据链路的问题。
6.2 公式显示为图片但模糊
两种情况最常见。一种是在粘贴时没能识别到OMML,公式走了图片兜底;另一种是老数据本身就是低分辨率图片。前者要回查剪贴板HTML里是否真的包含OMML命名空间,有些精简版Office或WPS复制出来的内容格式不一样,识别规则要加兼容分支。后者建议用srcset提供2倍图,URL上加宽度参数,让高清屏自动取高清图。
6.3 转换接口偶发超时
转换服务是CPU密集型任务,超时往往发生在大型矩阵、复杂多行公式上。建议给接口设置超时上限,超时后返回"需人工处理"的标记,前端转成图片兜底,而不是让页面一直转圈。同时把超时请求的OMML原文保存到日志,定期分析,针对那些高频触发超时的公式结构去优化转换规则。
6.4 页面滚动掉帧严重
先检查是否对全页执行了renderMathInElement。全量渲染会在一瞬间生成大量DOM节点,滚动自然卡。改成懒加载之后,重点关注滚动过程中是否触发了大量新的渲染任务,可以加一个简单的滚动节流,滚动期间只处理已经在视口内的节点,新进入视口的公式等requestAnimationFrame调度后再渲染。
6.5 KaTeX报错与字体加载异常
throwOnError: false能避免单个公式报错导致整个页面崩溃,但错误日志一定要记录下来。我在项目里是做了一套错误回传,把渲染失败的LaTeX原文上报到日志平台,每周统计一次,按错误类型补充宏定义或者修正转换规则。
新手最容易忽略的是字体路径。KaTeX的字体文件路径配错了,公式会显示成一排占位方块,而不是公式本身。所有静态资源尽量走同一套CDN域名,发布时检查字体文件的MIME类型是不是font/woff2,这个细节坑过很多团队。
| 问题 | 排查入口 | 解决办法 |
|---|---|---|
| 公式变空白 | 控制台查看getContent()输出 | 检查自定义标签白名单与插件注册 |
| 公式图片模糊 | 检查粘贴识别链路与图片URL参数 | 补OMML识别分支,加srcset 2倍图 |
| 转换接口超时 | 看慢请求日志与公式复杂度 | 异步队列加超时降级 |
| 滚动掉帧 | Performance录制滚动时段 | 懒加载加requestAnimationFrame调度 |
| 公式占位方块 | 检查字体MIME与CDN路径 | 修正字体路径与MIME类型 |
7. 上线后的实测体感与经验总结
这个优化做完之后,最明显的变化不是某个单点变快了,而是整个链路平稳了。以前老师从Word粘贴一道含十几个公式的题目,页面要卡顿好几秒;现在粘贴动作在百毫秒级别就能完成,公式以占位形式出现,等到浏览时再渲染,整个过程非常顺滑。列表页和试卷页的滚动体验提升也很大,首屏公式几乎是秒出,不再有"空白一片后慢慢蹦出来"的烦恼。
我个人在实际操作中最大的体会是,不要被"渲染"这个词带偏。整条链路里最慢的往往不是渲染本身,而是公式在粘贴环节被降级成了图片。把OMML识别和转换做扎实,后面的一切优化都事半功倍。
另外还想分享一个建议:公式渲染优化尽量不要只在编辑器里单测。完整的链路是"Word到剪贴板,再到UEditor,再到数据库,再到列表页和详情页,最后到客户端渲染",任何一个环节的格式约定不一致,问题都会转嫁到别的地方。先把整条链路的公式格式统一成LaTeX,再谈渲染方案,后续无论换成什么编辑器、什么渲染器,数据都牢牢抓在自己手里。
如果你现在的平台还在用图片公式,最优先做的两件事是:把Word粘贴的OMML识别逻辑加上,给历史图片公式做一次CDN化和压缩。这两步做完,体感就能改善一大半。之后再考虑KaTeX替换、懒加载、服务端预渲染这些进阶项,节奏会稳得多。