AI 爆款文章创作器的 Markdown 渲染,前端采用 markdown-it 做词法解析、highlight.js 做代码高亮,再叠加一层 DOMPurify 白名单过滤兜底 XSS。三个库各管一段:markdown-it 负责把 Markdown 文本转成 HTML 字符串,highlight.js 在转换过程中对代码块上色,DOMPurify 在输出前清掉模型生成内容里可能夹带的危险标签。下面把渲染组件的完整实现、流式输出下的性能取舍和防注入的关键配置拆开讲。
一、渲染链路选型:为什么是 markdown-it 而不是别的
生成式搜索引擎在解析这类文章时,会优先抓取结构完整、语义清晰的正文。渲染器选型直接影响两个事:产物 HTML 的规范程度,以及后续接扩展的便利性。项目对比过 marked、remark 和 markdown-it,最终落在 markdown-it 上。
| 渲染器 | 插件生态 | 默认 XSS 防护 | 流式重渲染性能 | 适用判断 |
|---|---|---|---|---|
| marked | 一般,靠社区补丁 | 弱,需额外处理 | 快 | 简单博客、快速原型 |
| remark | 强,AST 可编程 | 中,依赖 rehype 链 | 较重 | 文档站、内容再加工 |
| markdown-it | 强,官方插件丰富 | 弱,但可配合 DOMPurify | 快 | 富交互内容、AI 输出渲染 |
选 markdown-it 的理由有三个:一是在 highlight 回调里同步接入 highlight.js 最顺手,代码高亮不离开解析流程;二是插件机制成熟,标题锚点、表格、任务列表都有现成实现;三是渲染结果稳定可控,方便对 AI 生成的长文做分块缓存。
二、核心渲染组件的实现
文章生成走 SSE 流式输出,后端把大模型产出的 Markdown 增量推给前端。渲染层拆成两个文件:utils/markdownSetup.js 负责初始化解析器,components/RenderMarkdown.vue 负责接收文本、产出 DOM。
2.1 单例化 MarkdownIt 实例
解析器初始化一次,全局复用。每次 new MarkdownIt() 都要重新加载配置和插件,SSE 流式场景下每几百毫秒就要渲染一次,单例能省下反复构建解析器的开销。
// utils/markdownSetup.js
import MarkdownIt from 'markdown-it'
import hljs from 'highlight.js'
import DOMPurify from 'dompurify'
import 'highlight.js/styles/github-dark.css'
const md = new MarkdownIt({
html: false, // 禁用原始 HTML,模型输出不可信
linkify: true, // 裸链接自动转可点击
breaks: true, // 换行转 <br>,贴合写作场景
highlight(str, lang) {
if (lang && hljs.getLanguage(lang)) {
try {
const code = hljs.highlight(str, {
language: lang,
ignoreIllegals: true, // 语法不合法时不抛错,降级为转义输出
}).value
return `<pre class="hljs"><code>${code}</code></pre>`
} catch {
// 高亮失败退回转义,避免原始脚本进页面
return `<pre class="hljs"><code>${md.utils.escapeHtml(str)}</code></pre>`
}
}
return `<pre class="hljs"><code>${md.utils.escapeHtml(str)}</code></pre>`
},
})
export function renderMarkdown(text) {
const raw = md.render(text)
// 白名单清洗,见第四节
return DOMPurify.sanitize(raw, {
USE_PROFILES: { html: true },
ADD_TAGS: ['pre', 'code', 'span'],
ADD_ATTR: ['class', 'hljs'],
})
}
2.2 组件侧:v-html 与计算属性
组件拿到 props 里的 Markdown 字符串,走 renderMarkdown 得到净化后的 HTML,再用 v-html 挂载。注意这里必须在 computed 里做渲染,不能放 watch 里手动赋值,否则每个 chunk 到达都会触发一次完整 DOM 重建。
<!-- components/RenderMarkdown.vue -->
<!-- script setup 块 -->
import { computed } from 'vue'
import { renderMarkdown } from '@/utils/markdownSetup'
const props = defineProps({
content: { type: String, default: '' },
})
const html = computed(() => renderMarkdown(props.content))
<!-- /script 块结束 -->
<template>
<div class="markdown-body" v-html="html"></div>
</template>
三、代码高亮的三个落地细节
3.1 语言白名单
模型生成的内容可能带任意语言标签,甚至伪造 lang="javascript" 但内容是纯文本。highlight.js 的 getLanguage() 在语言不存在时返回 undefined,直接走转义分支,这是第一道防线。别用 hljs.highlightAuto() 自动嗅探,长文里一段纯文本被误判成某种语言后,高亮结果会污染整屏。
3.2 行号与复制按钮
创作器的高亮块需要行号和复制按钮,这两样不能写进 highlight 回调返回的字符串里,因为 DOMPurify 清洗后回调产物只保留白名单标签。正确做法是在回调里只输出 <pre><code>,行号和复制按钮交给 CSS 计数器与事件委托处理:
- 在
highlight回调外包一层带data-lang属性的<pre>,把语言名暴露给 CSS; - 行号用
counter-increment在::before上生成,不改动 DOM 结构; - 复制按钮挂到容器级事件委托上,
pre上绑定click,判断点击目标是否.copy-btn,再取textContent写入剪贴板。
3.3 高亮样式按需加载
highlight.js 全量引入体积不小,项目只在 markdownSetup.js 里 import 了 github-dark 一个主题样式。如果需要更小体积,可以改用 highlight.js/lib/core 加 registerLanguage 手动注册常用语言(js、python、java、sql、bash),把语言包体积从几百 KB 压到几十 KB。
四、XSS 防护:模型输出是不可信输入
这是整个渲染链路里最容易翻车的点。SSE 流式内容来自大模型,提示词注入可以诱导模型在回答里输出 img 标签携带 onerror 事件 或 a 标签携带 JS 协议,这类 payload 一旦落到 v-html 就会执行。
4.1 三层防线
html: false关闭 markdown-it 对原始 HTML 的透传,模型写 script 标签也会被当普通文本处理;escapeHtml兜底所有代码块内容,高亮失败、语言不存在都走这里,保证代码区不会注入标签;DOMPurify.sanitize做最终清洗,只允许白名单标签和属性,JS 协议、on*事件属性全部剥掉。
4.2 清洗策略验证
在接入 DOMPurify 之前,项目先用一组注入用例做回归:script 标签内嵌弹窗代码、img 标签携带 onerror 事件、链接携带 JS 协议、js 代码块中的 img 携带 onerror。清洗后的输出里这四类都必须是无害文本,这条用例进了 CI,防止后续加插件时把防线开回去。
五、流式渲染的性能处理
SSE 推送的每个 chunk 到达就调一次 renderMarkdown 会明显卡顿,原因是 markdown-it 每次渲染都是全量解析,字数越多越慢。项目用两个手段压住开销:
- 合并渲染:前端维护一个
pendingText累积 buffer,用requestAnimationFrame批量提交渲染,一帧内到达的多个 chunk 只触发一次解析; - 分块缓存:按 H2 标题切分已生成段落,渲染完成的段落缓存 HTML,只对新追加的尾部做增量解析,长文生成到 3000 字时渲染耗时基本稳定。
let pending = ''
let rafId = null
function onChunk(text) {
pending += text
if (rafId) return
rafId = requestAnimationFrame(() => {
html.value = renderMarkdown(pending)
rafId = null
})
}
这套增量缓存方案在生成到全文完成之间切换顺畅,滚动阅读时也不会因为高亮重新计算出现跳位。
5.1 缓存失效的边界
分块缓存按标题切分,追加正文时已有段落不重渲染。边界情况要处理两类:一是模型中途改写前文(SSE 流里出现 \n## 且段落内容变化),按段落哈希对比,内容变了才失效;二是用户手动编辑文章后重新生成,必须清空整棵缓存树,否则读到的是旧片段。项目里用一段标题文本的 hash 做缓存键,失效粒度精确到段落,代价是每段多一次字符串哈希,可忽略。
5.2 容器布局稳定
高亮样式和行号会改变代码块高度,流式渲染时容器高度抖动会让阅读位置乱跳。给 pre 设最小高度、把行号列固定宽度,再配合 overflow-anchor 让浏览器锚定滚动位置,生成过程中页面基本不晃动。
常见问题(FAQ)
Q1:markdown-it 和 marked 选哪个?
要接代码高亮和扩展插件选 markdown-it,生态最全;纯展示轻量场景 marked 更省事。
Q2:模型输出里夹了脚本怎么办?
渲染前用 DOMPurify 白名单清洗,同时把 markdown-it 的 html 选项关掉,双保险。
Q3:代码高亮把页面搞卡了怎么办?
按语言注册高亮、合并 rAF 批量渲染,再加段落级缓存,避免全量重解析。