项目:markdown-renderer-fix
作者:Yardon
GitHub:https://github.com/YardonYan/markdown-renderer-fix
在线演示:https://yardonyan.github.io/markdown-renderer-fix/
版本:v5.0.0(2026-05-06)


前言

如果你做过大模型应用开发,一定遇到过这些让人抓狂的问题:

  • 用户问"你好",流式输出却显示成 ���
  • Markdown 表格渲染成一团乱麻
  • 代码块没有复制按钮,用户只能手动选中
  • 数学公式和 Mermaid 图表死活不显示
  • 工具调用的内部输出泄露到用户界面

这些问题表面简单,实际涉及前后端 8 个数据阶段的任何一个环节出错。我在多次实战修复中积累了经验,最终决定把这些沉淀为一个可复用的 OpenClaw Skill——markdown-renderer-fix

这篇文章记录了这个 Skill 从 v1.0 到 v5.0 的完整进化过程,以及每个版本背后的设计思考。


v1.0:解决"有没有"的问题

时间:2026-04-30
核心目标:让 Skill 能跑起来

v1.0 是一个最小可用版本,包含:

  • SKILL.md:基础的决策树和快速修复表
  • assets/chat_template.html:一个能用的聊天界面模板
  • scripts/diagnose_encoding.py:简单的编码诊断脚本
  • references/:8 份参考文档

v1.0 的痛点

虽然能工作,但问题很明显:

  1. XSS 漏洞container.innerHTML = marked.parse(text) 没有任何净化
  2. 无障碍缺失:没有 ARIA 属性,屏幕阅读器无法使用
  3. 性能粗糙:每次 SSE 消息都全量重新渲染 Markdown
  4. 代码质量:inline onclick、无错误边界、无清理机制

举个例子:v1.0 的 chat_template.html 中,用户输入 <script>alert('XSS')</script> 会直接执行。这在生产环境是不可接受的。


v2.0:安全与工程化

时间:2026-05-05
核心目标:让 Skill 达到生产可用

v2.0 是一次彻底的工程化重构,重点解决 v1.0 的安全和质量问题。

1. XSS 防护:DOMPurify 全链路净化

v1.0 的问题

// 危险!直接插入未净化的 HTML
container.innerHTML = marked.parse(text);

v2.0 的修复

// 安全:先净化再插入
const raw = marked.parse(text);
const clean = DOMPurify.sanitize(raw, {
  ALLOWED_TAGS: ['h1','h2','h3','p','br','ul','ol','li',
    'strong','em','a','code','pre','blockquote','table',
    'thead','tbody','tr','th','td','img','span','div','hr','input'],
  ALLOWED_ATTR: ['href','src','class','id','target','alt','title',
    'type','checked','disabled'],  // v3 新增任务列表支持
  ALLOW_DATA_ATTR: false
});
container.innerHTML = clean;

更深一层:过滤危险协议

DOMPurify 默认不过滤 javascript: 协议链接。v2.0 添加了钩子:

DOMPurify.addHook('afterSanitizeAttributes', function(node) {
  if (node.hasAttribute('href')) {
    const href = node.getAttribute('href').trim().toLowerCase();
    if (href.startsWith('javascript:') || href.startsWith('data:') ||
        href.startsWith('vbscript:')) {
      node.removeAttribute('href');
    }
  }
});

这样 [click me](javascript:alert('XSS')) 会变成纯文本链接,无法执行恶意代码。

2. 事件委托:从 inline onclick 到 CSP 兼容

v1.0 的问题

<!-- inline onclick 违反 CSP,且每个按钮都要绑定 -->
<button onclick="copyCode(this)">复制</button>

v2.0 的修复

// 事件委托:在父容器上统一监听
document.getElementById('chatFlow').addEventListener('click', function(e) {
  const btn = e.target.closest('[data-action]');
  if (!btn) return;
  
  const action = btn.dataset.action;
  switch(action) {
    case 'copy-code': copyCode(btn); break;
    case 'copy-msg': copyMsg(btn); break;
    case 'regenerate': regenerateMsg(btn); break;
  }
});

HTML 改为:

<button data-action="copy-code" data-code="...">复制</button>

这样即使启用严格的 CSP(禁止 'unsafe-inline'),功能依然正常。

3. 无障碍支持:ARIA + 键盘快捷键

v2.0 新增

  • role="log" + aria-live="polite":屏幕阅读器自动播报新消息
  • aria-atomic="false":只播报变更部分,不重复整段
  • 键盘快捷键:Enter 发送、Shift+Enter 换行、Escape 取消流式输出
  • prefers-reduced-motion:尊重用户的减少动画偏好
<!-- v2.0 的消息容器 -->
<div id="chatFlow" role="log" aria-live="polite" aria-atomic="false">
  <article class="message" role="article" aria-label="助手消息">
    <div class="content" role="region"></div>
  </article>
</div>

4. 增量渲染:从 O(n²) 到 O(n)

v1.0 的问题:每次 SSE 消息都重新渲染整个 Markdown,导致卡顿。

v2.0 的修复

// 增量渲染:80 字符阈值以下直接追加,以上才重新解析
function renderMarkdown(text, options = {}) {
  const { incremental = false } = options;
  
  if (incremental && text.length < 80) {
    // 小文本:直接追加,不重新解析
    return document.createTextNode(text);
  }
  
  // 大文本:完整解析
  const raw = marked.parse(text);
  return DOMPurify.sanitize(raw, { /* ... */ });
}

5. AbortController:优雅的异步清理

v2.0 新增

const abortCtl = new AbortController();

// 请求时传入 signal
fetch('/api/chat/stream', { signal: abortCtl.signal });

// 用户点击"停止"或切换对话时取消
abortCtl.abort();

// 清理 setTimeout,避免内存泄漏
const timerIds = [];
function safeSetTimeout(fn, delay) {
  const id = setTimeout(fn, delay);
  timerIds.push(id);
  return id;
}
// 取消时:timerIds.forEach(clearTimeout);

v3.0:性能与细节打磨

时间:2026-05-05(v2.0 同一天)
核心目标:让 Skill 更快、更稳、更完善

1. 图片懒加载

// v3.0:Markdown 图片自动添加懒加载
const renderer = new marked.Renderer();
renderer.image = function(href, title, text) {
  return `<img src="${href}" alt="${text}" loading="lazy" decoding="async" 
    style="max-width:100%;height:auto;">`;
};
marked.use({ renderer });

2. CDN 降级检测

v3.0 新增:DOMContentLoaded 时同步检测 CDN 可用性,无需等待 3 秒超时。

// 检测 CDN 是否加载成功
window.addEventListener('DOMContentLoaded', () => {
  if (window.MARKED_FAIL || window.HLJS_FAIL) {
    showDegradationBanner('CDN 加载失败,部分功能不可用');
  }
});

3. 错误边界

// KaTeX 渲染失败时不阻断整个页面
try {
  renderMathInElement(el, { throwOnError: false });
} catch (e) {
  console.warn('KaTeX render failed:', e);
  // 保留原始文本,不显示空白
}

// Mermaid 同理
try {
  await mermaid.run({ querySelector: '.mermaid' });
} catch (e) {
  console.warn('Mermaid render failed:', e);
}

v4.0:架构优化与审计修复

时间:2026-05-05
核心目标:修复审计发现的问题,优化架构

1. 修复 regenerateMsg 重复消息 Bug

问题:重新生成时,旧消息没有被替换,而是追加了新消息。

修复:传入 replaceTarget 参数,明确指定替换目标。

function regenerateMsg(btn, replaceTarget) {
  // 删除旧消息
  if (replaceTarget) {
    replaceTarget.remove();
  }
  // 重新发送请求...
}

2. cleanToolOutput O(n) 优化

v3.0 的问题:流式阶段每次都用正则过滤,O(n²) 复杂度。

v4.0 的修复

// 增量检查:只检查新增部分是否包含工具调用标记
function cleanToolOutput(text) {
  const toolPatterns = [
    /<function_calls?[\s\S]*?<\/function_calls?>/gi,
    /<invoke[\s\S]*?<\/invoke>/gi,
    /<tool_call[\s\S]*?<\/tool_call>/gi,
    /<|tool_call_begin|[\s\S]*?<|tool_call_end|>/gi,
  ];
  
  let cleaned = text;
  for (const pattern of toolPatterns) {
    cleaned = cleaned.replace(pattern, '');
  }
  return cleaned;
}

3. 中文正则收窄

问题:正则匹配过宽,可能误伤正常内容。

修复:改为行首模式匹配,更精确。

4. 模拟流式打字机效果

v4.0 为 demo.html 添加了模拟 SSE 流式效果,方便离线演示:

// 两阶段流式:前 200 字符逐字显示,之后增量渲染
function simulateStream(text, container) {
  let i = 0;
  const plainPhase = 200; // 前 200 字符纯文本
  
  function tick() {
    if (i >= text.length) return;
    
    if (i < plainPhase) {
      // 阶段 1:逐字符追加
      container.textContent += text[i];
      i++;
      setTimeout(tick, 25 + Math.random() * 20);
    } else {
      // 阶段 2:增量 Markdown 渲染
      const chunk = text.slice(i, i + 5);
      container.innerHTML = renderMarkdown(text.slice(0, i + 5));
      i += 5;
      setTimeout(tick, 180);
    }
  }
  
  tick();
}

v5.0:完善与发布

时间:2026-05-06
核心目标:完善文档,准备公开发布

1. 双语文档

所有 12 份文档统一添加双语头部:

# 中文 / English

> 🇬🇧 EN: Description in English
> 🇨🇳 ZH: 中文描述

2. 新增文档

文档 内容
accessibility.md ARIA、键盘快捷键、色对比度
browser_support.md 浏览器兼容矩阵、CDN 可用性
CLAUDE_CODE_PROMPT.md Claude Code 提示词设计规范

3. GitHub 发布准备

  • LICENSE:MIT 许可证
  • .gitignore:Python、macOS、Windows、IDE
  • README.md:完整的中文文档,含截图占位
  • GitHub Pages:自动部署在线演示

4. 第七轮审计修复

v5.0 进行了 7 轮审计,修复了 50+ 个问题,包括:

  • P0(严重):XSS 防护、死锁修复、函数未定义
  • P1(重要):Clipboard 降级、setTimeout 清理、错误边界
  • P2(一般):暗黑模式、响应式表格、文档同步

核心设计哲学

1. 8 阶段数据流视角

任何编码或渲染问题,都从 8 个阶段逐一排查:

后端: tiktoken 解码 → Python str → json.dumps 转义 → .encode('utf-8') → HTTP 传输
前端: reader.read() → TextDecoder → JSON.parse → marked.parse()

2. 防御性编程

  • XSS:DOMPurify + 协议过滤 + CSP
  • 性能:增量渲染 + 节流 + 懒加载
  • 兼容:CDN 降级 + 浏览器检测 + polyfill
  • 无障碍:ARIA + 键盘 + 减少动画

3. 实战优先

所有方案都来自真实问题:

  • 锟斤拷 乱码 → GBK/UTF-8 混用章节
  • ��� 乱码 → tiktoken 单 token 解码章节
  • 工具调用泄露 → cleanToolOutput() 函数
  • 代码块无复制 → 自动注入复制按钮

使用示例

快速诊断

# 全面扫描
python scripts/diagnose_encoding.py --full

# 测试实际 SSE 端点
python scripts/diagnose_encoding.py --real-sse --endpoint http://127.0.0.1:18765/api/chat/stream

# 检查 CDN 依赖
python scripts/diagnose_encoding.py --deps

前端模板使用

<!-- 直接复制 chat_template.html,修改 SSE 端点即可 -->
<script>
const SSE_ENDPOINT = 'http://your-api.com/chat/stream';
</script>

框架适配

// React 示例
import DOMPurify from 'dompurify';
import { marked } from 'marked';

function MarkdownRenderer({ content }) {
  const html = DOMPurify.sanitize(marked.parse(content));
  return <div dangerouslySetInnerHTML={{ __html: html }} />;
}

未来展望

  1. v5.1:添加 WebSocket 支持、更完善的测试覆盖
  2. v6.0:支持更多 Tokenizer(Claude、Gemini)、WebRTC 实时协作
  3. 生态:社区贡献的框架适配、更多主题配色

结语

从 v1.0 到 v5.0,markdown-renderer-fix 从一个简单的修复脚本,成长为一个覆盖前后端全链路、包含 11 份参考文档、支持 4 个前端框架、具备完整安全防护措施的生产级 Skill。

这个过程中,我深刻体会到:工程化不是炫技,而是把每一个细节都做对。XSS 防护、错误边界、异步清理、无障碍支持——这些看似"额外"的工作,恰恰是区分"能跑"和"能用"的关键。

如果你也在做大模型应用开发,希望这个 Skill 能帮你少走弯路。


GitHub:https://github.com/YardonYan/markdown-renderer-fix
在线演示:https://yardonyan.github.io/markdown-renderer-fix/
许可证:MIT


Logo

小龙虾开发者社区是 CSDN 旗下专注 OpenClaw 生态的官方阵地,聚焦技能开发、插件实践与部署教程,为开发者提供可直接落地的方案、工具与交流平台,助力高效构建与落地 AI 应用

更多推荐