首发于公众号 code进化论,欢迎关注。
前言
随着大模型能力增强,单纯的纯文本/图片输出已无法满足复杂内容的展示需求。以 Claude.ai 为代表的产品,已开始支持直接输出 HTML 并进行渐进式渲染,同时渲染出的 HTML 也可支持简单交互,从而实现更丰富的结构化内容表达与更流畅的用户体验。
本篇文章会带大家探索如何通过大模型能力生成可交互的 HTML 内容,并通过前端技术实现流式渲染,从而达到与 claude.ai 相似的效果。
Claude.ai 分析
下面通过一个图表生成的例子来探索 claude.ai 是如何实现 HTML 的流式渲染。
code-1311608451.cos.ap-guangzhou.myqcloud.com/agent%E7%94…
消息协议分析
下面是抓取的 claude.ai 返回的 sse 消息。
data: {"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":"\"\\n<div style=\\\"padding: 1rem"}}
data: {"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":" 0;\\\">\\n <h2 class=\\\"sr-only\\\">2022\\u5e74\\u81f32025\\u5e74\\u4e2d\\u56fd\\u51fa"}}
data: {"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":"\\u751f\\u4eba\\u53e3\\u67f1\\u72b6\\u56fe</h2>\\n <div style=\\\"display: flex; flex-wrap: wrap; gap: 12px; margin-bottom: 1."}}
data: {"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":"5rem;\\\">\\n <div style=\\\"background: var(--color-background-secondary); border-radius: var(--border"}}
data: {"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":"-radius-md); padding: 1rem; flex: 1; min-width: 120px;\\\">\\n <p style=\\\"font-size: 13"}}
data: {"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":"px; color: var(--color-text-secondary); margin: 0 0 4px;\\\">\\u6700\\u9ad8\\u5e74"}}
data: {"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":"\\u4efd</p>\\n <p style=\\\"font-size: 22px; font-weight: 500; margin: 0;\\\">2022"}}
data: {"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":"</p>\\n <p style=\\\"font-size: 13px; color: var(--color-text-secondary); margin: 4px 0 0;\\\">956 \\u4e07\\u4eba"}}
data: {"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":"</p>\\n </div>\\n <div style=\\\"background: var(--color-background-secondary); border-radius: var(--border-radius-md); padding: 1rem; flex: 1; min-width: 120px"}}
data: {"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":";\\\">\\n <p style=\\\"font-size: 13px; color: var(--color-text-secondary); margin: 0 0 4px;\\\">\\u6700\\u4f4e\\u5e74\\u4efd</p>\\n <p style=\\\"font-size: 22px"}}
data: {"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":"; font-weight: 500; margin: 0;\\\">2025</p>\\n <p style=\\\"font-size: 13px; color: var(--color-text-secondary); margin: 4px 0 0;\\\">954"}}
data: {"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":" \\u4e07\\u4eba</p>\\n </div>\\n <div style=\\\"background: var(--color-background-secondary); border-radius: var(--border-radius-md); padding: 1rem; flex: 1; min-width:"}}
data: {"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":" 120px;\\\">\\n <p style=\\\"font-size: 13px; color: var(--color-text-secondary); margin: 0 0 4px;\\\">\\u56db\\u5e74\\u7d2f"}}
data: {"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":"\\u8ba1</p>\\n <p style=\\\"font-size: 22px; font-weight: 500; margin: 0;\\\">3795</p>\\n <p style=\\\"font-size: 13"}}
data: {"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":"px; color: var(--color-text-secondary); margin: 4px 0 0;\\\">\\u4e07\\u4eba</p>\\n </div>\\n </div>\\n\\n <div style=\\\"display: flex; gap: 8"}}
data: {"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":"983\\u4e07\\u4eba\\u3002</canvas>\\n </div>\\n</div>\\n\\n<script src=\\\"https://cdnjs.cloudflare.com/ajax"}}
data: {"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":"/libs/Chart.js/4.4.1/chart.umd.js\\\"></script>\\n<script>\\n const isD"}}
从 sse 消息可以分析出几个关键点:
- 每条 sse 消息只输出一部分 HTML 内容,且是无规则的。
- HTML 的内容输出顺序是 style → content HTML → script ,这也是一个完整的 HTML 文档的标准格式。
HTML 渲染机制
最终输出的内容在一个独立的 iframe 中渲染,与宿主环境完全隔离。
如果输出 HTML 的过程中突然中断对话,展示的是已输出的合法的 HTML 内容,像<p style="font-size: 13 这种内容需要过滤。
难点分析
-
HTML 渲染的样式可约束
最终输出的 HTML 渲染出来的样式是可约束的,而不是大模型随机生成的,这样最终输出出来的 HTML 风格统一,质量稳定。因此需要输出一套大模型可理解的样式规范。
-
HTML 渲染不能影响宿主环境
HTML 本身可能会包含样式、脚本,直接在宿主环境渲染可能会影响宿主环境。
-
HTML 如何高效的增量渲染
因为每条 sse 消息拿到的是一小段 HTML 内容,是不完整的。如果等 HTML 内容完全输出后再渲染,等待时间会很长,整体体验也不友好,达不到预期目标。因此需要提供一套高效的增量渲染方案,在保证性能的情况下能够逐步渲染已有的内容。
-
如何过滤掉不合法的 HTML 内容
sse 输出的 HTML 内容不能保证是完整的,会出现
<p style="font-size: 13这种情况,在渲染时需要进行过滤。 -
如何执行 script 脚本
在浏览器规范中,通过
innerHTML、insertAdjacentHTML等基于 HTML 解析器的方式插入的<script>标签不会被执行,因此对于 script 脚本需要单独处理。 -
确保 script 脚本的执行顺序
script 脚本的执行可能存在依赖关系,比如绘制图表的脚本一定要等 Chart.js 加载完才能执行。
如何定制 HTML 生成规范?
HTML 生成规范可以参考开源项目 pi-generative-ui ,它是一个专为 pi(code agent)设计的插件,能够让 pi 像 claude.ai 一样输出具备一致设计风格的UI。下面展示了部分内容,详细提示词可参考 guidelines.ts 文件。
### Tokens
- Borders: always \`0.5px solid var(--color-border-tertiary)\` (or \`-secondary\` for emphasis)
- Corner radius: \`var(--border-radius-md)\` for most elements, \`var(--border-radius-lg)\` for cards
- Cards: white bg (\`var(--color-background-primary)\`), 0.5px border, radius-lg, padding 1rem 1.25rem
- Form elements (input, select, textarea, button, range slider) are pre-styled — write bare tags. Text inputs are 36px with hover/focus built in; range sliders have 4px track + 18px thumb; buttons have outline style with hover/active. Only add inline styles to override (e.g., different width).
- Buttons: pre-styled with transparent bg, 0.5px border-secondary, hover bg-secondary, active scale(0.98). If it triggers sendPrompt, append a ↗ arrow.
- **Round every displayed number.** JS float math leaks artifacts — \`0.1 + 0.2\` gives \`0.30000000000000004\`, \`7 * 1.1\` gives \`7.700000000000001\`. Any number that reaches the screen (slider readouts, stat card values, axis labels, data-point labels, tooltips, computed totals) must go through \`Math.round()\`, \`.toFixed(n)\`, or \`Intl.NumberFormat\`. Pick the precision that makes sense for the context — integers for counts, 1–2 decimals for percentages, \`toLocaleString()\` for currency. For range sliders, also set \`step="1"\` (or step="0.1" etc.) so the input itself emits round values.
- Spacing: use rem for vertical rhythm (1rem, 1.5rem, 2rem), px for component-internal gaps (8px, 12px, 16px)
- Box-shadows: none, except \`box-shadow: 0 0 0 Npx\` focus rings on inputs
### Metric cards
For summary numbers (revenue, count, percentage) — surface card with muted 13px label above, 24px/500 number below. \`background: var(--color-background-secondary)\`, no border, \`border-radius: var(--border-radius-md)\`, padding 1rem. Use in grids of 2-4 with \`gap: 12px\`. Distinct from raised cards (which have white bg + border).
### Layout
- Editorial (explanatory content): no card wrapper, prose flows naturally
- Card (bounded objects like a contact record, receipt): single raised card wraps the whole thing
- Don't put tables here — output them as markdown in your response text
按照 pi-generative-ui 官方的介绍,它的设计规范的提示词是完成从 claude.ai 中提取出来的,这一点作者已通过爬取 claude.ai 的源码验证过,因此如果大家想在自己的项目中应用这套提示词,这也是一个很好的衡量标准。
除此之外,在 pi-generative-ui 文档中也详细地讲解了 claude 实现生成式 UI 的详细步骤,作者下面要介绍的前端渲染方案就参考了里面的内容。
浏览器解析策略
前端在实现 HTML 流式渲染之前,需要先了解一下在浏览器中渲染一段 HTML 字符串时背后的策略,最简单的例子如下:
const tmp = document.createElement('div')
tmp.innerHTML = `
<div style="font-size: 13px">
HTML渲染
<p>Hello World</p>
<p style="font-size: 13
`
如果将一个不合法的 HTML 字符串通过 仍给浏览器,最终渲染出来的 DOM 树会是什么样的呢?
根据 WHATWG HTML 标准定义,浏览器在解析 HTML 时遵循 WHATWG HTML 标准中定义的解析算法,该算法本身是容错的。在解析过程中,标签结构会被自动补全,而语法错误的属性则会被直接忽略。因此,最终生成的 DOM 树往往是“修复后的结果”,而非原始字符串的直接映射。因此在浏览器中最终展示的 DOM 树如下:
如何实现 HTML 流式渲染?
Iframe 沙箱隔离
优势:
- 接入非常简单,接入方使用没有任何心智负担。
- iframe 天生具备隔离能力,无论是js、css、dom,都完全与宿主环境隔离。
缺点:
-
dom 严重割裂,弹窗只能在 iframe 内部展示,无法覆盖全局。
-
通信困难
iframe是独立的运行上下文,并且通常是以跨域的形式出现,与宿主通信困难体现在3点:
-
方式困难
仅可通过 postmessage 等方式,难以同步执行、直接调用。
-
数据结构困难
仅可传输Transferable Object。
-
效率低,内存限制大
传输数据(除sharedArrayBuffer), 均需要做structuredClone。
-
-
隐私限制
对于跨域的场景, iframe 中的代码因跨域无法获取到用户隐私信息(cookie, localstory, indexDB)等。极大限制了功能实现。iframe 也难以感知到宿主环境状态。
在对话场景下 HTML 渲染出来的页面是纯展示页面,不存在复杂的交互,不需要和宿主环境通信,因此 Iframe 已经能满足场景需求。
Morphdom 增量渲染
浏览器在解析 HTML 时具备一定的容错能力,因此在一般场景下可以直接将 HTML 字符串交由解析器处理,而无需对其进行严格的预校验。在通过 SSE 获取 HTML 内容后,最直接的渲染方式是使用 innerHTML 将其插入到页面中。然而,innerHTML 在更新内容时会整体替换原有 DOM 子树,这不仅会导致已有状态(如输入框内容、滚动位置等)丢失,还可能引发明显的重绘与闪烁问题,影响用户体验,为了解决上述问题,可以引入 morphdom 进行增量渲染。
定义
morphdom 是一个轻量级的 DOM diff 库,它通过对比当前 DOM 与目标 DOM 的差异,仅对发生变化的节点进行最小化更新,从而避免整树替换带来的性能开销和状态丢失问题。与 React 等基于虚拟 DOM 的方案不同,morphdom 并不引入额外的抽象层,而是直接在真实 DOM 上执行 diff 与 patch 操作,在保持较高性能的同时简化了整体实现复杂度。
基本使用
使用 morphdom 非常简单。以下是一个基本示例,演示如何将一个 DOM 元素转换为另一个:
var morphdom = require('morphdom');
// 创建初始元素
var el1 = document.createElement('div');
el1.className = 'foo';
// 创建目标元素
var el2 = document.createElement('div');
el2.className = 'bar';
// 将 el1 转换为匹配 el2
morphdom(el1, el2);
// el1 现在拥有类 'bar'
console.log(el1.className); // 'bar'
除了传递 DOM 元素,也可以传递 HTML 字符串作为目标:
var morphdom = require('morphdom');
var container = document.getElementById('my-container');
container.innerHTML = '<div class="old-content">Hello World</div>';
// 用新内容更新容器
morphdom(container, '<div class="new-content">Hello Morphdom</div>');
这个就和当前的场景非常类似,但是这里有一个核心功能需要重点关注,当传递 HTML 字符串作为目标时,morphdom 会先调用 DOM API 将字符串转换为 DOM 元素,源码如下:
function morphdom(fromNode, toNode, options) {
if (!options) {
options = {};
}
if (typeof toNode === 'string') {
var toNodeHtml = toNode;
toNode = doc.createElement('html');
toNode.innerHTML = toNodeHtml;
}
//...
}
这也就是说 morphdom 也是支持传递不合规的 HTML 字符串,因为内部也会先通过浏览器的解析算法进行容错处理并转换为 DOM 元素。
手动执行 Script 脚本
在浏览器规范中,通过 innerHTML、insertAdjacentHTML 等基于 HTML 解析器的方式插入的 <script> 标签不会被执行,因此需要等 HTML 内容生成完之后手动获取脚本并执行。
const runScripts = async (root: HTMLElement): Promise<void> => {
const scripts = Array.from(root.querySelectorAll("script"));
for (const old of scripts) {
const s = root.ownerDocument.createElement("script");
if (old.src) {
// 外部脚本:等待加载完成再继续
await new Promise<void>((resolve, reject) => {
s.src = old.src;
s.onload = () => resolve();
s.onerror = () => reject(new Error(`Failed to load: ${old.src}`));
old.parentNode!.replaceChild(s, old);
});
} else {
s.textContent = old.textContent;
old.parentNode!.replaceChild(s, old);
}
}
}
runScripts 方法里展示了 script 执行的大概流程,总结如下:
- 获取所有 script 元素并遍历。
- 通过 DOM API 创建并插入新的 script 元素。
- 如果是加载外部脚本,需要等加载完毕之后再执行后面的脚本。
Demo
code-1311608451.cos.ap-guangzhou.myqcloud.com/agent%E7%94…
这个例子的 sse 数据直接用的 claude.ai 的,可以自己写个简单的 server。