资讯详情

资讯详情

前端公式编辑器架构解析:从LaTeX解析到AST渲染的工程实践

简介面向需要在网页中创建与编辑数学公式的开发者这份基于 JavaScript 与 HTML5 的公式编辑器可用作教学演示、在线答题或科研文档工具支持公式输入、函数编辑与图形绘制。压缩包为 rar 格式共 2 个文件包含 1 个 HTML 页面和 1 个 JS 脚本前者提供交互界面与页面结构后者承担事件监听、公式解析、渲染及绘图逻辑整体仅 9KB轻量易读、便于二次开发。目前已有 1311 人学习适合前端初学者理解 DOM 操作及公式渲染原理也适合需要快速集成公式功能的开发者直接参考。资源围绕 LaTeX/MathML 解析、Canvas/SVG 绘图、事件处理与性能优化等关键点展开可帮助读者掌握从界面搭建到公式实时预览的完整实现路径。1. 当我没把公式编辑器当“输入框”来做问题反而解决了做在线作业系统的时候最头疼的不是题目数据模型而是用户怎么在网页里把\frac{a}{b}这种公式自然输进去。直接套现成的开源编辑器短时间能出效果可一旦要跟业务里的“题目知识点”绑定要自定义符号面板要控制导出格式组件就成了黑匣子。后来我决定自己维护一套基于 JavaScript 的公式编辑器方案把“输入、解析、渲染、导出”整条链路握在手里。这篇文章就拆一下这套方案的架构、实现细节和踩过的坑适合要把公式编辑能力嵌入自己系统的前端开发者和独立开发者。2. 从输入到渲染公式编辑器为什么要分四层而不是做一个大输入框公式编辑器不是“文本框加几个符号按钮”而是一个“结构化输入系统”。用户看到的是富文本底层却是可编程的LaTeX字符串中间经过解析器转成抽象语法树AST渲染器再把AST变成可交互的DOM。我这套方案里每一层都能单独测试也方便在业务里替换掉某一块。2.1 三种输入模式为什么不能只做工具栏所有公式输入最终都能归成三类动作点按符号面板、敲键盘快捷键、直接粘贴LaTeX源码。工具栏适合不熟悉LaTeX的用户快捷键适合高频录入的操作者直接输入LaTeX则是兜底方案尤其在从题库导入题目时老数据里有很多$\frac{1}{2}$这类字符串。只做工具栏意味着用户没法从题库里复制公式进来只做LaTeX输入则会把一大半不熟悉语法的用户挡在门外。我把这三种输入统一接进同一个“插入点控制器”。设计上很简单维护一个insertPosition对象它由nodePath从根节点到当前节点经过的分支索引和charIndex在当前节点文本中的偏移量组成。无论用户是点了\pi按钮还是按了CtrlShiftF最终都调用同一个insertLatex(latexStr)方法。// input/adapter.js export function insertLatex(latexStr) { const currentBranch getBranchByPath(editorState.cursor.nodePath); currentBranch.splice(editorState.cursor.charIndex, 0, latexStr); editorState.cursor.charIndex latexStr.length; notifyEditorChanged(); }这段代码背后有一个原则插入的“最小粒度”是LaTeX字符串而不是HTML片段。因为LaTeX字符串可以序列化、可比较、可存数据库如果直接插入HTML后面的解析器就得去猜这个HTML对应的公式结构很容易出错。参数说明里nodePath是数组比如[2, 1, 0]表示“根节点第2个子节点的第1个子节点的第0个子节点”charIndex用来处理“光标刚好落在\frac后面但还没输入分子”的中间态。三种输入模式的定位差异很大工具栏和快捷键本质是在“生成LaTeX字符串”而直接粘贴是“消费已有LaTeX字符串”。因此工具栏按钮插入的字符串和键盘输入造成的文本节点在AST里必须能够无缝拼接。设计上我让两者都走insertLatex唯一区别是快捷键支持cursorOffset能让光标自动跳到插入串中间的空槽而直接粘贴则是整段光标移到末尾。2.2 选区与光标模型为什么不能用普通contenteditable最初我倒也想省事直接用contenteditable让“公式区”可编辑。结果一输入\sqrt{x1}浏览器的光标就乱跑点根号外框光标却跑进 x 里面点分数线光标跑到父节点后边。原因很简单普通编辑区只认识扁平文本不认识“分数有分子和分母两个槽位”“根号有被开方式一个槽位”这种树形结构。要让光标按公式语义走就必须自己管理光标。我最后采用的模型是“AST 虚拟光标”。AST节点分成四类叶节点文本、命令、单子节点sqrt、sum、双子节点frac、容器节点括号、矩阵。每个节点上维护一个children数组叶子节点额外维护text。渲染时每个节点对应一段DOM点击某个子节点时渲染器通过DOM上的>// model/astNode.js export function createNode(type, props {}) { return { type, // frac | sqrt | text | ... children: [], // 子节点数组 text: props.text ?? , attrs: props.attrs ?? {}, // 额外参数比如括号类型 }; }这里最关键的一点光标不是存{x: 42, y: 18}坐标而是存“树路径”。如果只存坐标一旦公式换行或字号缩放光标就会漂移存路径则无论渲染成什么样都能定位到语义位置。每次渲染结束后渲染器会调用restoreCursorByPath()把虚拟光标还原到对应DOM节点上。这个模型对后面实现“点击分母编辑”“用方向键在分子分母间移动”都提供了基础。顺带说一下为什么不让用户直接编辑“渲染后的公式”而是永远只编辑“源LaTeX”。因为渲染层是单向的从AST到DOM如果要反过来把DOM里的span stylevertical-align: sub2/span转回_2需要写一套很难维护的逆向规则。LaTeX作为唯一数据源渲染永远只是它的投影。实际上“虚拟光标”这套东西一开始也很容易做成“退格删除整个节点”的粗糙版本。真正的光标模型还需要记录“锚点和焦点”两个Path用户拖选时选中范围可能跨越多个子节点。我用的方案是selection { anchorPath, focusPath, anchorOffset, focusOffset }然后推导出选区覆盖的AST节点集合。后续按AltF插入\frac时如果存在选区就把选区内容作为分子而不是简单在光标处插入空分数。2.3 四个核心模块的边界划分架构上我拆了四个模块输入适配器input adapter、解析器parser、AST运行时model、渲染器renderer。输入适配器负责把用户动作变成insertLatex调用解析器把LaTeX字符串转成ASTAST运行时管理光标、校验合法性渲染器负责把AST绘制成可交互DOM。四个模块通过事件总线通信事件只有四类contentChanged、cursorChanged、renderRequested、exportRequested。这样做的好处是解析器和渲染器不依赖浏览器API可以跑在Node环境里做单元测试。比如题库接口返回一批LaTeX我可以直接用同一套解析器校验字段是否合法不需要先打开页面。某次排查线上问题就是靠这个隔离把“渲染层样式问题”和“解析层语法问题”区分开的。// runtime/eventBus.js const listeners new Map(); export function on(event, fn) { listeners.set(event, fn); } export function dispatch(event, payload) { listeners.get(event)?.(payload); }事件总线只有一收一发两个函数没有做复杂的中间件。每个模块在初始化时注册自己关心的事件输入适配器注册contentChanged渲染器注册renderRequested。如果要用到多人协同编辑在这个事件总线上加一个remoteChange事件即可AST序列化后发出去另一侧重新解析。下面这几个模块的依赖方向是固定的输入适配器 → AST运行时 → 渲染器解析器 → AST运行时。如果有人想扩展比如给公式添加化学式符号只需要在AST运行时增加节点类型并在解析器注册对应命令渲染器增加一个模板分支即可。协议上不允许渲染器直接反写AST这样保证数据流永远是单向的排查问题的时候不用考虑“渲染层改坏了AST”这种玄学。注意如果你打算把公式编辑器嵌入一个已有后台系统最值得复用的就是AST运行时这个模块。它不依赖DOM意味着你可以直接在Node层写题目导入、批改、查重逻辑。3. 符号面板实战数据驱动渲染而不是写死300个按钮公式编辑器的工作量大头不在“公式计算”而在符号面板和交互细节。如果直接写button onclickinsert(\\alpha)α/button来硬编码所有符号后期维护会非常痛苦。我用的办法是把符号全部定义成数据用一份数组驱动按钮渲染。3.1 符号库的数据结构设计符号库按数学类别分了basic、operation、relation、greek、matrix五个分组。每一项都是一个对象核心字段包括latex、label、tooltip、category、insertMode。insertMode区分三种插入行为direct直接把latex插入光标处比如、\times。inline插入后自动包一层{ }比如\sqrt插入成\sqrt{ }光标落在花括号里。wrap先在选区两边包命令比如选中ab后给它加\frac{}{}原来的内容进入分子。// symbols/symbolGroups.js export const symbolGroups [ { key: greek, label: 希腊字母, items: [ { latex: \\alpha, label: α, tooltip: alpha, insertMode: direct }, { latex: \\beta, label: β, tooltip: beta, insertMode: direct }, { latex: \\frac, label: 分数, tooltip: 插入分数, insertMode: inline } ] } ];把分数放到希腊分组里只是为了示例实际分组会分开。inline模式的实现很关键插入\frac{ }后编辑器不能简单地把光标跳到最后而要跳进第一个空的花括号里。我一般会解析插入后的字符串找到第一个{ }的内部偏移再把虚拟光标设到那里。为了让符号面板可配置我还给每个分组加了visible开关业务方可以只显示自己需要的几组。比如在初中数学场景里隐藏矩阵在高等数学场景里保留。这个开关不是运行时动态切换而是初始化时传入配置避免符号库在用户使用过程中突然扩大导致按钮排序变化。3.2 用事件委托绑定按钮而不是给每个按钮单独绑事件300 个符号按钮如果每个都addEventListener性能上问题不大但内存占用和清理麻烦。更麻烦的是很多 UI 框架重渲染时会替换节点导致事件丢失。我在这套方案里用事件委托在符号面板容器上只绑一个click监听函数通过按钮上的>// components/symbolPanel.js export function initSymbolPanel(container, dispatch) { container.addEventListener(click, (e) { const btn e.target.closest([data-symbol-id]); if (!btn) return; const symbolId btn.getAttribute(data-symbol-id); const symbol symbolIndex.get(symbolId); dispatch({ type: INSERT_SYMBOL, payload: symbol }); }); }dispatch是一个统一的事件入口最终会调用输入适配器的insertLatex。用closest是为了允许按钮内部有子元素比如希腊字母按钮里放了一个i标签。如果不用closest点击子元素时e.target就不是按钮本身事件就找不到符号对象。这是开发中很容易踩的小坑。按钮本身是用JavaScript生成的遍历symbolGroups把每一项渲染成button>// runtime/preview.js let timer null; export function schedulePreview(ast) { clearTimeout(timer); timer setTimeout(() { const changedNode latestChangedNode.current; if (changedNode) { renderer.updateNode(changedNode); // 局部重绘 } else { renderer.render(ast); // 首次全量渲染 } }, 80); }updateNode是渲染器提供的方法只重建受影响的DOM片段。比如用户正在修改\frac的分子那只需要重绘分子那个span分母和外面的分数横线不动。这样既保证视觉同步又控制了重绘范围。schedulePreview本身的防抖时间不是越长越好80ms 是视觉上“跟手”和“不卡”的平衡点如果逻辑复杂可以放宽到 120ms超过 200ms 用户就会觉得有延迟。实际操作中“脏节点”的标记不是靠对比DOM而是靠对比AST每次insertLatex时记录插入的节点路径schedulePreview只更新该路径对应的DOM。如果用户连续输入多个字符latestChangedNode始终指向父级文本节点所以更新的是整个文本节点不会因为输入速度过快而出现遗漏。3.4 快捷键映射与冲突处理编辑器快捷键不能用CtrlB这类会被浏览器拦截的全局键也要避免和业务系统的粘贴、保存冲突。我维护了一张快捷键表修饰键一律优先用Alt因为页面里Alt 字母很少被浏览器占用。// input/shortcuts.js const SHORTCUTS { AltF: { insert: \\frac{ }{ }, cursorOffset: 5 }, AltR: { insert: \\sqrt, cursorOffset: 5 }, AltA: { insert: \\alpha, cursorOffset: 0 } }; document.addEventListener(keydown, (e) { if (e.altKey !e.ctrlKey !e.metaKey) { const hit SHORTCUTS[e.key.toUpperCase()]; if (hit) { e.preventDefault(); insertLatex(hit.insert, hit.cursorOffset); } } });这里的cursorOffset等于“光标应停在第几个字符后面”。比如AltF插入\frac{ }{ }后光标应该停在第一个{后面也就是字符串索引5的位置。之前有人问为什么不用“把下一个空花括号作为选区”这种方式因为cursorOffset是纯数字索引更容易在撤销栈里记录重做时能精确回到同一个位置。键盘事件处理时要特别注意e.key在用户输入大写字母时可能变成F所以必须toUpperCase()。另外如果编辑器不处于聚焦状态快捷键不应该生效。判断方式是检查document.activeElement是否在公式编辑区内部否则用户在别的输入框里按AltF也会触发插入。4. 自研LaTeX解析器把公式字符串变成可编辑的AST前面说了所有公式操作都围绕AST。这一章写解析器的实现我会用一个最小化版本展示从字符串到AST的拆解过程。之所以不直接引入现成解析库是因为业务要求能识别题库里的方言LaTeX比如有些数据里\left(和\right)是分开写的有些地方是\displaystyle\sum标准的解析器遇到这些会直接报错。自己写可以容错把识别不了的命令降级为文本。4.1 词法分析切分命令、分组与上下标词法分析的任务是把\frac{a}{b}拆成一个token数组。规则很简单反斜杠开头的英文单词是一个命令token数字、字母、普通符号是文本token{、}、^、_、$单独成token。我实现时最常踩的坑是“命令名之后如果跟一个普通字母不能自动吞掉”。比如\sin2x词法应当拆成\sin、2、x而不是把2x当成命令的一部分。// parser/lexer.js export function tokenize(latex) { const tokens []; let i 0; while (i latex.length) { const ch latex[i]; if (ch \\) { let j i 1; while (j latex.length /[a-zA-Z]/.test(latex[j])) j; tokens.push({ type: command, value: latex.slice(i 1, j) }); i j; } else if (ch { || ch } || ch ^ || ch _) { tokens.push({ type: ch, value: ch }); i; } else { let j i; while (j latex.length !\\{}^_$.includes(latex[j])) j; tokens.push({ type: text, value: latex.slice(i, j) }); i j; } } return tokens; }这段代码有四个分支覆盖了最常见的LaTeX结构。注意文本分支里我把$也作为分隔符这样可以直接处理$...$包裹的公式片段题库导入时不必先做一层脱壳。token类型里没有单独的“空格”类型因为LaTeX中空格本身没有数学含义词法层丢弃它们反而利于后期渲染。词法层还有一个容易忽略的点命令名的判定只允许[a-zA-Z]因此\aleph这种命令会被识别为aleph一个token没问题但\ne后面的字母也属于命令名的一部分会被当成ne。如果业务里需要支持\neq和\ne两种写法最好在词法层把它们归一化成一个命令缩写否则渲染层要做两次匹配。4.2 递归下降解析处理\frac和上下标有了token解析器按优先级递归下降。我的解析器优先级分成三层层最低的是“并排排列”相当于同一行内的多个 text中间层是上下标^和_最高层是命令节点比如\frac会立即消耗后面两个“块”。// parser/parser.js export function parse(tokens) { let pos 0; function parseBlock() { if (tokens[pos]?.type {) { pos; const items []; while (tokens[pos]?.type ! }) items.push(parseExpr()); pos; // 跳过 } return { type: group, children: items }; } return parseExpr(); } function parseExpr() { const node parseAtom(); while (tokens[pos]?.type ^ || tokens[pos]?.type _) { const script tokens[pos].type; pos; const child parseBlock(); node.children.push({ type: script ^ ? sup : sub, children: [child] }); } return node; } function parseAtom() { const t tokens[pos]; if (!t) return { type: text, text: }; if (t.type command t.value frac) { const numerator parseBlock(); const denominator parseBlock(); return { type: frac, children: [numerator, denominator] }; } if (t.type command) return { type: command, value: t.value }; return { type: text, text: t.value }; } return { type: root, children: [parseExpr()] }; }这里的parseBlock专门处理{...}分组因为\frac的两个参数必须是块。如果不写parseBlock遇到\frac12这种省略花括号的写法解析器会把1当分子、2当分母但遇到多字符分母就出错。很多LaTeX方言允许不写花括号但为了保证AST稳定我只识别\frac{a}{b}的标准形式遇到\frac12就直接把\frac当普通命令渲染宁可不解析也不生成错误结构。解析器默认开启“宽松模式”。遇到未知命令\xyz我不会直接抛错而是生成{ type:command, value:xyz }节点渲染时原样输出\xyz。这看起来像把问题掩盖了但实际处理题库数据时非常必要10年前的题目里经常混着\notin、\ne这类标准命令变体宽松模式至少让页面不白屏。真正的校验放在导出阶段而不是输入阶段。4.3 渲染器AST到HTML/CSS的映射AST解析出来后渲染阶段反而简单。每个节点类型对应一个HTML模板我只用三个核心CSS规则文本节点用span分数用体系化嵌套的display:inline-block上下标用vertical-align。难点在分数和根号的横线对齐。// renderer/render.js export function renderNode(node) { switch (node.type) { case text: return span classformula-text${escapeHtml(node.text)}/span; case frac: { const num node.children[0].children.map(renderNode).join(); const den node.children[1].children.map(renderNode).join(); return span classfrac-box span classfrac-num${num}/span span classfrac-line/span span classfrac-den${den}/span /span; } case sup: return span classsup${node.children.map(renderNode).join()}/span; case sub: return span classsub${node.children.map(renderNode).join()}/span; default: return span${node.value ?? }/span; } }渲染层的“参数说明”主要体现在CSS类名设计上。.frac-box必须是inline-block这样\frac{a}{b}c里的加号才能保持在分数右侧同一基线。.frac-num和.frac-den用display:flex; justify-content:center让分子分母水平居中。.frac-line用绝对定位覆盖在中间。最容易出问题的不是分数而是sup/sub很多浏览器缩放级别下vertical-align: super和sub会让上下标基线偏移量不一致所以我改用vertical-align: baseline; position: relative; top: 0.6em;以及负数top: -0.4em做微调。渲染器性能方面我做了两个优化一是用Map缓存“节点类型 当前宽度”对应的渲染模板减少重复字符串拼接二是对“纯文本节点”不做DOM重建只更新textContent。在一个包含500个公式的长文档里切换编辑器视图时全量渲染时间能控制在10ms以内。另外渲染器的输出不是为了打印而是为了继续编辑。因此每个可编辑文本节点都要保留>// export/validate.js export function verifyLatex(latex) { try { const tokens tokenize(latex); const ast1 parse(tokens); const ast2 parse(tokenize(serialize(ast1))); if (JSON.stringify(ast1) ! JSON.stringify(ast2)) return false; return true; } catch (e) { return false; } }这里有个隐藏细节serialize不是简单的把AST文本合并而是要按节点类型补全花括号。比如frac节点序列化时必须输出\frac{ numerator }{ denominator }不能输出\frac{a}{b}之外的形式。校验的意义在于序列化过程中如果丢失了分组信息二次解析出来的AST和原AST不同导出的公式在别的系统里大概率会错位。6.2 把导出函数做成黑盒方便业务方调用我把编辑器对外只暴露三个方法insertLatex、getLatex、setLatex。业务方不需要知道内部AST是什么只管拿到字符串。getLatex内部会先做校验并触发上面的verifyLatex如果失败就返回上一次合法的值同时开启一个错误提示态。这样即使后续有新的非法公式进入也不会带坏下游数据。从那以后我每次做带“输入框”的复杂组件都会强制走一遍“输入→解析→渲染→导出”的闭环测试把各种极端的空值、括号嵌套、未知命令都放进回归清单。这个习惯帮我省了无数半夜被叫起来修Bug的时间。希望帮到你。本文还有配套的精品资源点击获取
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →