Markdown渲染引擎——js技能提升
3. Markdown渲染引擎
功能概述
实现功能完整的Markdown渲染引擎,支持数学公式、流程图、图表、代码高亮等高级功能。
技术难点
- 自定义Markdown解析器
- 数学公式LaTeX渲染
- 流程图和图表集成
- 代码语法高亮
- 插件系统设计
实现思路
3.1 Markdown解析器核心
// src/markdown/index.ts
import MarkdownIt from 'markdown-it'
import { full as emoji } from 'markdown-it-emoji'
import markdownItAbbr from 'markdown-it-abbr'
import markdownItFootnote from 'markdown-it-footnote'
import markdownItDeflist from 'markdown-it-deflist'
import markdownItIns from 'markdown-it-ins'
import markdownItMark from 'markdown-it-mark'
import markdownItTaskLists from 'markdown-it-task-lists'
import markdownItToc from 'markdown-it-toc-done-right'
import mila from 'markdown-it-link-attributes'
import markdownItAttrs from 'markdown-it-attrs'
import markdownItKatex from 'markdown-it-katex'import { renderer } from './tokenRenderer'
import type { ParsedToken, MarkdownPlugin } from './types'export type { MarkdownPlugin } from './types'class MarkdownParser {private static instance: MarkdownParser | null = nullprivate md: MarkdownItprivate tokenRenderer: ReturnType<typeof renderer>private initialized: boolean = falseprivate plugins: MarkdownPlugin[] = []private constructor() {this.md = new MarkdownIt({html: true,linkify: true,typographer: true,})this.tokenRenderer = renderer()}public static getInstance(): MarkdownParser {if (!MarkdownParser.instance) {MarkdownParser.instance = new MarkdownParser()}return MarkdownParser.instance}private initialize() {if (this.initialized) return this// 注册所有插件this.md.use(emoji)this.md.use(markdownItAttrs)this.md.use(markdownItAbbr)this.md.use(markdownItFootnote)this.md.use(markdownItDeflist)this.md.use(markdownItIns)this.md.use(markdownItMark)this.md.use(markdownItToc)this.md.use(markdownItTaskLists, { enable: true })this.md.use(mila, {matcher(href: string) {return href.startsWith('https:') || href.startsWith('http:')},attrs: {target: '_blank',rel: 'noopener noreferrer',},})this.md.use(markdownItKatex)this.initialized = truereturn this}// 注册自定义插件registerPluginRenderer(plugin: MarkdownPlugin) {this.plugins.push(plugin)this.tokenRenderer.registerPlugin(plugin)}parse(md: string): any[] {if (!this.initialized) {this.initialize()}const tokens = this.md.parse(md, {})return this.tokenRenderer.renderTokens(tokens)}
}export function createMarkdownParser(): MarkdownParser {return MarkdownParser.getInstance()
}
3.2 Token渲染器
// src/markdown/tokenRenderer.ts
import { h } from 'vue'
import type { ParsedToken, TokenAttributes, MarkdownPlugin } from './types'
import { TAG_MAP } from './constants'
import { getStyles } from './theme'
import { NImage } from 'naive-ui'
import {CodeHighlight,MathBlock,EchartsBlock,MermaidBlock,
} from './components'class TokenRenderer {private static instance: TokenRenderer | null = nullprivate plugins: MarkdownPlugin[] = []private constructor() {}static getInstance(): TokenRenderer {if (!TokenRenderer.instance) {TokenRenderer.instance = new TokenRenderer()}return TokenRenderer.instance}// 注册插件registerPlugin(plugin: MarkdownPlugin) {this.plugins.push(plugin)}// 获取插件组件private getPluginComponent(fenceType: string) {const plugin = this.plugins.find(p => p.type === fenceType)return plugin?.component}// 渲染叶子节点private renderLeaf(token: ParsedToken,renderTokens: (tokens: ParsedToken[]) => any[]) {switch (token.type) {case 'text':return token.content || ''case 'emoji':return token.contentcase 'softbreak':return ' 'case 'hardbreak':return h('br')case 'html_inline':return h('span', { innerHTML: token.content })case 'html_block':return h('div', { innerHTML: token.content })case 'math_inline':return h(MathBlock, { formula: token.content, displayMode: false })case 'math_block':return h(MathBlock, { formula: token.content, displayMode: true })case 'code_inline':return h('code', { class: getStyles(token) }, token.content)case 'fence': {const fenceType = (token.info?.trim() || '').toLowerCase()const pluginComponent = this.getPluginComponent(fenceType)if (pluginComponent) {return h(pluginComponent, { content: token.content })}if (fenceType === 'echarts') {return h(EchartsBlock, { option: token.content })}if (fenceType === 'mermaid') {return h(MermaidBlock, { code: token.content })}return h(CodeHighlight, {code: token.content || '',type: fenceType || 'text',})}case 'hr':return h('hr', { class: getStyles(token) })case 'image':return h(NImage, this.getAttributes(token))default:return this.renderUnknownToken(token, renderTokens)}}// 渲染Token数组renderTokens(tokens: ParsedToken[]): any[] {const nodes: any[] = []let i = 0while (i < tokens.length) {const token = tokens[i]const tokenType = token.typeif (tokenType.endsWith('_open')) {let depth = 1let j = i + 1while (j < tokens.length) {if (tokens[j].type.endsWith('_open')) depth++if (tokens[j].type.endsWith('_close')) depth--if (depth === 0) breakj++}const tagName = this.getTagName(token)const attrs = this.getAttributes(token)const childrenTokens = tokens.slice(i + 1, j)const childrenNodes = this.renderTokens(childrenTokens)const vnode = h(tagName, attrs, childrenNodes)nodes.push(vnode)i = j + 1} else if (tokenType === 'inline') {const childrenNodes = this.renderTokens(token.children || [])nodes.push(...childrenNodes)i++} else {const leafNode = this.renderLeaf(token, tokens =>this.renderTokens(tokens))if (leafNode !== null) {nodes.push(leafNode)}i++}}return nodes}
}export function renderer(): TokenRenderer {return TokenRenderer.getInstance()
}
3.3 数学公式渲染组件
// src/markdown/components/MathBlock/index.tsx
import { defineComponent, computed } from 'vue'
import katex from 'katex'
import 'katex/dist/katex.min.css'export default defineComponent({name: 'MathBlock',props: {formula: { type: String, required: true },displayMode: { type: Boolean, default: false },},setup(props) {const rendered = computed(() => {try {return katex.renderToString(props.formula, {throwOnError: false,displayMode: props.displayMode,})} catch (e) {console.error('公式渲染错误:', e)return '<span style="color:red">公式错误</span>'}})return () => (<spanclass={props.displayMode ? 'katex katex-block' : 'katex'}innerHTML={rendered.value}/>)},
})
3.4 流程图渲染组件
// src/markdown/components/MermaidBlock/index.tsx
import { defineComponent, ref, watch, onBeforeUnmount, onMounted } from 'vue'
import mermaid from 'mermaid'
import { NImage } from 'naive-ui'function getUniqueId() {return 'mermaid-svg-' + Math.random().toString(36).substr(2, 9)
}// 将SVG转换为data URL
function svgToDataUrl(svgString: string): string {const svgBlob = new Blob([svgString], { type: 'image/svg+xml;charset=utf-8' })return URL.createObjectURL(svgBlob)
}export default defineComponent({name: 'MermaidBlock',props: {code: { type: String, required: true },},setup(props) {const imageUrl = ref('')const uniqueId = getUniqueId()const containerRef = ref<HTMLDivElement | null>(null)const renderMermaid = () => {if (!containerRef.value) returnmermaid.initialize({startOnLoad: true,theme: 'neutral',flowchart: { useMaxWidth: false },})mermaid.render(uniqueId, props.code, containerRef.value).then(r => {imageUrl.value = svgToDataUrl(r.svg)}).catch(err => {console.error('Mermaid渲染错误:', err)})}onMounted(renderMermaid)watch(() => props.code, renderMermaid)onBeforeUnmount(() => {if (!imageUrl.value) returnURL.revokeObjectURL(imageUrl.value)})return () => (<div class="bg-gray-800 p-4 rounded-md mb-4 flex items-center justify-center"><divref={containerRef}class="absolute -left-[9999px] -top-[9999px] w-[800px] h-[400px]"/><NImagesrc={imageUrl.value}alt="Mermaid Diagram"previewed-img-props={{ class: 'bg-gray-800' }}/></div>)},
})
3.5 图表渲染组件
// src/markdown/components/EchartsBlock/index.tsx
import { defineComponent, onMounted, ref, watch } from 'vue'
import * as echarts from 'echarts'export default defineComponent({name: 'EchartsBlock',props: {option: { type: String, required: true },},setup(props) {const chartRef = ref<HTMLDivElement | null>(null)let chartInstance: echarts.ECharts | null = nullconst renderChart = () => {if (chartRef.value) {if (!chartInstance) {chartInstance = echarts.init(chartRef.value)}try {const optionObj = JSON.parse(props.option)chartInstance.setOption(optionObj)} catch (e) {console.error('图表渲染错误:', e)chartInstance.clear()chartInstance.setOption({title: {text: '图表解析中',left: 'center',top: 'center',},})}}}onMounted(renderChart)watch(() => props.option, renderChart)return () => <div class="w-full mb-4 min-h-[300px]" ref={chartRef}></div>},
})
关键技术点
- 插件系统: 可扩展的插件架构设计
- Token解析: 自定义Token渲染逻辑
- 数学公式: KaTeX集成和错误处理
- 流程图: Mermaid渲染和SVG处理
- 图表集成: ECharts动态渲染