一、前言
mathjax库用来渲染dom结构中的latex公式。和katex相比,它支持更多的语法(排版语法等),并且有多种输出格式,包括html、svg等。笔者因为katex难以实现根据容器大小自动缩放公式的功能,mathjax支持输出svg比较方便,所以在项目中把库换成了mathjax。然而mathjax文档略混乱,网上资料又少,自己痛苦摸索了几天,在掘金记录一下用法以便后来人查阅……
本文描述了即时公式渲染和批量渲染dom两种渲染方式。
使用到的框架是react,但涉及到框架的内容并不多,用vue的同学也可以参考~
二、引入mathjax
这里使用的是目前最新的mathjax版本(3.2),网上已有的资料大部分是2.7的,大家注意区分,其中的api变化不小。
2.1 引入脚本
我们可以使用cdn引入script脚本,也可以使用npm包。使用npm包的话首先需要运行npm install mathjax@3(现在已经更新到4大版本了,但是我们还是用3),但是mathjax的npm包本质上还是加载js文件,跟引入script的配置方法几乎没有差异。在后续startLoadMathjax方法中对npm引入方式进行了注释。
const MATHJAX_SCRIPT_URL = 'https://cdn.bootcdn.net/ajax/libs/mathjax/3.2.2/es5/tex-svg-full.js';
需要使用promise等方法防止重复引入,重复引入会导致window.MathJax的一些方法被删除! 下面是一个例子,其中load方法是自定义加载script脚本的方法,大概就是创建一个script标签再append到body上,这里不赘述。
let initMathjaxPromise; // Promise对象,表示脚本是否加载完毕
async initMathjax() {
if (!this.initMathjaxPromise) {
this.startLoadMathjax();
}
return this.initMathjaxPromise;
}
async startLoadMathjax() {
this.initMathjaxPromise = (async () => {
window.MathJax = MATHJAX_CONFIG; // 这个是配置信息,后文会讲到。使用默认配置可以删掉这句。
await load(MATHJAX_SCRIPT_URL); // 这里写自己的加载script方法
//npm包引入写: await require('mathjax/es5/tex-svg-full.js')
//引入mathjax或者使用import等方法都会报错,mathjax对webpack本身非常不友好
})();
}
mathjax的配置信息载入方式比较怪异,需要在载入脚本之前先写好配置信息。除了上述代码中用到的方法,还有其他的方式,例如单独创建一个js文件存放配置信息等,想要了解的话可以访问文末附上的参考资料查看。
2.2 配置信息
2.2.1 完整的配置信息
这里先附上笔者使用的配置信息,后边会讲点比较重要的配置。完整的配置信息见官方文档:www.osgeo.cn/mathjax/opt…
export const MATHJAX_CONFIG = {
startup: {
pageReady: () => Promise.resolve(), // 重写pageReady,禁止加载脚本后自动渲染
},
options: {
// 隐藏右键菜单
enableMenu: false,
// 忽略扫描的标签
skipHtmlTags: ['script', 'noscript', 'style', 'textarea'],
// 需要排除的class标签
ignoreHtmlClass: 'class-ignore',
// 需要处理的class标签
processHtmlClass: 'class-process',
},
// 输入配置
tex: {
// 自定义匹配行内数学公式的开头和结尾
inlineMath: [
['\(', '\)']
],
// 自定义匹配行间数学公式的开头和结尾
displayMath: [
['$$', '$$'],
['\[', '\]']
],
// 解析出现错误的回调函数
formatError: () => {
throw new Error('mathjax error')
},
},
// 输出配置
svg: {
// 缩放比例
scale: 1,
// factor最小缩放比例
minScale: 0.5,
// 靠左水平对齐
displayAlign: 'left',
},
};
2.2.2 startup配置
startup包含ready和pageReady两个方法,默认的方法分别是MathJax.startup.defaultReady()和MathJax.startup.defaultPageReady()我们可以对他们进行重写。只需要在这里写需要重写的方法就行了。
ready表示mathjax内容初始化过程,为了能正常运作咱们还是得调用默认方法。但重写的话可以在加载前后添加一些小的修饰。下面的例子来自官方文档:
window.MathJax = {
startup: {
ready: () => {
console.log('MathJax is loaded, but not yet initialized');
MathJax.startup.defaultReady();
console.log('MathJax is initialized, and the initial typeset is queued');
}
}
};
pageReady是这里的重点,它默认的行为会在mathjax就绪后立刻对整个文档进行公式字符串的替换(这个过程称为排版),所以我们最好进行重写。注意需要返回一个promise对象。
startup: {
pageReady: () => Promise.resolve(), // 重写pageReady,禁止加载脚本后自动渲染
},
2.2.3 options配置
这里着重讲解skipHtmlTags、ignoreHtmlClass、processHtmlClass三者。
mathjax的批量替换dom工作中,对于标签在skipHtmlTags中的元素,会直接跳过整个标签及其内部元素;如果某个元素的某级父元素在ignoreHtmlClass中,但自身又在processHtmlClass中,则还是会处理内部的字符串。
如果我只想渲染class"equation-container"中的元素,那么可以给整个body设置一个class,放ignoreHtmlClass中,再把"equation-container"放到processHtmlClass中。
注意,把body放到skipHtmlTags会导致整个文档无法被批量渲染;只设置processHtmlClass不设置ignoreHtmlClass会让整个文档被渲染。
options: {
// 隐藏右键菜单
enableMenu: false,
// 忽略扫描的标签
skipHtmlTags: ['script', 'noscript', 'style', 'textarea'],
// 需要排除的class标签,多个用 | 隔开,'class1|class2|class3'
ignoreHtmlClass: 'class-ignore',
// 需要处理的class标签,多个用 | 隔开,'class1|class2|class3'
processHtmlClass: 'class-process',
},
三、使用
3.1 tex2svgPromise()
tex2svgPromise传入一个字符串,返回的是一个value是svg对象的promise对象。我们可以利用这个做一些加载态的过渡等效果。适用于随着文本变化动态更新公式渲染的情况。 如果不要promise可以直接调tex2svg()。
使用这种方法不会产生跳变,并且在任意时候都不会露出公式原字符串。这种渲染方式本身并不依赖dom,我们采用传入公式字符串后更新state,从而更新需要挂载的元素dom的方法。首次渲染和监听到props.value变化之后调用updateSvg更新我们自己指定的dom。
下面列举了react类组件中的用法,vue也是差不多的,更新data中存放svg的变量,让新的svg挂到dom上即可。也可以使用防抖等方法提高性能。
// props:value 公式字符串
class EquationDisplay extends Component{
public override state={
svg:'';
isLoaded:false;
}
public override render(){
if(!this.state.isLoaded){
return null
}
return <div dangerouslySetInnerHTML={{ __html: this.state.svg }} />;
}
public override componentDidMount() {
this.init();
}
public override componentDidUpdate() {
this.updateSvg();
}
public async init() {
if (!window.MathJax) {
await initMathjax(); // 我们之前写的载入script的方法
}
this.setState({ isLoaded: true });
this.updateSvg();
}
// 异步更新dom内容
public updateSvg = async () => {
const { value } = this.props;
try {
const output = await window.MathJax.tex2svgPromise(value);
const svgs = output.getElementsByTagName('svg');
if (svgs.length > 0) {
const svg = formula[0];
this.setState({ svg: svg.outerHTML }); //注意这里需要渲染的是svg.outerHTML
}
}
// 清除MathJax缓存
window.MathJax.startup.document.clear();
window.MathJax.startup.document.updateDocument();
} catch (e) {
console.log(e);
}
};
}
如果需要公式svg能够自动缩放,在父容器的样式中写上下面的样式即可:
.containter {
svg {
max-width: 100%;
max-height: 100%;
}
}
3.2 typesetPromise()
上面一种方法对于单个公式渲染是比较友好的,但是在有多个独立公式dom存在的情况下,每个dom渲染都是异步的,可能会出现卡顿。对于大量需要处理的公式dom,可以使用typesetPromise()
typesetPromise()方法对我们在options中设置的dom进行批量处理,匹配元素innerHTML中的公式字符串(tex配置项中我们设置了公式字符串的开头结尾)。如果我们使用默认的pageReady(),在这个过程中也会进行一次typeset()。这个方法只处理当前文档中存在的元素,每次dom更新之后,都要重新调一次typesetPromise()才可以更新新增的公式,而每次调用mathjax都要遍历整个文档树,因此频繁变化的公式不适宜用这个方法。
由于渲染需要时间,使用这个方法刚渲染的时候会露出原有的文本内容,这个大家可以设置color:white,或者利用promise控制蒙层显示等。
// 很多很多公式,每个都要渲染
private equations=[
'v = \frac { c } { n } ',
'v = \frac { c } { n } ',
'v = \frac { c } { n } ',
....
'v = \frac { c } { n } '
]
public overrde render(){
return (
<div className='shouldProcess'>
{
//要把公式文本包裹在标签,注意前后缀
this.equations.map((value)=>(<div>{`$$ ${value} $$`}</div>))
}
</div>
)
}
public override componentDidMount(): void {
this.show();
}
public async show() {
await initMathjax();
await window.MathJax.typesetPromise();
}