掘金 人工智能 07月03日 16:08
做大模型应用的你们,真的懂markdown渲染吗?
index_new5.html
../../../zaker_core/zaker_tpl_static/wap/tpl_guoji1.html

 

本文深入探讨了在前端项目中实现Markdown渲染的多种方案,核心关注安全性、性能和局部更新。文章首先介绍了使用DOMPurify清理恶意HTML,并结合MarkdownIt进行Markdown渲染。随后,文章分析了使用v-html进行渲染时遇到的问题,以及如何通过AST(抽象语法树)和VNode(虚拟节点)实现局部更新,以提高性能。最后,文章介绍了基于unifiedjs生态的更强大的Markdown渲染方案,提供了更灵活的定制选项。

🛡️ **安全性与清理:** 为了确保安全性,文章推荐使用DOMPurify库来清理渲染后的HTML,防止XSS攻击,同时结合MarkdownIt进行Markdown渲染。

⚙️ **MarkdownIt与DOMPurify的结合:** 介绍了MarkdownIt的核心流程:创建解析实例、配置选项、解析Markdown文本和生成HTML。并展示了如何结合DOMPurify,在HTML渲染后进行净化,提高安全性。

🔄 **局部更新的实现:** 阐述了使用v-html直接渲染HTML时,Vue无法有效进行DOM diff的问题。文章提出了通过将Markdown转换为AST,再转换为VNode,从而实现局部更新,提高渲染性能的解决方案。

🚀 **基于Unifiedjs的渲染方案:** 介绍了基于Unifiedjs生态的更强大的Markdown渲染方案,通过使用remarkParse、remarkBreaks等插件,可以将Markdown转换为VNode,从而实现更灵活的定制和更高效的更新。

实现的效果:

核心设计原则

四个要点:Web安全、高可用、易拓展和性能

站得高才飞得远,怎么在众多markdown渲染库和清理恶意 HTML的库中技术选型呢?

清理恶意 HTML:选择 DOMPurify。

markdown渲染库:选择markdownIt

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>

其他常用插件:

自定义渲染规则

自定义代码块(``` 包裹的内容)

例如:为代码块添加自定义类名和容器:

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  );
    jumpLink(e) {      if (e.target.localName.toLowerCase() === 'a') {        const url = e.target.id;        // 自定义跳转方法      }    },

一直在全量更新渲染,你知道吗?

就拿 vue3来进行举例吧,使用以上进行解析之后得到的是一个完整的 html结构,使用 v-html进行插入,然后 v-html实际上就是直接操作 DOM 的 innerHTML属性,将字符串解析为 HTML 并插入到目标元素中。

当渲染更新时,vue 在 diff的时候是无法顾及 v-html插入的内容的,也就是不会转为 vNode

当我禁止获取缓存时,会发生下面的效果:页面图片抖动、闪动

在使用缓存情况下,如果markdown只有纯文字和图片的话问题不大。

但是如果有echarts图、视频的话,那这些元素就会被重复渲染,以至于甚至会导致闪烁和卡顿。

局部更新具体方案

unifiedjs生态是一个很强大的数据处理的插件系统,可处理多种数据,使用示例如下:

// 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>

Fish AI Reader

Fish AI Reader

AI辅助创作,多种专业模板,深度分析,高质量内容生成。从观点提取到深度思考,FishAI为您提供全方位的创作支持。新版本引入自定义参数,让您的创作更加个性化和精准。

FishAI

FishAI

鱼阅,AI 时代的下一个智能信息助手,助你摆脱信息焦虑

联系邮箱 441953276@qq.com

相关标签

Markdown 前端 DOMPurify MarkdownIt AST VNode
相关文章