ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

KaTeX高效渲染数学公式:性能优化与工程实践指南

KaTeX高效渲染数学公式:性能优化与工程实践指南

我这两年有好几个项目都被数学公式渲染卡过脖子——文档站点、在线题库、课程笔记系统,全都要跟公式打交道。最早图省事直接上 MathJax,结果首屏加载时间稳定多出两秒以上,页面一多连编辑器的输入都开始发飘。后来换成 KaTeX,同样的文档站,脚本体积直接降了一个数量级,滚动和输入的手感完全不一样了。这篇文章就从头到尾聊聊我实际用 KaTeX 渲染数学公式的完整经历,包括为什么选它、各种接入姿势、服务端渲染玩法、以及我踩过的那些坑。

如果你正在做需要展示数学公式的网页,或者被公式渲染的性能问题折腾过,这篇文章应该能帮你少走不少弯路。从基础接入到高阶优化我都会讲,确保不管是新手还是已经踩过坑的人,都能找到自己需要的部分。

1. 为什么最终选择了 KaTeX 而不是 MathJax

1.1 一次页面性能优化的真实对比

先说我自己做的一个模拟项目 X,是一个在线习题系统,题库里有大量包含数学公式的题目。第一版是后端直接渲染 HTML 片段,前端引了 MathJax 来做公式渲染。刚开始题目数量少的时候没觉得有什么问题,后来题库涨到几千道题,问题就出来了。

最直观的感受是页面加载。打开一个包含公式的页面,MathJax 需要额外请求一大堆字体和配置资源,首屏时间被明显拉长。而且它的渲染方式是扫描整个 DOM,找到数学标记后逐段处理,页面公式一多就会出现明显的延迟,用户输入答案的时候输入框也跟着卡。

后来换成了 KaTeX,情况改善非常明显。KaTeX 的核心库加 CSS 加起来体量比 MathJax 小很多,而且它不需要额外加载字体文件包(默认使用 Web Font 或者本地系统字体),渲染是同步完成的,公式以 HTML 和 CSS 形式直接输出到页面上——本质上它就是纯字符串处理,不涉及二次扫描和异步排版,所以即使一页里有几十个公式,渲染也是一瞬间完成的。

1.2 性能之外,KaTeX 的实际优势在哪里

很多人光听"更快"可能没概念,我用数据说话。我这边的模拟项目 X 实测过,同样一段包含二十个复杂公式的页面:

  • MathJax 从页面开始解析到所有公式完成渲染,大约需要 2-3 秒(视浏览器而定)。
  • KaTeX 在同样的页面上的渲染基本在几十毫秒到一两百毫秒内完成,差距是数量级的。

除了性能,KaTeX 还有一个对开发者特别友好的点:它是纯同步渲染。MathJax 渲染完成后需要触发 reflow 和 re-layout,如果你需要在公式渲染后精确测量元素尺寸或者做动画,会非常痛苦。KaTeX 输出的是干净的 HTML 和 CSS,渲染完成那一刻 DOM 就是最终形态,后续做任何操作都不必担心布局被二次改变。

KaTeX 的生态也很干净,它支持大多数 LaTeX 数学命令和符号。虽然极少数高级命令不支持(比如某些复杂的排版环境),但对于日常的数学公式、物理公式、统计学公式,覆盖率已经足够了。语法上跟 LaTeX 保持一致,写过 LaTeX 的人基本零学习成本。

2. 基础接入:几种引入 KaTeX 的方式与适用场景

2.1 CDN 直接引入,适合原型和轻量项目

最开始图省事,我用的是 CDN 引入,三分钟就能跑起来:

<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css"> <script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>

这里有几个细节要注意。defer属性一定要加,因为 KaTeX 的 JS 文件需要等待 DOM 解析完成后再执行,加了defer能保证脚本不会阻塞 HTML 解析。另外 CDN 上的 CSS 和 JS 版本号要对应好,版本不一致可能会出现样式错乱,比如公式里的字母显示成异常的字形。

CDN 方式适合的场景是:快速原型、内部工具、小流量页面。它的优点是零配置、不需要构建工具,缺点是首屏依赖外网 CDN 的可用性和稳定性,而且没法做细粒度的按需加载。我自己的生产项目不建议一上来就用裸 CDN,原因后面在性能优化部分会详细说。

2.2 用 npm 包管理,适合需要构建的项目

正规一点的做法是用 npm 安装:

npm install katex

然后在代码里按需引入。以我常用的 Vite 项目为例:

import katex from 'katex'; import 'katex/dist/katex.min.css';

这种方式的好处是 KaTeX 跟你的项目代码一起打包,版本可控、资源本地化,还能利用构建工具的 Tree Shaking(只打包用到的函数)。KaTeX 本身是 CommonJS 模块,在 Vite 里可以正常引入,不过要注意一点:它默认会引用一些 Node.js 内置模块吗?不会,但有些构建配置可能需要处理 CSS 里的字体文件路径。在 Vite 里一般不需要额外配置就能跑起来,但在某些老旧的 Webpack 配置里可能需要处理字体文件的 file-loader 规则。

2.3 服务端渲染(SSR)接入,首屏最快的方式

KaTeX 官方提供了renderToString方法,可以直接在服务端把 LaTeX 字符串渲染成 HTML 字符串,然后随页面一起返回给浏览器。这样浏览器根本不需要等待任何 JS 执行,只要是包含公式的 HTML 就已经是最终形态了。

以 Node.js 环境为例:

const katex = require('katex'); const html = katex.renderToString('c = \\pm\\sqrt{a^2 + b^2}', { throwOnError: false });

我目前在模拟项目 X 的习题详情页里就是这么干的。后端处理习题数据时,把题面里的 LaTeX 公式全部预先渲染成 HTML 字符串,发布后页面直接输出,公式部分零延迟。而且因为是纯字符串输出,甚至连前端框架都不需要参与公式渲染那一层。

2.4 浏览器端自动渲染模式,适合动态内容

如果你的内容是纯前端动态生成的(比如用户输入了一段含公式的文本),就需要用 KaTeX 提供的自动渲染扩展renderMathInElement。这个函数不在核心包里,需要单独从 CDN 引或者 npm 引入:

import 'katex/dist/contrib/auto-render.min.js'; // 或者在浏览器里直接引 CDN 版本

然后调用:

const elements = document.querySelectorAll('.math-content'); document.addEventListener('DOMContentLoaded', function() { renderMathInElement(document.body, { delimiters: [ {left: '$$', right: '$$', display: true}, {left: '\\[', right: '\\]', display: true}, {left: '$', right: '$', display: false}, {left: '\\(', right: '\\)', display: false} ], throwOnError: false }); });

注意delimiters配置的顺序很重要,$$和$这类包含关系要放前面,否则像$x$这种短定界符会被长定界符截断。我吃过这个亏,当时配置里把$放在$$前面,结果所有$$...$$内容都被$...$给提前截断了,公式全部分裂成碎片,排查了半天才找到原因。

其实自动渲染模式的核心原理很简单:它扫描给定的容器内的文本节点,找出匹配的定界符,把内部的 LaTeX 源码用katex.render()替换成渲染后的 HTML。但它有个性能上的坑——如果页面里的文本节点特别多(比如整个 body 有几百个节点),扫描一遍也会有可感知的耗时。所以尽量把renderMathInElement的容器范围缩小,只扫包含公式的那一部分 DOM,别动不动就传document.body。

3. 进阶配置和实用玩法

3.1 自定义宏定义,省掉重复代码

写数学内容多的人都会遇到这种情况:文档里反复出现同一个复杂表达式,比如某个物理符号组合\boldsymbol{F}_{\text{net}}。每次都写完整表达式,代码就特别啰嗦。

KaTeX 支持自定义宏,在调用时传macros参数:

katex.renderToString('\\Fnet', { macros: { "\\Fnet": "\\boldsymbol{F}_{\\text{net}}" } });

我用得比较多的场景是给一系列自定义算子做简写,比如偏微分、期望值、常见向量积之类的。这个功能不只是方便,还能让整个文档结构更清爽——改一个宏定义,所有用到的地方同步更新,不用全局替换。

有一点要注意:宏定义里单参数形式(比如\\dfrac{#1}{#2})KaTeX 是支持的,但多参数宏可能在某些 ESLint 或 TS 类型检查下报错,需要类型断言绕一下。还有,宏定义重复了也没关系,后定义的会覆盖先定义的,这个行为跟 LaTeX 的\renewcommand有点像。

3.2 strict 模式和 throwOnError 的合理配置

KaTeX 默认遇到不认识的命令或者语法错误,会直接 throw 一个异常,并且渲染出醒目的红色错误文本。这在开发阶段是好事,便于发现问题,但到了生产环境就有点粗暴了——用户看到一条红色错误提示,体验很差。

推荐的生产配置:

katex.renderToString(latexSource, { throwOnError: false, strict: false, errorColor: '#cc0000' });

throwOnError: false表示出错时不抛异常,只是把错误信息以普通文本形式输出出来;strict: false是为了容忍一些 KaTeX 能处理但不完全符合 LaTeX 规范的写法。比如某些非标准命令在 LaTeX 里会警告,KaTeX 默认也会警告,但strict: false之后就直接当作普通文本处理,不报警告。

不过我建议开发环境里把throwOnError设成true。调试时让错误彻底暴露出来,等上生产了再关掉。这个习惯帮我抓出过不少前端拼写错误,比如\fraq少写了一个c(正确是\frac),开发环境直接红字显示,一眼就看到。

3.3 在 Markdown 渲染链路里无缝集成 KaTeX

如果你在用 Markdown 写技术文档,公式一般是这样的形式:

行内公式:$f(x) = x^2$ 块级公式: $$ \int_0^\infty e^{-x^2} dx = \frac{\sqrt{\pi}}{2} $$

KaTeX 本身不解析 Markdown,需要在 Markdown 渲染之后做一步公式转换。我用过的方案是:

  1. 先用 Markdown 解析库(比如 marked 或 markdown-it)把 Markdown 转成 HTML。
  2. 对 HTML 字符串做正则匹配,找到$...$和$$...$$块。
  3. 调用katex.renderToString把匹配到的内容替换成渲染后的 HTML。

其实这一步有个现成的轮子,比如 markdown-it-texmath 插件,原理就是这样。我早期自己写正则踩了几个坑,比如公式里的$是用作货币符号的场景,再比如行内公式里本身就包含$(概率论里的条件概率P(A|B)倒是没事,但真的遇到过 LaTeX 里本应转义的\$)。后来我还是老老实实用现成的插件,正则边界太容易出问题了。

如果你用 remark / rehype 这套生态,也有现成的插件可以接。思路都是一样的:Markdown 先转 AST,公式节点单独抽取出来渲染,最后再放回 AST 里。

4. 实操过程中踩过的坑与排查经验

4.1 公式渲染后 CSS 样式被污染

这是我最常遇到的坑——KaTeX 的 CSS 是带作用域类名的,比如.katex和.katex-html。但如果你在全局样式里写了比较宽的 reset 规则,比如对* { margin: 0; padding: 0; }或者对span统一设置了字体、颜色,KaTeX 输出出来的一些布局元素就会被全局样式干扰。

典型症状是公式里的字母间隙变宽、间距错乱、上下标位置偏移。

排查思路是先打开 DevTools 看 Computed Style,看是哪个样式覆盖了 KaTeX 的默认值。KaTeX 的 CSS 优先级设计得还行,但如果你的全局样式用了!important,那就会直接穿透。

我的经验是给 KaTeX 渲染出来的容器加一层隔离:

.math-block { all: initial; }

或者稍微稳妥一点,在引入全局样式时显式排除 KaTeX 类名,比如:

body :not(.katex) { /* 全局样式 */ }

当然这个排除写法也有性能代价,最优解还是规范自己的全局 reset 规则,别写那种杀伤力太大的* {}选择器。至少我在做了 CSS reset 清理后,这类问题基本绝迹了。

4.2 字体加载的 FOUT 闪烁问题

KaTeX 默认会用自己的字体(KaTeX_Main、KaTeX_Math 等),这些字体文件在首次加载时需要从 CDN 请求。如果你的页面在字体加载完成之前就渲染出了公式,那公式会先显示成系统字体,随后突然跳动成 KaTeX 字体,视觉上就是一阵闪烁(FOUT)。

解决思路:

  1. 使用fonts.googleapis.com还是本地化?KaTeX 的字体文件可以跟着 npm 包走,传到自己的 CDN 或静态资源服务器,这样加载速度和稳定性都更好。
  2. 使用display: none兜底?我没这么干,因为闪一下也可以用 CSSfont-display属性控制。

我在生产环境实际采用的做法是,把 KaTeX 的字体用 CSS@font-face定义,font-display: swap保留(这个属性控制字体加载过程中是否显示备用字体),同时给公式容器设置一个最小高度,减少字体加载完成前后的布局位移。

4.3 公式内中文显示异常

这是很多写中文技术文档的人会遇到的问题。KaTeX 默认字体不包含中文字形,如果你在公式里写了中文(比如\text{平均数}),渲染结果往往非常难看,甚至直接显示成方框。

解决办法是给 KaTeX 的字形做 fallback:

.katex { font-family: KaTeX_Main, 'PingFang SC', 'Microsoft YaHei', sans-serif; }

但要注意,这个 fallback 是全局的,会影响所有字母。我建议更精细地控制:只给.katex .katex-mathml或.katex .katex-html里的文字设置 fallback,避免英文数学符号的显示被中文字体干扰。实测下来,配合\text{}命令的时候,中文 fallback 比较可靠,直接写裸中文(不包\text{})则非常容易出现排版错乱,所以我还是要求内容作者把中文统一包在\text{}里面。

4.4 动态内容渲染时的标签闭合问题

KaTeX 输出的 HTML 是嵌套结构,如果你在一个切好的字符串片段上直接调renderToString,有可能因为标签闭合不完整导致页面结构错乱。我就遇到过 v-html 方式绑定数据时,公式 HTML 没被正确转义,导致 Vue 的模板解析报错的情况。

实际场景里我是在某次给某跨平台系统做移动端适配时,接口返回的公式源码里带了未转义的<和>字符,KaTeX 解析时按普通文本处理了,但外层框架把它当 HTML 标签解析了,整个页面的结构直接错乱。排查半天才定位到是待渲染的 LaTeX 字符串里包含不安全的 HTML 标签。

解决办法是:

  1. 在渲染前对输入做 HTML 转义,或者要求内容后台对公式里的特殊字符做转义。
  2. 前端输出时使用textContent的方式(或框架的{{ }}插值)而不是v-html/dangerouslySetInnerHTML直接塞。

但公式本身需要输出 HTML,所以这不是个完全通用的解法。更靠谱的方式是:渲染前先对公式字符串做一个校验,过滤掉明显的 HTML 标签闭合尝试,比如把<script>这类内容直接掐掉。KaTeX 有output: 'html'和output: 'mathml'两个模式,默认情况下它自己会做一定程度的清理,但不能完全依赖它处理所有边界情况。

4.5 异步组件加载时公式未渲染

在 Vue 或 React 项目里,组件是异步加载的,DOM 插入时机跟自动渲染的触发时机对不上,会导致一部分公式没被渲染就永远停留在$...$源码状态。

这个问题的本质是:renderMathInElement扫描的是当前 DOM 里已存在的元素,如果组件是在扫描之后才挂载的,那它里面的公式自然不会被处理。

解决方案也比较直接:在异步组件挂载完成的生命周期钩子里,对组件根元素单独执行一次renderMathInElement。如果组件内部又有二次异步加载的内容,就要在内容加载完成后再次调用。我这边实现上封装了一个简单的工具函数,每次数据到达后触发一次局部渲染,效果稳定。

5. 性能优化:让公式渲染不拖慢页面

5.1 从资源加载层面做减法

公式渲染的性能不只是渲染本身,还包括资源的加载和解析。前面提到 CDN 直接引入最简单,但生产项目里我更推荐把 KaTeX 的资源放到自己的静态资源服务器上,并配合构建工具做版本哈希。

以 Vite 为例:

npm install katex

在入口文件引入:

import katex from 'katex'; import 'katex/dist/katex.min.css';

Vite 打包时会自动把字体文件也拷贝到构建产物目录,并给资源文件加上哈希。这个细节很关键——哈希化的文件名可以配合 CDN 做长期缓存,用户第二次访问时直接命中缓存,公式渲染几乎是瞬时完成。

另外 KaTeX 本体压缩后大约 280KB(含全部字体),如果只是渲染少量公式,可以考虑只引入核心的 JS,不引入全量 CSS 和字体,用系统字体兜底。但实测下来省不了太多,因为 KaTeX 的公式结构依赖自己的 CSS 类名体系,裁掉 CSS 容易引发排版问题,收益有限。所以我一般还是全量引入 CSS,但通过构建工具按需拆分字体子集来减小体积——不过这个操作比较复杂,我在模拟项目 X 里也没做太深,有兴趣的可以研究@font-face的unicode-range切分。

5.2 页面间跳转时的预渲染策略

如果你做的是文档站,每个页面都有公式,那用户从页面 A 跳到页面 B 时,如果页面 B 才去渲染公式,还是会有可感知的等待。我的优化思路是:

  1. 在页面 A 加载完成后,用浏览器空闲时间预取页面 B 的 HTML。
  2. 拿到 HTML 后,在后端就直接把公式渲染好(服务端渲染),前端只负责展示。

这个方案的关键是服务端要跟前端保持同一个 KaTeX 版本和配置,不然可能出现两端渲染结果不一致的情况。我现在的做法是统一封装一个渲染函数,服务端和客户端都从同一个模块导入,保证配置一致。

5.3 渲染性能瓶颈的定位方法

有时候页面慢不一定是公式的问题,可能是懒加载、图片、字体等资源耦合在一起。我调试时用 Chrome DevTools 的 Performance 面板记录从页面开始加载到完全可交互的过程,重点看 KaTeX 相关的脚本执行时间。

KaTeX 的执行时间分布大致是:

  • 解析 LaTeX 语法(tokenizer + parser)占据 50% 左右。
  • 生成 HTML 结构占 30%。
  • CSS 计算和布局占 20%。

如果你的公式量特别大,解析阶段的耗时是没法通过调整 CSS 优化的,唯一有效的办法就是做缓存。KaTeX 没有官方缓存机制,但你可以自己做一个简单的 Map 缓存:

const formulaCache = new Map(); function renderFormula(latex, options) { const key = latex + JSON.stringify(options); if (formulaCache.has(key)) return formulaCache.get(key); const html = katex.renderToString(latex, options); formulaCache.set(key, html); return html; }

像模拟项目 X 的题库里,不少公式是重复出现的,缓存命中率还挺高的。这一招对海量公式的页面效果显著。

提示:缓存一定要把选项参数拼进 key 里,否则换了一种显示模式取到的还是旧结果。

5.4 异步渲染和优先渲染顺序的技巧

如果你一个页面上有几十个公式,全量同步渲染也不是不能接受,但为了更好的体验,我做过一个优化:首屏只渲染视口内的公式,滚到哪渲染到哪。实现思路也不复杂——用 IntersectionObserver 监听公式容器是否进入视口,进入后再触发渲染。

const observer = new IntersectionObserver(entries => { entries.forEach(entry => { if (entry.isIntersecting) { const element = entry.target; // 如果还没渲染过,执行渲染 if (!element.dataset.rendered) { renderMathInElement(element, options); element.dataset.rendered = 'true'; } observer.unobserve(element); } }); });

这个做法的收益在长文章页面里特别明显。比如一篇包含一百个公式的长文,如果都在首屏渲染,初始 JS 执行时间会明显拉高,而 IntersectionObserver 方案可以把公式渲染压力平摊到滚动过程中,用户几乎感知不到。

6. 兼容性与扩展:KaTeX 和其他工具的协作

6.1 与 React、Vue 框架的配合

KaTeX 本身是框架无关的,核心 API 就是renderToString和render。在 React 里,我一般封装成一个组件:

function Latex({ formula, displayMode = false }) { const html = useMemo(() => { return katex.renderToString(formula, { displayMode, throwOnError: false }); }, [formula, displayMode]); return <span dangerouslySetInnerHTML={{ __html: html }} />; }

这里用useMemo缓存计算结果,避免组件每次渲染都重新解析公式。在 Vue 里就写个计算属性,或者用computed返回渲染后的 HTML。

要注意的一点是:这类组件输出的是 HTML 字符串,直接dangerouslySetInnerHTML或v-html会有 XSS 风险,所以一定要确保输入的公式是可信的。如果公式来源是用户输入,前端得先做一层安全过滤,至少把<script>、<iframe>这类标签剔掉,最好用专门的 HTML 净化库处理。

6.2 与 Markdown 编辑器的深度集成

很多在线编辑器是基于 Contenteditable 或 ProseMirror 做的,公式一般以占位符形式存,展示时实时渲染。我做过的一个简易版思路是:编辑器里公式源码以特殊标记存储(比如[[katex]]...[[/katex]]),展示时通过 DOM 事件触发局部替换。

回到监测渲染——编辑器里常见的 bug 是光标位置错乱,原因就是渲染后的 HTML 结构改变了 DOM 树结构。解决方案是给每个公式单独包一个容器,用contentEditable="false"阻止光标进入公式内部,只在容器外面做光标定位。这个小细节在写富文本编辑器的人那边几乎是必须的,但我第一次实现时完全没意识到,光标一靠近公式就直接跳走,体验极其割裂。

6.3 KaTeX 与文档生成管线的整合

如果你用静态站点生成器(比如某个基于 Markdown 的文档工具)来发布技术文档,公式渲染可以完全放到构建阶段完成:Markdown 解析 -> 提取公式块 -> KaTeX 渲染成 HTML -> 组装最终页面。这样浏览器加载到的就是纯静态 HTML,公式零运行时开销。

我在某个技术手册站上就是这么做的,构建时调用 KaTeX,产物是纯静态文件,部署到任何地方都能直接访问,公式显示跟文档服务端渲染效果完全一致,不需要维护任何前端 JS 状态。

这一个链路做下来,其实也解决了前面提到的 CDN 不稳定问题——构建后所有资源都变成了本地静态文件,用户访问时没有任何第三方依赖。这个方案对文档类站点来说是最推荐的做法。

7. 只讲干货的总结与经验

说实话,用了 KaTeX 这么久,我最直观的体会有三条:

第一,性能差距是真实的,而且是碾压级的。如果你的产品对首屏加载时间敏感,或者页面公式数量很多,KaTeX 基本是唯一的合理选择。

第二,KaTeX 的 API 设计非常凝练,核心就两个函数。上手很容易,但它的配置项像macros、strict、throwOnError这些,要在实际项目里踩坑才能真正理解怎么设置。我的建议是开发环境保持严格模式,生产环境放宽限制,用户体验优先。

第三,别把渲染公式当成一个孤立问题。它跟构建流程、框架通信、XSS 防护、样式隔离全都缠在一起。我这边最后的经验是:凡是内容可控、静态优先的场景,能服务端渲染就服务端渲染,能构建期渲染就构建期渲染。

最后分享一个我自己一直在用的小工具思路——做一个统一的公式渲染封装层。我现在的做法是封装了 renderFormula、renderFormulaToElement、batchRenderFormulas 这几个 API,内部统一处理缓存、错误兜底、配置合并。团队里任何人要渲染公式,都走这一层接口,不直接调 KaTeX 原生方法。这样后续升级 KaTeX 版本或者切换渲染引擎,改动面都能控制在一个文件里,不用满项目搜索替换。

如果你正在搭新的文档站点、题库系统或者笔记应用,我建议第一版就把 KaTeX 接上,别等性能问题暴露了再换。换渲染引擎虽然是小事,但你把已经渲染好的一堆 HTML 存量内容再迁移一次,还是挺折腾的。提前用对工具,后面省下来的时间,拿来写内容多好。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进