手摸手带你解决AI应用开发中Markdown渲染问题

3,983 阅读3分钟

使用 Markdown-It + Vue Render 实现安全可控的 Markdown 渲染

作者:菠萝吹雪

在前端项目中,Markdown 的渲染经常使用 markdown-it。它功能丰富、插件多,但默认的渲染方式是直接生成 HTML 字符串并通过 v-html 渲染。这种方式虽然简单直观,但带来了两个严重问题:

问题一:存在 XSS 风险

<template>
  <div v-html="compiledMarkdown" />
</template>

<script>
import MarkdownIt from 'markdown-it'

export default {
  props: ['content'],
  computed: {
    compiledMarkdown() {
      const md = new MarkdownIt({ html: true })
      return md.render(this.content)
    }
  }
}
</script>

html: true 开启后,用户输入的 HTML 标签会原样输出,这意味着:

<script>alert('XSS')</script>

会直接执行,造成严重的安全隐患。

问题二:无法渲染自定义组件

使用 v-html 无法自动识别 Vue 组件。例如我们定义了一个:

<ThinkBlock status="hint">内容</ThinkBlock>

但通过 v-html 渲染后,它就是一个普通的字符串,不会解析为组件。

问题三:页面渲染不灵活

直接通过  v-html  渲染 HTML 存在以下弊端:

  1. 全量渲染无状态
    每次更新都会重新生成整个 HTML 字符串,无法追踪 DOM 状态(如表单输入值、滚动位置等),导致交互体验差。
  2. 调试困难
    渲染后的标签在 Chrome 调试工具中无法展开层级结构,难以定位和调试具体元素。
  3. 失去 Vue 响应式优势
    无法利用 Vue 的虚拟 DOM diff 算法实现增量更新(如仅更新变化的文本或样式),性能较低。

对比传统渲染与 Vue 响应式渲染

特性v-html 全量渲染Token 转 VNode 渲染
更新机制每次全量重新生成 HTML基于虚拟 DOM 增量更新
状态管理支持 Vue 组件状态绑定
调试友好性标签无法展开可在调试工具中查看组件树
性能低(全量重绘)高(仅更新变化节点)
 

优化思路(基于 Vue 响应式原理)

在将 Markdown 转换为 VNode 的渲染过程中,利用key 在 Vue 虚拟 DOM 高效更新的核心机制作用,其作用主要体现在以下三方面:

一、唯一标识节点,避免 diff 混乱

  • 场景:当列表或树状结构中的节点顺序变化时(如动态添加/删除段落),Vue 需通过 key 识别「真实 DOM 节点」与「VNode」的对应关系。
  • 示例
    // 为每个段落生成唯一 key(基于 token 索引或内容哈希)
    if (token.type === 'paragraph_open') {
      return h('p', { key: `para-${index}` }, this.renderTokens(h, token.children, `para-${index}`));
    }
    
通过  markdown-it  生成 tokens 后,利用 Vue 的渲染函数将其转换为 响应式 VNode 树:
 ```javascript
// 示例:递归生成带 key 的 VNode(避免 diff 混乱)  
renderTokens(h, tokens, parentKey = '') {  
  return tokens.map((token, index) => {  
    const key = `${parentKey}-${index}`;  
    if (token.type === 'paragraph_open') {  
      return h('p', { key }, this.renderTokens(h, token.children, key));  
    }  
    // 其他节点处理...  
  });  
}  

关键优势:

  • 响应式数据绑定(如 {{ data }} 动态渲染)
  • 组件生命周期管理(mounted/updated 钩子)
  • 条件渲染(v-if)和列表渲染(v-for

更好的做法:使用 Render 函数将 Markdown token 转换为 VNode

解决思路是:

  • 使用 markdown-it 仅生成 tokens
  • 自定义 renderTokens(h, tokens) 递归将 token 转换为 Vue 的 VNode
  • 支持自定义组件如 <think>,使用自定义 token 标记并转换为 Vue 组件

Render 渲染函数

renderTokens(h, tokens) {
  return tokens.map((token, index) => {
    if (token.type === 'think') {
      return h(ThinkBlock, {
        props: { status: token.meta.status },
        key: `think-${index}`
      }, this.renderTokens(h, md.parse(token.content, {})))
    }
    // 其他 tag 渲染略...
  })
}

样式美化:使用 GitHub Markdown CSS

推荐使用 github-markdown-css,效果好,覆盖全面:

npm install github-markdown-css
@import 'github-markdown-css/github-markdown.css';

.markdown-body {
  box-sizing: border-box;
  min-width: 200px;
  max-width: 980px;
  margin: 0 auto;
  padding: 30px;
}

配合 class="markdown-body" 应用样式。


总结

  • 使用 v-html 虽方便,但存在严重 XSS 风险和组件失效问题
  • 通过 Render 函数将 Markdown token 显式转换为 VNode,既安全又灵活
  • 支持自定义组件和结构控制,例如 <think status="hint"> 等封装结构
  • 搭配 GitHub 风格样式,实现优雅 Markdown 展示
- 千万别用 v-html 直接渲染用户输入的 Markdown
+ 用 token 转 VNode 的方式安全地构建渲染流程