从 v1.0 到 v5.0:一个 OpenClaw Skill 的进化之路
文章摘要(149字): 该项目"markdown-renderer-fix"是解决大模型应用中Markdown渲染痛点的工具集,历经5个版本迭代。v1.0实现基础功能但存在XSS漏洞;v2.0通过DOMPurify净化、事件委托优化和无障碍支持达到生产可用;v3.0进一步提升性能,采用懒加载和CDN降级检测;后续版本持续优化错误处理与交互体验。该项目完整记录了从基础功能到工程化
项目: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 的痛点
虽然能工作,但问题很明显:
- XSS 漏洞:
container.innerHTML = marked.parse(text)没有任何净化 - 无障碍缺失:没有 ARIA 属性,屏幕阅读器无法使用
- 性能粗糙:每次 SSE 消息都全量重新渲染 Markdown
- 代码质量: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、IDEREADME.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 }} />;
}
未来展望
- v5.1:添加 WebSocket 支持、更完善的测试覆盖
- v6.0:支持更多 Tokenizer(Claude、Gemini)、WebRTC 实时协作
- 生态:社区贡献的框架适配、更多主题配色
结语
从 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
更多推荐




所有评论(0)