DC娱乐网

从原理到实践:深度剖析js数学公式渲染引擎 MathJax

大家好,我是徐小夕。架构师,曾任职多家上市公司,多年架构经验,打造过上亿用户规模的产品,目前全职创业,主要聚集于“Doo

大家好,我是徐小夕。架构师,曾任职多家上市公司,多年架构经验,打造过上亿用户规模的产品,目前全职创业,主要聚集于“Dooring AI零代码搭建平台”和“flowmixAI多模态办公软件”

之前和大家分享了我实现的 pxcharts 多维表格编辑器和协同文档编辑器:

px-doc,一款开箱即用的协同文档编辑器

pxcharts多维表格ultra版:AI + 多维表,工作效率飙升!

最近在迭代多模态文档编辑器 flowmix/docx 的过程中,发现了一款非常强大的数学公式渲染器 MathJax ,它可以在网页上完美展示复杂数学公式,瞬间就被它的强大功能圈粉了。

MathJax 是一个开源的 JavaScript 数学公式渲染引擎,支持 LaTeX、MathML 和 AsciiMath 等多种数学符号表示法,能在所有现代浏览器中完美运行。

GitHub 仓库:https://github.com/mathjax/MathJax

很多高校、科研机构和科技公司都在使用它在网页端展示数学内容。

这个项目最打动我的一点是:它让前端工程师无需担心用户的设备或浏览器配置,只需专注于网页开发,剩下的排版和渲染工作交给 MathJax 即可。

功能亮点

在深入研究源码和文档后,我发现 MathJax 有几个堪称 "杀手级" 的特性:

全浏览器兼容:无需任何插件,在所有现代浏览器中都能正常工作,解决了早期数学插件兼容性差的痛点。多格式支持:同时支持 LaTeX、MathML 和 AsciiMath 三种主流数学表示法,满足不同用户的习惯。无障碍访问:内置对屏幕阅读器等辅助技术的支持,自动生成语音描述,让视障人士也能 "阅读" 数学公式。高质量渲染:采用矢量图形输出,无论缩放多少倍都能保持清晰,远超图片展示的效果。灵活扩展:提供强大的 API,开发者可以根据需求定制渲染效果,甚至开发新的输入输出格式。剪切粘贴互通性:支持公式在不同应用间的无缝复制粘贴,解决了数学内容传播的一大障碍。

如果你是文档 / 知识库类产品开发者,这款开源插件绝对是不二选择。后期我也会把它集成到 flowmix/docx 多模态文档编辑器中,让它渲染更复杂的数学公式。

技术实现深度剖析

我深度分析了github上的源码,总结了一份 MathJax 的设计架构,大家也可以参考学习一下:

作为一个成熟的 JavaScript 库,MathJax 的技术实现有很多值得学习的地方:

语言与模块化:最新版本采用 TypeScript 开发,确保类型安全,同时使用 ES6 模块系统组织代码,既支持浏览器环境也支持 Node.js。渲染技术:通过抽象适配层(Adaptor)处理不同环境的 DOM 操作,核心渲染逻辑与具体环境解耦,这也是它能同时支持浏览器和 Node.js 的关键。组件化设计:采用组件化架构,将不同功能封装成独立组件(如输入格式、输出格式、辅助功能等),用户可以按需加载,减少资源体积。字体处理:内置对多种数学字体的支持,通过动态加载和范围优化技术,在保证渲染质量的同时减少字体文件大小。异步处理:大量使用异步操作处理公式解析和渲染,避免阻塞主线程,保证页面响应性。

我看了 package.json ,MathJax 的依赖管理非常精简,核心功能基本自研,仅在字体等特定功能上使用外部包(如 @mathjax/mathjax-newcm-font)。

应用场景

根据我自己对行业的了解,总结了 MathJax 的以下几个应用场景:

学术博客与论文平台像 arXiv、IEEE Xplore 等平台用它展示论文中的数学公式在线教育系统Coursera、edX 等慕课平台依赖它呈现数学课程内容科学计算工具一些在线计算工具(如 Desmos)用它展示计算过程和结果文档系统许多技术文档工具(如 Sphinx)集成它来处理文档中的数学表达式论坛与问答平台Stack Exchange 等平台用它让用户能方便地输入数学公式优缺点分析

经过一段时间的深度使用,我总结出 MathJax 的一些优缺点:

优点:

渲染质量极高,公式美观且清晰兼容性无可挑剔,几乎支持所有浏览器配置灵活,能满足各种个性化需求文档完善,社区活跃,问题能得到及时解答对无障碍访问的支持远超同类工具

缺点:

首次加载速度可能较慢,尤其对于复杂公式配置选项较多,初学者可能需要一定学习成本某些高级功能需要深入理解其内部机制最新版本(v4)与 v2 版本差异较大,迁移需要成本本地使用教程

由于官方文档都是英文,这里我简单和大家介绍一下如何把它应用到自己的项目。

首先我们需要安装依赖:

npm install mathjax@4

由于原生使用比较复杂,我封装了一个js类,大家可以轻松调用api来实现,感兴趣的可以参考一下我的实现代码:

// mathjax.js class MathJaxService {   constructor() {     // 确保配置对象存在且不覆盖已有的全局配置     this.config = window.MathJax?.config || {       tex: {         inlineMath: [['$', '$'], ['\\(', '\\)']],         displayMath: [['$$', '$$'], ['\\[', '\\]']],         tags: 'none'       },       svg: {         fontCache: 'global'       },       startup: {         // 使用箭头函数确保this指向正确         pageReady: () => this.handleReady()       }     };     // 状态管理     this.isLoaded = !!window.MathJax;     this.isLoading = false;     this.readyCallbacks = [];     // 初始化全局配置     if (!window.MathJax) {       window.MathJax = this.config;     } else {       // 如果已有配置,合并当前配置       Object.assign(window.MathJax.config, this.config);     }   }   // 加载MathJax核心库,增加加载状态防止重复加载   async load() {     if (this.isLoaded) return Promise.resolve();     if (this.isLoading) {       // 等待已有的加载过程完成       return new Promise(resolve => this.onReady(resolve));     }     this.isLoading = true;     return new Promise((resolve, reject) => {       // 检查是否已有script标签       const existingScript = document.querySelector(`script[src*="mathjax@3/es5/tex-svg.js"]`);       if (existingScript) {         // 如果已有脚本,监听其加载完成         const checkLoaded = () => {           if (window.MathJax?.isReady) {             existingScript.removeEventListener('load', checkLoaded);             this.isLoaded = true;             this.isLoading = false;             resolve();           }         };         existingScript.addEventListener('load', checkLoaded);         // 立即检查一次         checkLoaded();         return;       }       // 动态创建script标签加载MathJax       const script = document.createElement('script');       script.src = 'https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-svg.js';       script.defer = true;       script.crossOrigin = 'anonymous'; // 增加跨域支持       const cleanup = () => {         script.removeEventListener('load', onLoad);         script.removeEventListener('error', onError);       };       const onLoad = () => {         // 等待MathJax完全初始化         const checkReady = () => {           if (window.MathJax?.isReady) {             this.isLoaded = true;             this.isLoading = false;             cleanup();             resolve();           } else {             setTimeout(checkReady, 50);           }         };         checkReady();       };       const onError = (err) => {         console.error('MathJax加载失败', err);         this.isLoading = false;         cleanup();         reject(new Error('MathJax加载失败: ' + (err.message || '未知错误')));       };       script.addEventListener('load', onLoad);       script.addEventListener('error', onError);       document.head.appendChild(script);     });   }   // 处理MathJax就绪事件   handleReady() {     console.log('MathJax已就绪');     // 异步执行回调,避免阻塞主线程     setTimeout(() => {       this.readyCallbacks.forEach(cb => {         try {           cb();         } catch (err) {           console.error('MathJax就绪回调执行失败', err);         }       });       this.readyCallbacks = [];     }, 0);   }   // 注册就绪回调   onReady(callback) {     if (typeof callback !== 'function') {       console.warn('MathJax onReady需要传入函数作为参数');       return;     }     if (this.isLoaded && window.MathJax?.isReady) {       // 立即执行已就绪的回调       setTimeout(callback, 0);     } else {       this.readyCallbacks.push(callback);     }   }   // 渲染指定元素中的公式   render(element) {     return new Promise((resolve, reject) => {       // 参数验证       if (!(element instanceof HTMLElement)) {         return reject(new Error('render方法需要传入HTMLElement类型的参数'));       }       if (!window.MathJax) {         return reject(new Error('MathJax未加载,请先调用load方法'));       }       // 确保元素已添加到DOM中       if (!element.isConnected) {         return reject(new Error('渲染元素未添加到DOM中'));       }       // 使用MathJax的异步渲染方法       window.MathJax.typesetPromise([element])         .then(() => {           console.log('公式渲染完成');           resolve();         })         .catch(err => {           console.error('公式渲染失败', err);           reject(new Error('公式渲染失败: ' + (err.message || '未知错误')));         });     });   }   // 清理指定元素中的公式   clear(element) {     if (!(element instanceof HTMLElement)) {       console.warn('clear方法需要传入HTMLElement类型的参数');       return;     }     if (window.MathJax && window.MathJax.typesetClear) {       window.MathJax.typesetClear([element]);     }   } } // 导出单例实例 export const mathJaxService = new MathJaxService(); // 使用示例 (app.js) // import { mathJaxService } from './mathjax-service.js'; // 确保DOM加载完成后执行 if (document.readyState === 'loading') {   document.addEventListener('DOMContentLoaded', initMathApp); } else {   initMathApp(); } async function initMathApp() {   try {     console.log('开始加载MathJax');     await mathJaxService.load();     console.log('MathJax加载完成,准备渲染公式');     // 准备包含公式的内容     const content = `       <div>         <h3>量子力学公式</h3>         <p>薛定谔方程:$i\hbar\frac{\partial}{\partial t}\Psi(\mathbf{r},t) = \hat{H}\Psi(\mathbf{r},t)$</p>         <p>不确定性原理:$$\sigma_x \sigma_p \geq \frac{\hbar}{2}$$</p>       </div>     `;     // 创建DOM元素     const container = document.createElement('div');     container.innerHTML = content;     document.body.appendChild(container);     // 渲染公式     await mathJaxService.render(container);     // 动态添加新公式     setTimeout(async () => {       try {         const newFormula = document.createElement('p');         newFormula.innerHTML = `新添加的公式:$$\int_{-\infty}^{\infty} \psi^*(x) \psi(x) dx = 1$$`;         container.appendChild(newFormula);         // 重新渲染         await mathJaxService.render(newFormula);         console.log('动态公式渲染完成');       } catch (err) {         console.error('动态渲染失败', err);       }     }, 2000);   } catch (err) {     console.error('初始化失败', err);     // 显示用户友好的错误信息     const errorDiv = document.createElement('div');     errorDiv.style.color = 'red';     errorDiv.textContent = `公式渲染服务加载失败: ${err.message}`;     document.body.prepend(errorDiv);   } }

好啦,今天就分享到这,如果大家感兴趣,也欢迎随时留言区交流反馈。