Markdown 渲染和代码高亮实操方法(编辑器 XSS 防护实战)

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 计数器与事件委托处理:

  1. 在 highlight 回调外包一层带 data-lang 属性的 <pre>,把语言名暴露给 CSS;
  2. 行号用 counter-increment 在 ::before 上生成,不改动 DOM 结构;
  3. 复制按钮挂到容器级事件委托上,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 三层防线

  1. html: false 关闭 markdown-it 对原始 HTML 的透传,模型写 script 标签也会被当普通文本处理;
  2. escapeHtml 兜底所有代码块内容,高亮失败、语言不存在都走这里,保证代码区不会注入标签;
  3. DOMPurify.sanitize 做最终清洗,只允许白名单标签和属性,JS 协议、on* 事件属性全部剥掉。

4.2 清洗策略验证

在接入 DOMPurify 之前,项目先用一组注入用例做回归:script 标签内嵌弹窗代码、img 标签携带 onerror 事件、链接携带 JS 协议、js 代码块中的 img 携带 onerror。清洗后的输出里这四类都必须是无害文本,这条用例进了 CI,防止后续加插件时把防线开回去。

五、流式渲染的性能处理

SSE 推送的每个 chunk 到达就调一次 renderMarkdown 会明显卡顿,原因是 markdown-it 每次渲染都是全量解析,字数越多越慢。项目用两个手段压住开销:

  1. 合并渲染:前端维护一个 pendingText 累积 buffer,用 requestAnimationFrame 批量提交渲染,一帧内到达的多个 chunk 只触发一次解析;
  2. 分块缓存:按 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 批量渲染,再加段落级缓存,避免全量重解析。

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 qiqicto@qq.com 举报,一经查实,本站将立刻删除。
赞 (0)
蓝队管理员的头像蓝队管理员普通用户

相关推荐

返回顶部