实现的效果:
核心设计原则
四个要点:Web安全、高可用、易拓展和性能
站得高才飞得远,怎么在众多markdown渲染库和清理恶意 HTML的库中技术选型呢?
清理恶意 HTML:选择 DOMPurify。
- 高安全性:DOMPurify 专注于防止 XSS 攻击,能有效清理恶意 HTML。跨平台支持:同时支持浏览器和 Node.js,适合前后端一致的 Markdown 渲染需求。
markdown渲染库:选择markdownIt
- 生态丰富,各种插件都有,拓展性极强。start数最多...也说明认可度...
markdownIt基本使用
通过包管理工具安装:
npm install markdown-it --save
markdown-it 的核心流程是:创建解析实例 → 配置选项 → 解析 Markdown 文本 → 生成 HTML。
基础示例
javascript
// 引入 markdown-itimport MarkdownIt from 'markdown-it';// 创建实例const md = new MarkdownIt();// Markdown 文本const markdownText = `# 标题 1## 标题 2这是一段普通文本,包含 **加粗**、*斜体* 和 \`代码\`。- 列表项 1- 列表项 2[链接](https://example.com)`;// 解析为 HTMLconst html = md.render(markdownText);console.log(html); // 输出:包含对应标签的 HTML 字符串(如 <h1>、<strong>、<ul> 等)
核心配置项
示例:启用 HTML 解析 / 自动换行 / 自动识别链接
const md = MarkdownIt({ html: true, // markdown格式里内嵌html标签,可以进行解析 (默认 false) breaks: true, // 是否将换行符(\n)解析为 <br> 标签(默认 false) linkify: true // 自动识别链接(如 www.example.com 转为 <a>)(默认 false)});
插件拓展
示例:使用 emoji 插件
- 安装插件:
npm install markdown-it-emoji --save
- 使用插件:
import MarkdownIt from 'markdown-it';import emoji from 'markdown-it-emoji';const md = MarkdownIt().use(emoji); // 集成 emoji 插件// 解析包含 emoji 的 Markdownconst html = md.render('Hello :smile:!'); // 输出:<p>Hello 😊!</p>
其他常用插件:
<font style="color:rgb(0, 0, 0);background-color:rgb(249, 250, 251);">markdown-it-table-of-contents</font>
:生成目录<font style="color:rgb(0, 0, 0);background-color:rgb(249, 250, 251);">markdown-it-task-lists</font>
:支持任务列表(<font style="color:rgb(0, 0, 0);background-color:rgb(249, 250, 251);">- [x] 完成</font>
)<font style="color:rgb(0, 0, 0);background-color:rgb(249, 250, 251);">markdown-it-prism</font>
:代码块语法高亮(基于 Prism)自定义渲染规则
自定义代码块(``` 包裹的内容)
例如:为代码块添加自定义类名和容器:
md.renderer.rules.fence = function (tokens, idx, options, env, self) { const token = tokens[idx]; const code = token.content; // 代码内容 const lang = token.info || 'plaintext'; // 代码语言(如 js、html) // 自定义渲染结构 return `<div class="code-block lang-${lang}"> <pre><code>${md.utils.escapeHtml(code)}</code></pre> </div>`;};
DOMPurify基本使用
- 安装 DOMPurify:
npm install dompurify --save
- 渲染后净化 HTML:
import DOMPurify from 'dompurify';const md = MarkdownIt({ html: true });const dirtyHtml = md.render(markdownText);const safeHtml = DOMPurify.sanitize(dirtyHtml); // 净化恶意代码
markdownIt和DOMPurify配合使用
- 给一个很简单的示例吧,主要如何拓展和自定义后续的内容,可以进一步自己去封装
import MarkdownIt from 'markdown-it';import DOMPurify from 'dompurify';import { full as emoji } from 'markdown-it-emoji';export default function useToMarkdownHtml(initialMarkdown) { let error = null; // 初始化 MarkdownIt 实例 const md = MarkdownIt({ html: false, breaks: true, }).use(emoji); // 默认的渲染方法 const defaultRender = function (tokens, idx, options, env, self) { return self.renderToken(tokens, idx, options); }; // 自定义表情符号渲染规则 md.renderer.rules.emoji = function (tokens, idx, options, env, self) { return defaultRender(tokens, idx, options, env, self); }; // 自定义链接渲染规则 md.renderer.rules.link_open = function (tokens, idx, options, env, self) { const token = tokens[idx]; const hrefIndex = token.attrIndex('href'); token.attrs.splice(hrefIndex, 1); // 删除 href 属性 if (hrefIndex >= 0) { token.attrPush(['id', currentHref]); // 添加 id 属性 } return defaultRender(tokens, idx, options, env, self); }; // 获取渲染后的 HTML const getHtml = function () { try { let htmlContent = md.render(initialMarkdown); return DOMPurify.sanitize(htmlContent); } catch (err) { error = err; return useInitMarkdown(initialMarkdown); } }; return { html: getHtml(), error, };}// Markdown 初始化处理const MARKDOWN_CLEAN_PATTERNS = [ [/\[([^\]]+)\]\(.*?\)/g, '$1'], // 保留链接文字 [/!\[.*?\]\(.*?\)/g, ''], // 移除图片 [/`/g, ''], // 移除代码标记 [/\*/g, ''], // 移除星号 [/#/g, ''], // 移除井号 [/:[a-zA-Z0-9_]+:/g, ''] // 移除表情符号];export const useInitMarkdown = (markdown) => MARKDOWN_CLEAN_PATTERNS.reduce( (acc, [pattern, replacement]) => acc.replace(pattern, replacement), markdown );
- 关注几个细节:当遇上 a标签的时候,我们对其原本的
href
属性是进行了剔除的,增加一个id属性给a标签上,那么跳转的时候就可以自定义跳转的方法了。 jumpLink(e) { if (e.target.localName.toLowerCase() === 'a') { const url = e.target.id; // 自定义跳转方法 } },
- 实际上
html:false
时,是可以不使用 DOMPurify.sanitize
的,原因是markdownIt
此时不会渲染markdown
格式内容里面的标签的兜底策略:我们现在采用简单的去除标签来展示,当然你可以看业务要求选择别的更优雅的方式,实际上基本是不会触发这个 catch
的,markdownIt
解析的时候遇到非标准的也会解析为字符串,那么你就可以根据业务要求来自定义这个错误的捕获一直在全量更新渲染,你知道吗?
就拿 vue3
来进行举例吧,使用以上进行解析之后得到的是一个完整的 html
结构,使用 v-html
进行插入,然后 v-html
实际上就是直接操作 DOM 的 innerHTML
属性,将字符串解析为 HTML 并插入到目标元素中。
当渲染更新时,vue 在 diff的时候是无法顾及 v-html插入的内容的,也就是不会转为 vNode
。
当我禁止获取缓存时,会发生下面的效果:页面图片抖动、闪动
在使用缓存情况下,如果markdown只有纯文字和图片的话问题不大。
但是如果有echarts图、视频的话,那这些元素就会被重复渲染,以至于甚至会导致闪烁和卡顿。
局部更新具体方案
- 既然
vue
不会将其转为 vnode,那么我们进行手动转,流程如下markdown -> html -> ast -> vnodeunifiedjs生态是一个很强大的数据处理的插件系统,可处理多种数据,使用示例如下:
// use-markdown-renderer.tsimport { ref, watch, type Ref, type VNode, h, computed } from 'vue';import { unified } from 'unified';import remarkParse from 'remark-parse';import remarkBreaks from 'remark-breaks';import remarkGfm from 'remark-gfm';import remarkMath from 'remark-math';import remarkGemoji from 'remark-gemoji';import remarkRehype from 'remark-rehype';import rehypeRaw from 'rehype-raw';import katex from 'katex';import type { Root } from 'hast';import type { Plugin } from 'unified';export interface MarkdownRenderers { mermaid?: any; code?: any; image?: any; video?: any; math?: any; [key: string]: any;}export interface UseMarkdownRendererOptions { content: string; safeMode?: boolean; plugins?: Plugin[]; renderers?: MarkdownRenderers;}export function useMarkdownRenderer(options: UseMarkdownRendererOptions) { const vNodeTree = ref<VNode | null>(null); const loading = ref(false); const error = ref<string | null>(null); // 创建 markdown 处理器 const processor = computed(() => { let p = unified() .use(remarkParse) .use(remarkBreaks) .use(remarkGfm, { singleTilde: false }) .use(remarkMath) .use(remarkGemoji) .use(remarkRehype, { allowDangerousHtml: !options.safeMode }) .use(rehypeRaw); options.plugins?.forEach(plugin => p.use(plugin)); return p; }); // 完整转换逻辑 const createVNodeTransformer = (renderers?: MarkdownRenderers) => { const handlePreElement = (node: any): VNode | null => { const codeNode = node.children?.find((child: any) => child.tagName === 'code'); if (!codeNode) return h('pre', {}, node.children?.map((child: any) => hastToVNode(child))); const className = codeNode.properties?.className || []; const classArray = Array.isArray(className) ? className : [className]; const classStr = classArray.join(' '); // Mermaid 图表 if (classStr.includes('language-mermaid')) { const code = codeNode.children?.[0]?.value || ''; return renderers?.mermaid ? h(renderers.mermaid, { code }) : h('div', { class: 'mermaid-error' }, 'Mermaid组件未注册'); } // 数学公式块 if (classStr.includes('language-math')) { const mathCode = codeNode.children?.[0]?.value || ''; return renderers?.math ? h(renderers.math, { code: mathCode, displayMode: true }) : h('div', { innerHTML: katex.renderToString(mathCode, { displayMode: true, throwOnError: false }) }); } // 普通代码块 const lang = classArray.find(c => typeof c === 'string' && c.startsWith('language-'))?.replace('language-', '') || ''; const code = codeNode.children?.[0]?.value || ''; return renderers?.code ? h(renderers.code, { code, language: lang }) : h('pre', {}, h('code', codeNode.properties, code)); }; const handleCodeElement = (node: any): VNode => { const className = node.properties?.className || []; const classArray = Array.isArray(className) ? className : [className]; // 行内公式 if (classArray.includes('math-inline')) { const mathCode = node.children?.[0]?.value || ''; return renderers?.math ? h(renderers.math, { code: mathCode, displayMode: false }) : h('span', { innerHTML: katex.renderToString(mathCode, { displayMode: false, throwOnError: false }) }); } return h('code', node.properties, node.children?.map((child: any) => hastToVNode(child))); }; const hastToVNode = (node: any): VNode | string | (VNode | string)[] | null => { if (!node) return null; if (node.type === 'root') { return h( 'div', { class: 'markdown-body' }, node.children?.map((child: any) => hastToVNode(child)) ); } if (node.type === 'element') { const { tagName, properties, children } = node; if (renderers?.[tagName]) { return h(renderers[tagName], { ...properties, node }); } switch (tagName) { case 'pre': return handlePreElement(node); case 'code': return handleCodeElement(node); case 'a': return h('a', { ...properties, target: '_blank', rel: 'noopener noreferrer' }, children?.map(hastToVNode)); case 'img': return renderers?.image ? h(renderers.image, properties) : h('img', properties); case 'video': return renderers?.video ? h(renderers.video, properties) : h('video', properties); default: return h(tagName, properties, children?.map(hastToVNode)); } } if (node.type === 'text') return node.value; return null; }; return hastToVNode; }; // 处理 markdown 内容 const processMarkdown = async (content: string) => { loading.value = true; error.value = null; try { const file = await processor.value.process(content); const hastTree = file.result as Root; const transformer = createVNodeTransformer(options.renderers); return transformer(hastTree); } catch (err) { error.value = (err as Error).message; return h('div', { class: 'render-error' }, 'Markdown 处理错误'); } finally { loading.value = false; } }; // 自动监听内容变化 watch( () => [options.content, options.renderers, options.plugins], async ([newContent]) => { if (newContent) { vNodeTree.value = await processMarkdown(newContent) as VNode; } }, { immediate: true } ); return { vNodeTree, loading, error, refresh: () => processMarkdown(options.content) };}
组件使用示例 (MarkdownRenderer.vue):
<template> <div class="markdown-renderer"> <component :is="vNodeTree" v-if="vNodeTree" /> <div v-if="loading" class="loading-indicator"> <div class="spinner"></div> <span>渲染中...</span> </div> <div v-if="error" class="error-message"> <span>⚠️ {{ error }}</span> </div> </div></template><script lang="ts" setup>import { useMarkdownRenderer } from './use-markdown-renderer';import type { MarkdownRenderers } from './use-markdown-renderer';const props = defineProps<{ content: string; safeMode?: boolean; plugins?: any[]; renderers?: MarkdownRenderers;}>();const { vNodeTree, loading, error } = useMarkdownRenderer({ content: props.content, safeMode: props.safeMode, plugins: props.plugins, renderers: props.renderers});</script>