JSON Schema驱动的Generative UI:React组件动态渲染实战
1. 整体设计与思路拆解
1.1 它到底解决什么问题
最近在做 AI 应用落地,发现一个特别容易被低估的环节:LLM 已经把意图理解清楚了,该调的接口也调了,但最后一步——把动态内容变成用户界面——反而成了最容易翻车的地方。
你当然可以用预设模板:AI 识别出“天气查询”,前端就渲染天气卡片。但真实场景远比这个复杂。用户让 AI “做一个三栏对比表格,附带两个图表,再加一个折叠面板”,模板怎么写?模板写不出来,因为组合是无限的。
Vercel Labs 的 json-render 就是冲着这个场景来的。它的思路很直接:用 JSON 描述界面结构,前端拿到 JSON 后动态渲染成 React 组件树。LLM 只负责生成 JSON,界面渲染完全由前端运行时接管。这就是 Generative UI 的工程化雏形——不是让 AI 写代码,而是让 AI 写数据。
这个方案适合谁来参考?如果你在做 LLM 原生应用、Agent 工作台、低代码页面动态化,或者想实现 server-driven UI(服务端下发界面配置),json-render 这套设计都会很有价值。它要解决的核心命题不是“能不能把 JSON 变成组件”——这个三天就能写出来——而是:这个过程中怎么保证类型安全、怎么应对非法结构、怎么让业务组件无缝注入、怎么在不可信输入面前不出安全事故。
1.2 为什么是 JSON,而不是代码生成
一开始我也想过另一条路:让 LLM 直接生成 TSX 代码片段,前端动态编译执行。方向上听着更“智能”,但工程上全是坑。
第一是安全。动态执行 LLM 生成的代码,等于把 XSS 从“看看能不能绕过过滤”升级成“欢迎直接注入”。你需要沙箱、需要白名单、需要处理各种边界情况。而 JSON 是数据,天然没有执行能力,只要做好 schema 校验,风险就低一个量级。
第二是可控性和可迭代性。代码生成结果是字符串,不好做结构化校验;JSON 则可以用 JSON Schema 或 zod 做严格约束。渲染出错时,你可以把校验错误信息原样返回给 LLM 让它自我修正,这个“校验反馈闭环”是 Generative UI 工程化里最核心的机制之一。
第三是跨端能力。React、Vue、小程序、iOS、Android 都能消费同一份 JSON 描述,只是各自实现渲染层。json-render 的价值不只是“把 JSON 渲染成 React”,而是定义了一种跨端通用的界面协议。如果你以后要接移动端,核心 schema 不用改。
需要强调一点:json-render 的设计并不是传统意义上“数据驱动 UI”的简单复刻。它的关键是引入了一层“组件注册表”(registry),让核心渲染器与具体业务组件完全解耦。渲染器不认识任何业务组件,只认识name字符串。这种面向协议的设计,让它天然适合 AI 生成场景——AI 只需要知道你注册了哪些组件,以及它们的 props 长什么样。
1.3 三条核心设计原则
我读源码时,花了很长时间才从“它写了什么”里跳出来,看到“它为什么这么写”。概括下来是三条原则。
原则一:结构即数据。界面是一棵树,每个节点要么是基础节点(文本、标题、列表),要么是组件节点(引用了注册表里的组件)。树本身就是纯 JSON,可以被序列化、存储、回放。这意味着 AI 生成的结果可以被记录、审计,出问题可以直接定位到具体节点。
原则二:渲染与组件解耦。核心渲染器只做一件事:遍历节点,找到对应组件,把 props 传进去。它不认识业务组件,不直接依赖任何 UI 库。新增一种组件不需要改渲染器,只需要注册。这个边界让核心代码可以保持稳定和极度精简。
原则三:约束优先于能力。很多人以为 Generative UI 应该“什么都能生成”,恰恰相反,真正工程化的思路是“限制能生成什么”。组件注册表本身就是约束,props 的 schema 定义也是约束。约束越强,LLM 生成的稳定性越高,渲染成功率越好。行业内现在普遍认同 2026 年是智能体从概念演示走向工程化落地的分水岭,而工程化的意思,就是给想象力装上可控的轨道。
2. 核心细节解析与实操要点
2.1 数据模型:UI 描述即树
json-render 里最难理解也最值得抄的数据结构,是它的节点类型定义。我用 TypeScript 重新表达一下源码里的核心模型:
type SchemaNode = | { type: 'text'; value: string } | { type: 'heading'; level: 1 | 2 | 3; children: SchemaNode[] } | { type: 'paragraph'; children: SchemaNode[] } | { type: 'list'; ordered: boolean; items: SchemaNode[] } | { type: 'component'; name: string; props: Record<string, unknown>; children?: SchemaNode[] } | { type: 'fragment'; key: string; children: SchemaNode[] }这套设计的精巧之处,是把“基础结构”和“业务组件”做了明确的区分。text、heading、list这些是平台级节点,任何场景都通用,由渲染器原生实现;component节点则只携带name和props,渲染器不认识它,只负责去找注册表匹配。
这里有个关键设计取舍:props 的类型是Record<string, unknown>。为什么不用具体的 TypeScript 类型?因为 JSON 从 network 或 LLM 过来时,本身就是 unknown。源码选择在运行时再做校验,而不是假装静态类型能保护运行时数据。这是很务实的做法——你可以用 zod 来校验每个组件的 props,但核心渲染器本身保持宽容。
另外一个容易忽略的细节是key字段。递归渲染列表时,没有稳定 key 会导致 React 状态错乱。我会在第四章细讲这个坑,这里先记住结论:凡是 children 数组里的节点,都应该生成或要求携带唯一 key。
2.2 组件注册表:整个库的心脏
注册表是 json-render 最值得抄的部分。它的本质非常简单:一个name -> component definition的映射表。
export interface ComponentDefinition { name: string component: React.ComponentType<any> schema?: JSONSchema // 用于校验 props loading?: React.ComponentType fallback?: React.ComponentType } export class Registry { private items = new Map<string, ComponentDefinition>() register(defs: ComponentDefinition[]) { for (const def of defs) { if (this.items.has(def.name)) { throw new Error(`[json-render] Duplicate component: ${def.name}`) } this.items.set(def.name, def) } } get(name: string): ComponentDefinition | undefined { return this.items.get(name) } }为什么用字符串名字寻址,而不是直接传组件引用?两个原因。
第一,LLM 是文本生成模型,字符串是它最擅长输出的 token 序列。你让 AI 输出一个组件引用,它是做不到的;但你让它输出"chart"或"form",稳定性极高。字符串名字就是 AI 与运行时之间的协议。
第二,延迟解析。在实际场景里,组件包可能是异步加载的(code splitting),name -> definition的映射天然支持懒加载——注册表里可以先存一个 loader 函数,真正渲染时才加载组件代码。源码里虽然没做微内核级别的动态加载,但这个数据结构完全能承载这样的扩展。
自定义组件时,最容易被忽略的是fallback。AI 生成的 JSON 里可能引用了未注册的组件,此时不要直接抛异常把整个界面炸掉,而是渲染一个统一的“未找到组件”占位,并附带错误信息。我实际落地时还会在 fallback 里显示一个“重试”按钮,把错误上下文返回给 LLM,让 AI 自己修正 schema。
2.3 渲染器:递归才是核心算法
渲染器是 json-render 的运行时主循环。核心代码可以精简为这样一个递归函数:
function JsonRender({ root, registry }: { root: SchemaNode | SchemaNode[]; registry: Registry }) { if (Array.isArray(root)) { return <>{root.map((node, i) => renderNode(node, registry, `arr-${i}`))}</> } return <>{renderNode(root, registry, 'root')}</> } function renderNode(node: SchemaNode, registry: Registry, key: string): React.ReactNode { switch (node.type) { case 'text': return node.value case 'heading': return <Heading level={node.level}>{node.children.map((c, i) => renderNode(c, registry, `${key}-h-${i}`))}</Heading> case 'paragraph': return <p>{node.children.map((c, i) => renderNode(c, registry, `${key}-p-${i}`))}</p> case 'list': return <List ordered={node.ordered}>{node.items.map((c, i) => <ListItem key={`${key}-li-${i}`}>{renderNode(c, registry, `${key}-li-${i}`)}</ListItem>)}</List> case 'component': { const def = registry.get(node.name) if (!def) return <MissingComponent name={node.name} /> const Comp = def.component const children = node.children?.map((c, i) => renderNode(c, registry, `${key}-c-${i}`)) const props = { ...node.props, key, children } return <Comp {...props} /> } default: return <UnknownNode node={node} /> } }注意几个细节。
第一,这个函数是纯函数——同样的 node 输入必然得到同样的输出。这让它天然适合 SSR 和测试。渲染期间不产生副作用,副作用应该放在事件回调里。
第二,key生成使用的是从根到节点的完整路径,比如root-c-0-c-1。这条路是我踩过几次坑后刻意采用的。如果使用数组 index 直接做 key,当 AI 生成内容前后两次发生变化时,React 会复用错误组件的状态。路径 key 可以保证每个节点的身份是稳定的。
第三,递归要有边界。AI 生成的 JSON 完全可能有环(虽然 JSON 本身不支持循环引用,但嵌套深度可以非常大)。源码里建议加一个maxDepth配置,超过深度上限时渲染一个截断提示,而不是让 JS 引擎栈溢出。
2.4 action:让生成的界面“能动”
如果只是展示数据,这个库的价值会少一大半。真正的 Generative UI 必须支持交互——用户点击按钮、提交表单、切换 Tab,这些动作要能触发后续逻辑。
json-render 对交互的处理方式很优雅:UI 只负责声明意图,不负责实现。交互通过action机制透传:
type ComponentNode = { type: 'component' name: string props: Record<string, unknown> children?: SchemaNode[] // 交互描述 actions?: { event: 'onClick' | 'onSubmit' | 'onChange' handler: string payload?: Record<string, unknown> }[] }外层调用时,JsonRender接受一个onAction回调,统一处理所有事件:
<JsonRender root={uiSchema} registry={registry} onAction={async ({ handler, payload }) => { // 这里路由到对应的业务逻辑 const result = await handleAction(handler, payload) setUiSchema(result) // 用新的 schema 更新界面 }} />这个设计让我想到一个比喻:界面是“提案”,业务方是“决策者”。UI 组件只报告“用户点了按钮、附带了这些数据”,具体做什么由上层逻辑决定。这样做的好处是:actions 可以被序列化、被记录、被回放,整个交互链路是可追踪的;而且 Action 层可以做成不受信任的 UI 与受信任的后端逻辑之间的安全边界。
3. 实操过程与核心环节实现
3.1 从零搭建最小骨架
纸上谈兵没有意义,我带你完整走一遍实操过程。目标是搭建一个最简、可运行的 json-render 核心,不依赖任何 UI 库。
第一步,创建项目并安装依赖:
npm create vite@latest json-render-demo -- --template react-ts cd json-render-demo npm install zod第二步,定义 schema 类型。为了聚焦核心,我先用上面说到的 SchemaNode 类型。实际为了演示定位,我加一个简单的卡片组件节点:
type SchemaNode = | { type: 'text'; value: string } | { type: 'component'; name: string; props: Record<string, unknown>; children?: SchemaNode[] }第三步,实现最小注册表和渲染器。为了讲解清晰,我暂时把渲染器和注册表放在一个文件里:
// json-render.tsx import React from 'react' interface ComponentDefinition { name: string component: React.ComponentType<any> } class Registry { private items = new Map<string, ComponentDefinition>() register(def: ComponentDefinition) { this.items.set(def.name, def) } get(name: string) { return this.items.get(name) } } function renderNode(node: SchemaNode, registry: Registry, key: string): React.ReactNode { switch (node.type) { case 'text': return node.value as React.ReactNode case 'component': { const def = registry.get(node.name) if (!def) return <span key={key} style={{ color: 'red' }}>[未知组件: {node.name}]</span> const Comp = def.component const children = node.children?.map((c, i) => renderNode(c, registry, `${key}-${i}`)) return <Comp key={key} {...node.props}>{children}</Comp> } } } export function JsonRender({ root, registry }: { root: SchemaNode | SchemaNode[]; registry: Registry }) { const nodes = Array.isArray(root) ? root : [root] return <>{nodes.map((n, i) => renderNode(n, registry, `root-${i}`))}</> }第四步,注册一个真实的业务组件并渲染:
// App.tsx import { JsonRender, Registry } from './json-render' function UserCard({ name, email, children }: any) { return ( <div style={{ border: '1px solid #ddd', borderRadius: 8, padding: 12 }}> <h3>{name}</h3> <p>{email}</p> {children} </div> ) } const registry = new Registry() registry.register({ name: 'UserCard', component: UserCard }) const uiSchema: SchemaNode[] = [ { type: 'component', name: 'UserCard', props: { name: '张三', email: 'zhangsan@example.com' } }, { type: 'text', value: '欢迎使用 json-render' } ] function App() { return <JsonRender root={uiSchema} registry={registry} /> }到这里,一个可运行的 json-render 就完成了。运行npm run dev,你能看到卡片和文本被正确渲染。整个过程不到半小时,核心代码只有几十行。
3.2 接入真实业务组件与 props 校验
骨架搭好后,下一步是把这套东西接到你的业务里。真实业务组件通常有强类型 props,AI 生成的 JSON 里字段类型很可能不匹配。这时候 zod 就派上用场了。
每个组件注册时带一个 props 校验器,渲染时先校验再传值:
import { z } from 'zod' const UserCardSchema = z.object({ name: z.string().min(1), email: z.string().email().default('未填写'), level: z.enum(['admin', 'user']).default('user') }) export const userCardDef = { name: 'UserCard', component: UserCard, validate(props: unknown) { return UserCardSchema.safeParse(props) } }渲染器里这样用:
case 'component': { const def = registry.get(node.name) if (!def) return <Warning>未知组件</Warning> if (def.validate) { const result = def.validate(node.props) if (!result.success) { return <Warning>props 校验失败: {result.error.message}</Warning> } return <Comp {...result.data}>{children}</Comp> } return <Comp {...node.props}>{children}</Comp> }这里有几个重要的工程细节。
细节一:校验后要使用解析后的数据,而不是原始数据。safeParse的结果会把默认值、类型转换都处理好。直接用node.props会把未校验的脏数据传给组件,等于校验了个寂寞。
细节二:校验失败不要渲染组件!这是新手最容易犯的错误——校验失败后只是 console.error 一下,然后继续渲染。真实生产环境里,错误 props 可能让组件直接崩溃。正确的做法是渲染一个结构化错误提示,并把这个错误信息返回给上层(如果是从 LLM 生成的话,就能让 AI 看到错误原因并自我修正)。
我还见过一个比较激进的方案:组件 props 不单独校验,而是把整个 schema 的组件引用关系也纳入校验——引一个组件前先确认这个 name 是否存在、props 是否符合注册时声明的 JSON Schema。相当于在渲染前做一次“编译期检查”。这种方式的好处是错误能提前暴露,不用等 React 渲染开始。我在生产环境里用的是两层校验结合:渲染前做整体 schema 校验,渲染时对每个组件做局部 props 校验。
3.3 端到端:让 LLM 的 JSON 走完整个链路
这一步是 json-render 真正发挥威力的场景。我描述一下我实际搭建的完整链路,你可以直接照搬。
整体链路是:用户输入 -> LLM 生成 JSON(结构化输出) -> schema 校验 -> json-render 渲染 -> 用户点击触发 action -> 后端结果再转成 JSON 更新界面。
LLM 输出部分,我用的是 OpenAI 的 function calling(或者任意支持 JSON mode 的模型)。关键是定义一个 output schema,强制模型输出符合 json-render 节点格式的内容:
{ "name": "generate_ui", "description": "根据用户需求生成 UI 描述", "parameters": { "type": "object", "properties": { "root": { "type": "array", "items": { "type": "object", "properties": { "type": { "enum": ["text", "heading", "paragraph", "list", "component"] }, "name": { "type": "string", "description": "组件名称,必须是注册表中已存在的组件" }, "props": { "type": "object" }, "children": { "type": "array", "items": { "$ref": "#/definitions/SchemaNode" } } }, "required": ["type"] } } }, "required": ["root"] } }这个 JSON Schema 本身很关键。它把 AI 的输出约束在 json-render 能处理的范围内。千万不要让 LLM 自由发挥结构。generation 阶段的约束越强,运行时的防御越轻松。
拿到 LLM 返回的 JSON 后,进入渲染前的校验环节。这里我用 ajv 做整体 schema 校验(JSON Schema 原生支持递归定义),再用 zod 做组件 props 校验:
function validateGeneratedUI(json: unknown): { ok: true; data: SchemaNode[] } | { ok: false; errors: string[] } { const ajv = new Ajv({ allErrors: true, strict: false }) const validate = ajv.compile(uiSchema) if (!validate(json)) { return { ok: false, errors: validate.errors!.map(e => `${e.instancePath} ${e.message}`) } } return { ok: true, data: json as SchemaNode[] } }校验失败的场景,我把所有错误信息拼接成一条消息,回传给 LLM:
const result = validateGeneratedUI(llmOutput) if (!result.ok) { const fixPrompt = `你生成的 JSON 违反了 UI 协议,请修正后再试。错误信息:${result.errors.join('; ')}` // 重新调用 LLM }这个“校验失败 -> 错误回灌 -> 重新生成”的闭环,是我认为 Generative UI 工程化里最核心的控制手段。它能自动修复大部分低级的 schema 错误(字段名拼错、类型不对、引用了不存在的组件名),大幅提升生成成功率。
3.4 类型安全、SSR 与测试策略
这一节是工程化升级的必选题。从一个“能跑的 demo”变成一个“能上线”的模块,需要补三块拼图。
类型安全部分。json-render 的价值是让 AI 生成界面,但应用本身的 TS 类型不能丢。我的做法是写一个 codegen 脚本,扫描注册表,自动生成所有组件的 props 类型定义:
// 假设有个 scripts/registry.ts 脚本,生成了以下类型 export type UserCardProps = { name: string email?: string level?: 'admin' | 'user' }生成的类型和注册表里的 components 一一对应,这样你写业务代码时还是完全类型安全的。我自己是在 CI 里加了一个 check,任何注册表的变更都会重新生成类型,如果类型不匹配就报错。
SSR 部分。这是比较容易踩坑的点。json-render 的数据是纯 JSON,本来很利于 SSR,但坑在于:组件可能在useEffect里获取数据、依赖window等浏览器对象。AI 生成的组件必须遵守一条纪律——渲染期间不要有副作用,不要直接访问浏览器全局对象。否则 SSR 和 hydration 时会出现内容不一致。我在实践中给组件注册表加了一个元信息字段ssr: boolean,标注哪些组件只能在客户端渲染。服务端渲染时遇到非 SSR 组件,先渲染一个占位,客户端再自行挂载。
测试部分。json-render 有一个很大的优势:因为输入是 JSON,输出是组件树,测试可以写成快照形式。我给生产项目写了几十组黄金文件测试(golden test):每组 JSON schema 配一张渲染结果的截图,跑测试时做视觉回归。这比写组件单元测试快得多,而且能防止不小心破坏了某个 schema 结构后没人发现。核心渲染器本身也可以做纯单测:给定 mock registry 和 schema,断言渲染结果包含哪些元素。
4. 常见问题与排查技巧实录
4.1 问题速查表
实操过程中我遇到过的典型问题,整理成一张速查表,按我个人踩坑频率排序:
| 现象 | 原因 | 解决方案 |
|---|---|---|
| 界面空白,没有报错 | schema 结构不符合预期,render 返回了 undefined | 给 renderNode 的 default 分支加日志;用 ajv 提前校验 schema |
| 反复出现相同的 React key 警告 | 数组渲染用了 index 当 key,且内容动态变化 | 用从根到节点的完整路径作为 key |
| AI 生成的界面引用了未注册组件 | 注册表与 LLM 的 output schema 不同步 | 把注册表导出为 JSON Schema,直接塞进 LLM 的 function calling 定义里 |
| 组件 props 校验失败但界面没崩 | 校验失败被 console.error 吞掉了 | 校验失败渲染错误提示组件,不回退裸渲染 |
| 组件渲染两次,刷新时闪烁 | SSR 与客户端渲染结果不一致 | 排查组件是否在渲染期访问 window / localStorage |
| 图片加载就报错、链接点击跳到奇怪页面 | LLM 生成的危险 URL | 在 props 层做协议白名单,只允许 http/https |
| 深层嵌套导致页面卡顿 | 递归深度没有限制 | 加 maxDepth 配置,超限渲染折叠节点 |
4.2 三个容易翻车的细节
细节一:key 必须用路径,而不是 index。很多人写递归渲染时习惯map((child, i) => renderNode(child, i))。这在静态 schema 下没问题,但 Generative UI 是动态的——用户点一个按钮后,AI 会生成一份新 schema 替换旧的。如果两份 schema 结构相似但内容变化,react 会复用同一位置的组件实例,导致旧组件的 state(比如表单里输入的文本)被错误地带到新组件里。我用路径 key 之后,这个问题彻底消失。
细节二:不要在 props 里直接传函数。JSON 意味着一切数据可序列化。如果需要传回调,用 action 机制(handler 名字 + payload),而不是把 JavaScript 函数塞在 props 里——因为 JSON 根本表达不了函数。如果你的注册组件需要事件回调,在注册表定义里做一个 adapter 层,把 props 里的 handler 字符串转成真正的回调函数。我在注册表里实现了这样的 prop transformer:
export function wireActions(compProps, onAction) { return Object.fromEntries( Object.entries(compProps).map(([k, v]) => k.startsWith('on') && typeof v === 'string' ? [k, (payload: any) => onAction({ handler: v, payload })] : [k, v] ) ) }细节三:安全边界不能只在渲染器做。渲染器能做的防御很有限。真正重要的是:渲染器只暴露明确有限的 props 白名单,组件内部不直接渲染未加工的 HTML。尤其要阻止dangerouslySetInnerHTML和javascript:协议的链接。我的做法是在渲染器的最外层包一层 sanitize 函数,把 props 里的href、src等 URL 字段全部过一遍协议白名单。不要相信 AI 生成的 JSON 里任何字符串——它只是看起来“安全”的数据,该洗的还是得洗。
5. 从 Demo 到生产:Generative UI 的工程化落地
5.1 把 LLM 输出变成可控 JSON
很多人问,为什么我用 function calling 还要写十几行 JSON Schema?直接让模型自由输出不行吗?
因为“自由输出”的结果是渲染成功率不可控。工程化的衡量标准不是“成功一次”而是“一万次里成功多少次”。在我实际业务里,加了结构化输出约束之后,渲染成功率从 72% 提升到 96% 以上。剩下的 4% 由“校验失败 -> 回灌修正”兜底。
function calling 或 JSON mode 还不够,我强烈建议在 prompt 里明确告诉模型三件事:第一,只允许输出给定组件列表里的组件;第二,props 必须符合 JSON Schema 定义,不确定的字段别填;第三,用户要求的功能无法用现有组件表达时,用文本说明而不是编造一个组件名。这个 prompt 规范是我在几十次迭代里总结出来的,有效降低幻觉组件名的概率。
5.2 监控、版本兼容与发布策略
到了生产阶段,有两个坑是 demo 阶段完全感知不到的:监控和 Schema 演进。
监控很容易做:注册表里每个组件的渲染次数、校验失败率、渲染耗时都上报。我在地盘上给每个 schema 生成一个 trace id,任何渲染错误都能追踪到是哪次用户会话、哪次 LLM 调用产生的。
Schema 演进更复杂。你是先有注册表里的组件,还是先有 AI 生成需求?现实是同时演进。今天注册了 A 组件,明天业务需要一个 B 组件,后天 LLM 输出里已经开始用 B 了——但线上的旧版本 json-render 还不认识 B。
解决思路是“宽进严出”:LLM 输出可以引用未来组件,渲染时遇到未知组件先渲染 fallback,同时标记“客户端服务版本过低”。这比直接报错让整个页面崩掉温和得多。用户看到的是一个可读的占位,而不是白屏。然后把组件缺失信息上报,推动前端发版。
真正能稳定上生产的 Generative UI,一定不是“AI 说什么就是什么”。它应该是一套完整的链路:模型约束生成 -> 结构校验 -> 注册表匹配 -> 安全清洗 -> 渲染兜底 -> 反馈闭环 -> 可观测。json-render 这套源码,本质上不是帮你解决“渲染”这一步,而是帮你把上面这些环节串起来。
我自己在实际业务里看到最明显的效果是:以前和产品经理对需求,是“你到底要几个页面”;现在变成了“你允许 AI 用哪些组件来组合页面”。这个模型转变才是 Generative UI 工程化的真正价值。
最后分享一个调试技巧。我习惯把 LLM 生成的 JSON schema 原样打印出来存到日志里,配上渲染结果的截图和错误信息,形成一组“生成-渲染”对照样本。每次调整 prompt、组件注册表或渲染器逻辑后,跑一遍这组样本做回归。这个习惯帮我避免了无数次“改了 A 组件结果 B 场景崩了”的惨剧。