在富文本里插公式,我为什么最后把 MathQuill 换成了 MathLive
先想一个画面:题干里插着一张公式图片。字号和正文对不上,放大了糊成马赛克,老师想把 2x 改成 3x,只能把整张图删掉重画。更麻烦的是,这张图在数据库里就是一坨 base64,搜不到、改不了、导不进 Word。
在线教育、题库、文档协作,只要沾了数学内容,早晚会撞上这个问题。我做的是一套嵌在富文本编辑器里的可视化公式输入,前后换过两套渲染引擎。
先说结论:TinyMCE 6 + MathLive 编辑 + MathJax 3 展示,公式以 LaTeX 源码存库,渲染产物扔进 Shadow DOM,不进保存内容。
git地址:github.com/liliyang857…
在线体验:liliyang857.github.io/math-latex-…
一、先想清楚一件事:公式到底以什么形态存在
动手之前必须先把这个问题定下来,它决定了后面所有接口的形状。市面上常见三种存法:
| 存法 | 二次编辑 | 导出 PDF/Word | 存储体积 | 搜索 |
|---|---|---|---|---|
| PNG 图片 | 要靠 OCR,或者把源码塞进 data-* | 位图,放大会糊 | 大(base64 一张几十 KB) | 搜不到 |
| LaTeX 字符串 | 直接改源码 | 需要服务端重新渲染 | 小(几十字节) | 能搜到源码 |
| MathML | 需要专门的编辑器 | 依赖目标格式支持 | 大 | 一般 |
我们选了 LaTeX 源码:数据库里存的是 x^2-2x-3=0 这种裸文本,展示时再渲染。理由很朴素——它是唯一一种既轻量、又能被任意系统消费、还能人肉读懂的格式。
但这带来一个新的硬约束:任何一次「编辑 → 渲染 → 再编辑」的往返,都不能污染源码。
这一条后来成了整套选型的判据。
二、MathQuill 看着最省事,但它有天花板
一开始我选的是 MathQuill 0.10。理由很充分:它是 Khan Academy 和一大批在线题库在用的老牌方案,API 简单,latex() 直接进出。
上线前做功能验收,撞到墙:
| 诉求 | MathQuill 0.10 | MathLive 0.111 |
|---|---|---|
矩阵 \begin{pmatrix} | ❌ | ✅ |
分段函数 \begin{cases} | ❌ | ✅ |
多行对齐 \begin{aligned} | ❌ | ✅ |
细空格 \, | ❌ 丢弃或变逗号 | ✅ 原样保留 |
化学式 \ce{H2O}(mhchem) | ❌ | ✅ |
| LaTeX 往返 | ⚠️ 有损 | ✅ 逐字符无损 |
| jQuery | 必须锁 3.x | 不需要 |
| 静态渲染 | 要另接 MathJax | 同引擎 |
「有损」这三个字杀伤力最大。MathQuill 的 latex() 会做等价改写:
x^{2}导出成x^2(单字符上下标去花括号)- 括号自动补
\left…\right - 命令后的尾随空格被
replace(/(\\[a-z]+) (?![a-z])/ig,'$1')干掉
功能上等价,但对「以源码为唯一真相」的设计来说这是致命的:用户只是在可视化区点了一下光标、什么都没改,源码就变了。任何基于源码的 diff、去重、缓存、审核记录都会失效。要挡住只能写归一化函数,两侧都清洗一遍再比——这套活儿越写越脏。
还有个更直接的坑:npm i jquery 装到 4.x,MathQuill 加载即抛 TypeError: Cannot read properties of undefined (reading 'prototype')——它内部用了 jQuery.isFunction、.bind(),jQuery 4 全删了。得精确锁 3.7.1。
所以最终换了 MathLive。数学能力开箱即用,往返逐字符无损,还省掉一个 jQuery。
代价是体积。 MathLive 编译后 800 KB(gzip 221 KB)。这个数字放在内网后台无所谓,放在 C 端页面里必须处理。后面第八节讲怎么压。
三、双引擎:编辑用 MathLive,展示用 MathJax
这是整个项目里最反直觉、也最值得讲的一个决定。
既然 MathLive 就能渲染公式,为什么正文还要再挂一套 MathJax?
因为 MathLive 的排版是「面向输入」的,MathJax 的排版是「面向成品」的,两者的错误策略相反。
真实的 bug 长这样:编辑器的实时预览区用的是 MathLive 的 <math-span>,用户敲了 \left( \frac{1}{2},忘了闭合 \right)。MathLive 为了不让编辑者卡住,会自动补一个不可见定界符继续排版——所以预览区里公式看起来是好的。
点确认,插进正文,正文走 MathJax。MathJax 严格校验,判定失败,回退成源码文本。
现象就是:预览正常,插入正文变成一串 LaTeX 源码。录入时完全发现不了。
修法不是让 MathJax 也容错,而是把预览也切到 MathJax,让录入时看到的就是正文里会看到的东西。SDK 0.2.26 就是这么改的:底部预览和模板卡片统一走 renderLatex() 同一条链路,遇到 data-mjx-error 就直接显示源码(警示色 + title 说明)。
这条规则后来沿用到了所有地方:同一份 LaTeX,全系统只有一条渲染链路,只有一套判据。
编辑器和正文的数据流
调用侧只有三个 API:
import { openFormulaEditor, renderLatex, initFormulaRendering } from 'formula-editor-modal'
import 'formula-editor-modal/style.css'
// 应用启动调一次:让页面里已有的 LaTeX 节点能渲染
await initFormulaRendering()
const handle = openFormulaEditor({
subject: 'math', // math / physics / chemistry
outputType: 'latex', // latex 返回公式节点;img 返回 PNG 图片
latex: '', // 初始源码;双击编辑时传节点的 data-latex
async onConfirm(result) {
const html = result.type === 'latex'
? await renderLatex(result.data) // → 标准 span
: result.data // → <img> HTML
editor.insertContent(html)
},
onCancel() {},
})
onConfirm 支持 async:插入成功之后才关弹窗,失败就显示错误、保留输入、可以重试。
节点长什么样
<span class="fe-formula" data-latex="x^2"
contenteditable="false"
style="display:inline-block;vertical-align:baseline;">x^2</span>
三点设计:
data-latex是唯一真相。文本内容是x^2(未渲染时的降级显示),渲染产物在 Shadow DOM 里。contenteditable="false"让富文本编辑器把整个 span 当成一个原子节点,光标进不去,不会把公式拆散。- SVG 在 Shadow DOM,所以
innerHTML、cloneNode都拿不到它——这是特性不是 bug:库里存的 HTML 只有几十字节,不带上百 KB 的 SVG 路径。
vertical-align: baseline 是 0.2.9 加的。之前默认的 middle 会让公式里的数字和正文数字上下错位半个字,行内看起来很明显。
四、接进 TinyMCE:花时间的不是公式,是光标
TinyMCE 本身不带公式能力,但它把「富文本编辑器该有的脏活」都做对了:撤销栈、选区管理、粘贴清洗。SDK 不需要知道 TinyMCE 存在,宿主负责接线。
接线有四件事必须做对,每一条都是踩出来的。
1. 用 inline 模式,让两边共用同一份 document
await tinymce.init({
target: editorElement.value,
inline: true,
fixed_toolbar_container_target: toolbarElement.value,
toolbar_persist: true,
skin: false,
content_css: false,
noneditable_class: 'fe-formula',
extended_valid_elements:
'span[class|data-latex|contenteditable|style],img[class|data-latex|src|alt|width|height|style]',
})
默认的 iframe 模式里,编辑器正文在另一个 document 里,Shadow DOM 的公式渲染、弹窗定位、选区书签全都要跨文档处理。inline: true 让正文就在主 document 里,这些问题一次性消失。
extended_valid_elements 不能省:TinyMCE 会按白名单清洗 DOM,data-latex 不在默认白名单里,不清洗的话第一次保存就丢了。
2. 书签要用 getBookmark(1),不是 getBookmark(2, true)
TinyMCE 5 时代流传的写法是 getBookmark(2, true)。用在这个场景里会出事:它会把「紧挨着不可编辑公式的光标」还原成「选中整个公式」。结果是第二次插入公式时,把上一个公式覆盖掉了。
正确做法:
currentEditor.focus()
if (!target) currentEditor.selection.collapse(false) // 新增:光标折到末尾
const bookmark = currentEditor.selection.getBookmark(1)
弹窗开着的这几秒里,用户可能在弹窗里点来点去,编辑器的选区早就没了。所以插入前必须先 moveToBookmark(bookmark) 把位置找回来。
3. 插入必须走 undoManager.transact()
currentEditor.undoManager.transact(() => {
if (target?.isConnected) currentEditor.selection.select(target) // 编辑:替换旧节点
currentEditor.insertContent(html)
})
currentEditor.dispatch('input')
currentEditor.dispatch('change')
包在 transact() 里,一次公式插入才是撤销栈里的一个原子操作。不包的话,selection.select 和 insertContent 会变成两步,用户按一次 Ctrl+Z 只退一半——公式没了,但原来那个被替换的公式也回不来。
dispatch('input') / dispatch('change') 是给外层表单用的。TinyMCE 通过 API 改内容不会自动触发这两个事件,React Hook Form / v-model 都收不到更新。
4. 双击编辑:从 data-latex 回填
instance.on('dblclick', event => {
const node = (event.target as Element).closest<HTMLElement>(
'span.fe-formula[data-latex], img.fe-formula-image[data-latex]',
)
if (node && instance.getBody().contains(node)) {
event.preventDefault()
editFormula(node)
}
})
注意两点:
- 用
closest()而不是判断event.target本身。用户双击的位置大概率落在公式 SVG 的某个<path>上,不是 span。 instance.getBody().contains(node)兜底,防止浮层里的元素被误判。
回填时有个细节:双击编辑要保留原公式的形式。原来插的是图片,改完还是图片;原来是 LaTeX 节点,改完还是 LaTeX 节点。所以 outputType 由节点类型决定,不能用页面上的全局开关:
outputType: target ? (target.tagName === 'IMG' ? 'img' : 'latex') : outputType.value
五、返回形式:LaTeX 还是 PNG?
SDK 支持两种返回,怎么选取决于下游环境:
outputType: 'latex' | outputType: 'img' | |
|---|---|---|
| 返回 | 标准 span,Shadow DOM 里渲染 SVG | 完整 <img> HTML,PNG data URL |
| 体积 | 几十字节 | 一张几十 KB |
| 放大 | 矢量,无损 | 位图,会糊 |
| 依赖 | 展示端要装 SDK 并调 initFormulaRendering() | 任何系统都能显示 |
| 二次编辑 | 直接读 data-latex | 读 data-latex(图片上也带着) |
选 img 的典型场景是下游不可控:题库要同步到老后台、要发到第三方系统、要进微信富文本。那些地方不认你的 SDK,一张 PNG 最稳。
关键设计是图片也带 data-latex:
<img class="fe-formula-image" data-latex="\frac{1}{2}" src="data:image/png;base64,...">
所以「图片模式插入的公式」双击依然能进编辑器改,不是死图。
⚠️ 一个只在 TinyMCE 5 上出现的坑:它会自动把 data: 图片转成 blob URL,刷新后地址失效。要排除:
images_dataimg_filter: image => !image.classList.contains('fe-formula-image')
业务侧如果有「监听 img 自动上传」的逻辑,也要跳过 img.fe-formula-image。
六、正文里的 $x^2$:自动渲染,但不脏数据
实际内容里大量是接口直接返回的字符串,题干中混着 $...$。不可能让每个页面在 v-html 之前自己跑一遍替换。
做法是应用启动时开一个全局开关:
import { initFormulaRendering, vLatex } from 'formula-editor-modal'
app.directive('latex', vLatex)
void initFormulaRendering({ renderLatexText: true }).catch(console.error)
开了之后,用 MutationObserver 自动处理:当前页面、异步接口回来的内容、列表翻页、弹窗、Vue 的文本更新,全都不用改。
几条边界处理得比较细:
- 跳过输入框、可编辑区、TinyMCE 正文、代码块、已有的公式节点和图片——否则会把用户正在写的东西改掉
- 只改展示 DOM,不改接口对象、不改表单值。保存的永远是原始数据
- 不解 HTML 属性,只处理文本节点
\$不当公式定界符- 一条公式必须落在同一个文本节点里(
<span>把公式拆开就不认) - 某个区域想保持源码,加
data-fe-latex-ignore
想只渲染某个区域,就别开全局,挂指令就行:
<div v-latex v-html="question.title"></div>
<section v-latex><QuestionDetail /></section>
错误是逐个公式隔离的:MathJax 抛错时,那一条保留原始 LaTeX(不显示成乱码、也不改写源码),同批后面的公式继续渲染。相同节点、相同源码的失败不会重试;改过 data-latex 才重试。这条很重要——一个畸形公式不应该让整页公式全灭。
七、踩坑清单
挑几个当时查了很久、症状又反直觉的。
1. Mathfield not mounted——表现是「编辑区整个不出现」
const mf = new MathfieldElement()
host.appendChild(mf) // 必须先挂载
mf.value = 'x^2' // 再赋值
反过来直接抛 Mathfield not mounted。这个错误发生在 onMounted 的 try 里,如果外面套了 catch,用户看到的现象是编辑区压根没出现,而不是报错弹窗。查了半天才定位。
2. 关闭函数别叫 close
function close() { emit('update:visible', false) } // ❌ 静默失效
浏览器全局有 window.close(),裸的 close() 会被解析成它。不报错、什么也不发生,TypeScript 也不报(window.close 是合法的全局函数)。后果是「取消 / Esc / 点遮罩 / 点 ✕」四个入口一起失灵。
改名 requestClose() 就好了。同理别用 name、status、top、length、origin 这些 window 上已有的名字做顶层函数。
3. MathLive 自带的 ☰ / ⌨ 角标,外部 CSS 藏不掉
.ML__toggles 在 MathLive 的 shadow root 里,::v-deep 也穿不过去;:host 上暴露的 CSS 变量里也没有控制它们显隐的开关(mf.menuItems = [] 只能让菜单没条目,按钮还在)。
好在它用的是 attachShadow({ mode: 'open' }),可以直接注入:
mf.shadowRoot?.appendChild(
Object.assign(document.createElement('style'), {
textContent: '.ML__toggles{display:none !important}',
}),
)
软键盘能力不受影响——自己放按钮调 window.mathVirtualKeyboard.show()/.hide()。注意它的 --keyboard-zindex 默认 10000,和弹窗遮罩同层会被压在下面:
math-virtual-keyboard { --keyboard-zindex: 10001; }
4. 卡片网格:grid-auto-rows: auto + overflow: hidden 会把内容压没
公式模板库是网格卡片。行高用默认 auto,卡片带 overflow: hidden,结果整排卡片被压扁,里面的公式被裁掉。
根因很隐蔽:卡片有 overflow: hidden 时,它的 min-content 高度会被算成 0,而 auto 行高要靠内容高度算——于是行被压扁(实测 99px 压成 35px)。
症状全是反的,特别容易查错方向:网格 scrollHeight === clientHeight,看起来「刚好放得下、不需要滚动」,其实卡片里是空的。
修法就是给死值:
.cards { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr));
grid-auto-rows: 100px; overflow-y: auto; align-content: start; }
.card-body { height: 56px; } /* 公式区固定高,超出由缩略图等比缩小 */
5. 小尺寸预览要用 textstyle
模板卡片里渲染公式别写 mode="displaystyle":它会把 \sum、\lim 的上下限排到符号上下方,几十像素高的卡片放不下就被裁。默认的 textstyle 把上下限排在右侧,小尺寸反而清楚。
6. math-span 的 textContent 是 LaTeX 源码
MathLive 的静态元素把源码留在 light DOM 的文本节点里(视觉上被 slot 藏住了)。所以:
button.textContent // → "\begin{matrix}\square&...矩阵"
这会直接进按钮的可访问名,读屏软件念一串 LaTeX。必须显式给 aria-label:
<button :aria-label="tab.label">
<math-span aria-hidden="true">{{ tab.icon }}</math-span>{{ tab.label }}
</button>
顺带的教训:用 textContent 断言 UI 文案会误报,要断言就用 getAttribute('aria-label')。
7. 缩略图量尺寸要用 offsetWidth
卡片里的公式超出要等比缩小。量尺寸必须用 offsetWidth/offsetHeight(布局尺寸),不能用 getBoundingClientRect()(返回缩放后的尺寸)——用后者会「缩一次 → 量到更小 → 再缩」,越缩越小。
而且不能只量一次:MathLive 渲染完、KaTeX 字体到位都会改布局尺寸。
new ResizeObserver(fit).observe(el)
el.addEventListener('render', fit) // 静态元素渲染完成时派发(不冒泡)
八、体积:800 KB 的引擎不该进主包
MathLive 打完 800 KB,MathJax 1652 KB。两个都静态 import,首屏就废了。
这个项目 build 完的实际产物(Vite 默认分包):
| chunk | 原始 | gzip | 什么时候加载 |
|---|---|---|---|
| 主包(Vue + TinyMCE + 业务) | 1095 KB | 384 KB | 首屏 |
| MathJax | 1652 KB | 595 KB | 页面里出现公式时 |
| MathLive | 800 KB | 221 KB | 第一次打开弹窗 |
| KaTeX 字体 | 341 KB | 256 KB | 第一次打开弹窗 |
| CSS | 98 KB | 18 KB | 首屏 |
主包大是 TinyMCE 的锅,不是公式的锅。真正要守住的是最后两行:只读页面(题干展示、试卷预览、答案解析)压根不加载 MathLive 和那套字体——它们只用于编辑,不该为看的人付钱。
引擎本身做两件事:
let modulePromise: Promise<typeof import('mathlive')> | null = null
export function loadMathLive() {
modulePromise ??= import('mathlive')
.then((mod) => {
const MF = mod.MathfieldElement
MF.fontsDirectory = null // 断掉 CDN 字体,用本地 fonts.css 声明的
MF.soundsDirectory = null // 默认按键音效也走 CDN,直接关
MF.locale = 'zh-cn' // 语言包内置在 bundle 里,不发请求
return mod
})
.catch((err) => { modulePromise = null; throw err }) // 失败可重试
return modulePromise
}
缓存 Promise,多个组件共用同一次加载;失败时清空,允许重试。三个 null 是把 MathLive 默认的外链全部掐掉。
再有就是告诉 Vue 编译器这些不是组件,否则会刷一片警告:
vue({ template: { compilerOptions: { isCustomElement: (tag) => tag.startsWith('math-') } } })
配完之后页面零外部请求:字体本地、语言包内置、音效关掉,内网和离线环境都能跑。TinyMCE 也是同样的处理——npm 包本地加载,skin: false + content_css: false + 手动引 skin CSS,不碰 Tiny Cloud 的 API Key,也不依赖 CDN。
九、能力边界(说清楚,别让人踩坑)
-
Word/PDF 导出对 Shadow DOM 的支持要单独验证。浏览器端 SVG 在 shadow root 里,
innerHTML、cloneNode都拿不到。SDK 提供了 Node 入口,在服务端重新渲染:import { renderLatexSvg, splitLatexText } from 'formula-editor-modal/server' const html = renderLatexSvg('\\frac{1}{2}') // 自包含的 MathJax SVG const parts = splitLatexText('题干 $x^2$') // 拆正文和公式库里的数据格式不用改。
-
图片模式会增加存储体积。一张 PNG 几十 KB,接口有长度限制的话得业务侧另外上传。
-
源码丢了就救不回来。MathJax 渲染失败时节点会保留原始 LaTeX 文本,但如果哪天被人为清空了
data-latex,那条公式只能重打。 -
自动渲染只改展示 DOM。别把渲染后的 DOM 当保存内容提交,要存原始数据或走序列化流程。
序列化有两个方向,对应两种导出:
// 保留公式节点(HTML 导出 / 存库)
const html = serializeFormulaContent(editor.getContent())
// 公式节点转 $...$(LaTeX 导出)
const latex = serializeFormulaLatex(editor.getContent())
// <p>求 $x^2$ 的值</p>
十、做出来的东西长什么样
弹窗照 Mathpix 的形态做的:宽高随视口自适应(1440 视口下实测 980×580),圆角 12px,遮罩 50% 黑,关闭按钮故意探出面板右上角 14px,主色 #2563eb。
结构自上而下:头部(标题 + 学科分段)→ 分类标签 → 内容区(公式卡片 / 符号面板)→ 大编辑区 → 底部操作条(取消 / 清空 / 确认插入)。

符号库对齐了 Mathpix 的逐 tab 数量,一共 476 个:
| tab | 数量 | tab | 数量 |
|---|---|---|---|
| 常用 | 35 | 矩阵 | 23 |
| 运算 | 46 | 上下标 | 46 |
| 关系 | 44 | 大型运算 | 41 |
| 箭头 | 66 | 函数 | 25 |
| 希腊 | 132 | 双曲三角函数 | 18 |
「希腊」是 132 而不是 24:54 个希腊字母(24 大写 + 24 小写 + 6 个变体 ϵ ϑ ϖ ϱ ς ϕ)+ \mathbb{A}–\mathbb{Z} + \mathcal{A}–\mathcal{Z} + 26 个斜体 a–z。
对齐数量这件事,比「我有的都能渲染」有意义得多。后者是自证,前者才能发现「该有的缺了」。 顺手把整批 LaTeX 的合法性也校验了——MathLive 渲染失败会派发 render-error 事件,它不冒泡,所以在文档最开始用捕获阶段挂监听,把每个 tab 点一遍,errors 为空就是全合法:
document.addEventListener('render-error', (e) => errors.push(e.target.textContent), true)
另外两个细节:
- 分类图标直接用 Unicode 数学符号(
∑∫∮⇌▦α),比引图标字体省事,字体回退也稳。避开 emoji 区的(⚛⚗在部分环境渲染成方框)。 - 矩阵 / 分段函数 / 多行对齐共用一个 5×5 行列选择器,插入的就是可填写的格子,不是死模板。
十一、这套东西适合谁
如果你的场景符合下面任意一条,这套方案基本能直接搬:
- 富文本里要写公式,且公式要能被再次编辑。题库、教案、试卷、实验报告、在线作业,都算。
- 公式要能离开你的系统。导出 Word、生成 PDF、同步到第三方平台——存 LaTeX 才能在各处重新渲染,存图只能糊。
- 下游环境不可控。这时候用
outputType: 'img',一张自带data-latex的 PNG,哪都能显示,回来还能改。 - 需要离线或内网部署。零外部请求,字体和语言包都在包里。
- 公式量大、要能搜。存源码而不是存图,
LIKE '%\frac%'都能搜。
反过来,如果只是「展示几道题、永远不用编辑」,别上编辑器,直接用 MathJax 渲染 $...$ 就够,省下 800 KB。
最后
回头看,这个需求真正的难度不在「公式怎么渲染」——那部分交给 MathLive 和 MathJax 就行。难的是三件不显眼的事:
- 一致性:录入时看到的,必须和正文里、导出时看到的一模一样。所以预览也要用 MathJax,哪怕它更严格。
- 往返无损:任何一次编辑都不能悄悄改掉用户的源码。选型时这一条比功能多寡更重要。
- 光标:富文本里插入一个原子节点,撤销栈、书签、选区还原,每一个都能单独出 bug。
如果你也在做类似的东西,希望这几条坑能帮你省两天。
在线体验:liliyang857.github.io/math-latex-…