一个前端小白的踩坑日记,帮你避开那些"根号变触手"的诡异场面
事情是这样的
前两天接到一个需求:数据库里存了一堆 Markdown 格式的文本,里面还夹杂着各种数学公式,比如 $E=mc^2$ 这种,更狠的还有 $$\int_{-\infty}^{\infty} e^{-x^2} dx = \sqrt{\pi}$$。要在 Vue 页面里把它们渲染得漂漂亮亮的,公式要有专业排版,不能用图片糊弄。
我心想:Markdown 渲染嘛,网上插件一抓一大把,分分钟搞定。结果......一抓就是一把 bug,尤其是那些数学公式,跟成精了似的,根号能变出分身来。这一折腾就是两天,最后总算整明白了。今天就把这个过程揉碎了写给你,让你少走点弯路。
市面上的 Markdown 解析器很多,什么 marked、markdown-it、showdown......我最后选了 showdown,原因就两条:
- 稳:老牌库,这么多年了,坑基本都被填平了。
- 插件友好 :
showdown-katex插件刚好能满足我们 LaTeX 公式的需求,而且配置非常灵活。
装起来也就两行命令的事儿:
bash
npm install showdown
npm install showdown-katex
先上能跑的代码,免得你们着急。这是一个简单的 Vue 组件,用来展示带公式的 Markdown 内容。
vue
<template>
<div class="msg" v-html="transformMsg(msgInfo)"></div>
</template>
<script>
import showdown from "showdown";
import showdownKatex from "showdown-katex";
export default {
data() {
return {
msgInfo: "" // 从后端拿到的 Markdown 字符串
}
},
methods: {
transformMsg(msgInfo = "") {
// 第一步:预处理脏数据(后面解释为什么)
msgInfo = msgInfo.replaceAll("<br />", "\n");
// 第二步:创建 showdown 转换器,配置好插件
let converter = new showdown.Converter({
tables: true, // 支持表格
strikethrough: true, // 支持 ~~删除线~~
underline: true, // 防止下划线被误解析为斜体
extensions: [
showdownKatex({
throwOnError: false, // 公式写错了也别抛异常,页面别崩
displayMode: false, // 默认行内模式(可以改成 true 让 $$ 变块级)
delimiters: [
{ left: "$$", right: "$$", display: false },
{ left: "$", right: "$", display: false },
{ left: "~", right: "~", display: false, asciimath: true },
],
}),
],
});
// 第三步:转成 HTML 并返回
return converter.makeHtml(msgInfo);
}
}
}
</script>
看着挺简单对吧?我当时也是这么想的,直到我遇到了那个"根号怪"。
奇奇怪怪问题一:根号怎么变分身了?
第一个让我破防的 bug:只要公式里有根号(比如 $\sqrt{2}$),页面上就会出现两个根号!第一个是正常的,第二个像个伸着长脖子的怪物,根号线无限拉长,里面的公式还重复显示。那场面,简直像公式在分裂繁殖。
经过一番搜索(其实就是去 GitHub 翻 issue),才知道这是 KaTeX 渲染机制导致的。KaTeX 为了兼顾屏幕阅读器,会同时生成两个 DOM 结构:一个用于正常显示(.katex),另一个做辅助(.katex-html)。在某些复杂公式(尤其是根号)下,两个结构都会在页面上渲染出来,就造成了重影。
解决方法?简单粗暴,CSS 一刀切:
css
.katex-html {
display: none;
}
放心,这不会影响正常公式的显示,屏幕阅读器也能从 .katex 里读取内容。加完这句,根号立马老实了,世界清净。
奇奇怪怪问题二:数据库里的 <br /> 不听话
我们的数据是老系统倒过来的,里面换行用的是 <br />(中间俩空格)。结果 showdown 转换后,这些 <br /> 原样输出到了 HTML 里,根本没变成换行。页面上一段话全挤在一起,像没分段的作文。
解决办法是在转换前做替换,把 <br /> 变成 Markdown 认的换行符 \n:
javascript
msgInfo = msgInfo.replaceAll("<br />", "\n");
如果你数据库里还有 <br>、<br/> 等各种变体,可以写个正则一把抓:
javascript
msgInfo = msgInfo.replace(/<br\s*\/?>/gi, "\n");
奇奇怪怪问题三:下划线被当成斜体,公式乱套
物理老师最爱写 F_gravity,但 Markdown 里下划线默认表示斜体。于是 F_gravity 就变成了 F<em>gravity</em>,显示出来"F gravity"斜了,完全不是那个意思。
showdown 提供了一个 underline 选项,设为 true 后,__双下划线__ 会变成下划线,但单下划线 _ 还是斜体。这不够啊,我们想要的是:在普通文字里保留 Markdown 语法,但公式里的下划线别捣乱。
其实 showdown-katex 插件在解析时会先把公式内容"保护"起来,只要你的公式被 $...$ 或 $$...$$ 包住,里面的下划线就不会被 Markdown 引擎解析。所以关键是:所有公式都必须写定界符。
如果历史数据里确实有裸下划线没加 $,那只能写个脚本批量包一下,或者在转换前用正则把 \b_\w+_\b 之类的模式手动包成公式。这活儿有点糙,但能救急。
奇奇怪怪问题四:行内公式和块级公式不分家
看上面代码的 delimiters 配置,$$ 和 $ 的 display 都是 false,这意味着无论你写 $E=mc^2$ 还是 $$E=mc^2$$,都会被当成行内公式,不换行,不居中。这显然不符合常识。
正确的配置应该是:$ 行内,$$ 块级。改一下:
javascript
delimiters: [
{ left: "$$", right: "$$", display: true }, // 块级公式,独占一行
{ left: "$", right: "$", display: false }, // 行内公式,夹在文字中间
{ left: "~", right: "~", display: false, asciimath: true }
]
当然,如果你希望 $$ 也当行内用,那就保持原样。但根据 LaTeX 习惯,还是建议区分开来。
如果直接在模板里写 v-html="transformMsg(msgInfo)",每次组件重新渲染(比如窗口滚动、数据变化)都会重新解析 Markdown 和公式。公式一多,页面就卡得像幻灯片。
改成计算属性(computed)缓存一下结果:
javascript
computed: {
renderedHtml() {
return this.transformMsg(this.msgInfo);
}
}
模板里用 v-html="renderedHtml",只有当 msgInfo 真正变化时才会重新转换。
KaTeX 自带样式,但默认块级公式的上下边距可能跟你的页面不搭。可以自己在全局 CSS 里调一下:
css
.katex-display {
margin: 1.5em 0;
overflow-x: auto; /* 防止超宽公式溢出 */
}
如果行内公式显得太小,可以来个:
css
.katex {
font-size: 1.05em;
}
throwOnError: false 是个好习惯。公式写错了,页面不会白屏,只会显示一段红色的错误提示。用户至少能看到"这里有个公式没渲染好",而不是整个页面崩掉。
我把上面的点整合成一个 Vue 单文件组件,你直接拷到项目里就能用:
vue
<template>
<div class="markdown-math" v-html="renderedHtml"></div>
</template>
<script>
import showdown from "showdown";
import showdownKatex from "showdown-katex";
export default {
name: "MarkdownMath",
props: {
content: {
type: String,
default: ""
},
// 可选:自定义定界符
delimiters: {
type: Array,
default: null
}
},
computed: {
renderedHtml() {
return this.transform(this.content);
}
},
methods: {
transform(raw) {
if (!raw) return "";
// 预处理奇怪的换行
let processed = raw.replace(/<br\s*\/?>/gi, "\n");
const customDelimiters = this.delimiters || [
{ left: "$$", right: "$$", display: true },
{ left: "$", right: "$", display: false },
{ left: "\\[", right: "\\]", display: true },
{ left: "\\(", right: "\\)", display: false }
];
const converter = new showdown.Converter({
tables: true,
strikethrough: true,
underline: false, // 关掉下划线解析,避免干扰公式
simpleLineBreaks: true,
extensions: [
showdownKatex({
throwOnError: false,
delimiters: customDelimiters
})
]
});
return converter.makeHtml(processed);
}
}
};
</script>
<style scoped>
/* 组件内部样式 */
.markdown-math {
line-height: 1.6;
word-wrap: break-word;
}
</style>
<style>
/* 全局样式,解决根号分身 */
.katex-html {
display: none;
}
.katex-display {
margin: 1em 0;
overflow-x: auto;
overflow-y: hidden;
}
</style>