实现的效果:
遇到过的问题:当我禁止获取缓存时,会发生下面的效果:页面图片抖动、闪动
核心设计原则
四个要点: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-it
import MarkdownIt from 'markdown-it';
// 创建实例
const md = new MarkdownIt();
// Markdown 文本
const markdownText = `
# 标题 1
## 标题 2
这是一段普通文本,包含 **加粗**、*斜体* 和 \`代码\`。
- 列表项 1
- 列表项 2
[链接](https://example.com)
`;
// 解析为 HTML
const 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 的 Markdown
const 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 -> vnode
unifiedjs生态是一个很强大的数据处理的插件系统,可处理多种数据,使用示例如下:
// use-markdown-renderer.ts
import { 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>