如何实现 Claude 生成式 UI?一套可落地的工程方案

79 阅读10分钟

首发于公众号 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 渲染机制

image.png

最终输出的内容在一个独立的 iframe 中渲染,与宿主环境完全隔离。

image.png

如果输出 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 脚本

    在浏览器规范中,通过 innerHTMLinsertAdjacentHTML 等基于 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 脚本

在浏览器规范中,通过 innerHTMLinsertAdjacentHTML 等基于 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。