踩坑三个月,我用 Blazor 重构了一个 AI UI 协议,这些教训值得你看看
本文分享了作者在实现A2UI协议的Blazor版本时遇到的技术挑战和解决方案。文章重点探讨了七个关键问题:声明式UI设计解决了AI生成界面的安全性问题;数据绑定系统的三层解析机制;消息驱动架构确保状态一致性;处理JsonElement类型的坑;利用Blazor的DynamicComponent实现动态渲染;可配置主题系统的设计;以及性能优化策略。作者还提出了未来改进方向,包括扩充组件库、支持SSR
写在前面
说实话,接手这个项目的时候我心里是打鼓的。
你知道那种感觉吗?老板丢过来一个需求:"AI 要能动态生成 UI,而且要跨平台、安全、还要支持 Blazor"。我当时就在想,这不是要我上天吗?
但三个月后的今天,看着代码库里跑起来的 A2UI.Blazor,我居然觉得...还挺香?
这篇文章不讲虚的,就是想聊聊我在实现 A2UI 协议的 Blazor 版本时踩过的坑,还有那些让我熬到凌晨三点的技术难题。如果你也在做类似的动态 UI、AI 生成界面相关的项目,希望能给你一点参考。
一、那个让我重新思考的"声明式 UI"
痛点:AI 生成界面到底有多难?
一开始我以为很简单。不就是 AI 吐 JSON,前端渲染吗?
事实证明我太天真了。
第一个坑就是安全性。 你让 AI 直接生成代码?那跟在服务器上开个后门有什么区别。但你让 AI 生成 HTML?iframe 隔离?性能、样式、交互体验全废了。
我那时候就在想,有没有一种方式,既能安全地让 AI 描述界面,又能让界面像原生组件一样跑?
这就是 A2UI 协议要解决的问题:声明式数据,而非可执行代码。
我们的解决方案
简单说,AI 不生成代码了,它生成的是"组件配置数据"。比如一个按钮,不是生成 <button>点击</button>,而是生成这样的 JSON:
{
"id": "submit-btn",
"component": {
"Button": {
"child": "btn-text",
"action": {
"name": "submit_form"
}
}
}
}
看到区别了吗?没有 HTML,没有 JavaScript,只有纯数据描述。
前端拿到这个 JSON 后,从自己的"可信组件库"里找到 Button 组件,用配置数据渲染。这样 AI 永远只能从我们允许的组件库里选,安全性的问题就解决了。
二、数据绑定:那些让我头秃的路径解析
路径解析的噩梦
真正让我开始掉头发的,是数据绑定系统。
按照 A2UI 协议,组件需要支持类似 JSONPath 的数据绑定,比如 $.user.name。听起来不难对吧?
但问题是:
-
相对路径怎么处理?当前组件的数据上下文是什么?
-
嵌套组件的上下文如何传递?
-
路径不存在时怎么处理?默默失败还是抛异常?
我那段时间对着 DataBindingResolver.cs 发呆的次数,比我前十年加起来都多。
我们怎么解决的
最终我们设计了三层解析机制:
第一层:路径标准化
public string ResolvePath(string path, string? dataContextPath = null)
{
// 绝对路径直接返回
if (path.StartsWith('/'))
return path;
// 当前上下文
if (path == "." || path == "")
return dataContextPath ?? "/";
// 相对路径拼接
if (!string.IsNullOrEmpty(dataContextPath) && dataContextPath != "/")
return dataContextPath.EndsWith('/')
? $"{dataContextPath}{path}"
: $"{dataContextPath}/{path}";
return $"/{path}";
}
这短短几十行代码,我改了不下十次。每一次都觉得完美了,然后第二天就发现新边界 case。
第二层:数据模型树
我们用字典嵌套的方式实现了路径树:
public void SetValue(string path, object value)
{
var parts = path.TrimStart('/').Split('/');
var current = _data as Dictionary<string, object>;
// 创建不存在的中间节点
for (int i = 0; i < parts.Length - 1; i++)
{
var part = parts[i];
if (!current.ContainsKey(part))
current[part] = new Dictionary<string, object>();
current = (Dictionary<string, object>)current[part];
}
current[parts[^1]] = value;
}
第三层:上下文传播
这个是最 tricky 的。子组件渲染时需要知道父组件的数据上下文,我们在 A2UIRenderer 里做了这么一件事:
@code {
[Parameter]
public string? DataContextPath { get; set; }
private ComponentNode? ComponentNode;
protected override void OnParametersSet()
{
var surface = MessageProcessor.GetSurface(SurfaceId);
if (surface != null && surface.Components.TryGetValue(ComponentId, out var node))
{
// 如果有 DataContextPath,创建副本传递下去
if (!string.IsNullOrEmpty(DataContextPath))
{
ComponentNode = new ComponentNode
{
Id = node.Id,
Type = node.Type,
Properties = node.Properties,
Weight = node.Weight,
DataContextPath = DataContextPath // 关键!
};
}
}
}
}
这样 List 组件渲染每一项时,就能把当前项的路径传给子组件,实现相对绑定。
三、消息驱动架构:从"命令式"到"声明式"的思维转变
我犯的最大的错误
一开始我把 UI 更新做成了命令式的:直接调用 UpdateComponent(id, props)。
结果就是状态乱得一塌糊涂。客户端更新了,服务端不知道;服务端推送了,客户端没刷新。调试的时候我差点把键盘砸了。
痛定思痛,我改成了消息驱动。
A2UI 的消息模型
所有 UI 更新都必须通过消息:
-
BeginRendering:初始化一个 Surface,设置根组件
-
SurfaceUpdate:更新/添加组件配置
-
DataModelUpdate:更新数据模型
-
DeleteSurface:删除整个 Surface
关键在于,所有消息都由 MessageProcessor 统一处理:
public void ProcessMessage(ServerToClientMessage message)
{
if (message.BeginRendering != null)
HandleBeginRendering(message.BeginRendering);
else if (message.SurfaceUpdate != null)
HandleSurfaceUpdate(message.SurfaceUpdate);
else if (message.DataModelUpdate != null)
HandleDataModelUpdate(message.DataModelUpdate);
else if (message.DeleteSurface != null)
HandleDeleteSurface(message.DeleteSurface);
// 统一触发更新事件
OnSurfaceUpdated(message.SurfaceId);
}
然后 A2UISurface 组件订阅这个事件:
protected override void OnInitialized()
{
MessageProcessor.SurfaceUpdated += OnSurfaceUpdated;
}
private void OnSurfaceUpdated(object? sender, SurfaceUpdatedEventArgs e)
{
if (e.SurfaceId == SurfaceId)
{
LoadSurface();
InvokeAsync(StateHasChanged); // 触发 Blazor 重新渲染
}
}
这样所有组件都能响应到数据变化,状态一致性就保证了。
这个架构带来的好处是,你可以从多个地方触发 UI 更新——WebSocket、SSE、定时任务、本地事件,都不用担心状态不同步。
四、JsonElement:那个让我怀疑人生的类型
隐形杀手
这是我遇到的最坑的问题,没有之一。
从 JSON 反序列化出来的 Dictionary<string, object>,里面的 object 根本不是你以为的类型!
// 你以为的
var dict = JsonSerializer.Deserialize<Dictionary<string, object>>(json);
var value = (string)dict["key"]; // 💥 运行时崩溃
// 实际上
var value = ((JsonElement)dict["key"]).GetString(); // 要这样
我在 A2UIButton.razor 里处理 action context 的时候,因为这个 bug 调试了整整两天。
我们的解决方案
写了一堆类型转换的辅助方法:
protected Dictionary<string, object>? GetDictionaryProperty(string propertyName)
{
if (Component.Properties.TryGetValue(propertyName, out var value))
{
// 直接字典
if (value is Dictionary<string, object> dict)
return dict;
// JsonElement 需要特殊处理
if (value is JsonElement jsonElement && jsonElement.ValueKind == JsonValueKind.Object)
{
return JsonSerializer.Deserialize<Dictionary<string, object>>(
jsonElement.GetRawText()
);
}
}
return null;
}
血的教训:在 .NET 里处理动态 JSON,永远要考虑 JsonElement。
五、动态组件渲染:Blazor 的黑魔法
问题来了
AI 给我一个组件类型字符串 "Button",我怎么在 Blazor 里渲染对应的组件?
Blazor 又不像 Vue 那样支持 component :is="componentName"。
我查了一圈资料,最后找到了 DynamicComponent——这是 .NET 6 才有的特性。
我们的实现
private Type GetComponentType()
{
return ComponentNode.Type switch
{
"Text" => typeof(A2UIText),
"Button" => typeof(A2UIButton),
"Card" => typeof(A2UICard),
"Row" => typeof(A2UIRow),
"Column" => typeof(A2UIColumn),
// ... 更多组件
_ => typeof(A2UIText) // 降级处理
};
}
然后在 Razor 里:
<DynamicComponent Type="@GetComponentType()" Parameters="@GetParameters()" />
这个实现让我感慨,微软有时候还是能整出点好东西的。
但还有一个问题:参数怎么传?Blazor 的 Parameters 要的是 Dictionary<string, object>,但我们组件的参数是强类型的。
private Dictionary<string, object> GetParameters()
{
return new Dictionary<string, object>
{
{ "SurfaceId", SurfaceId },
{ "Component", ComponentNode! }
};
}
Blazor 会自动做类型匹配,只要名字对上了就行。这点还是挺智能的。
六、主题系统:从硬编码到可配置
早期的尴尬
最开始我把样式全写死在组件里:
<button style="padding: 8px 16px; background: #1890ff; color: white;">
结果就是改个颜色要改十几个文件,而且完全没法做暗色模式。
重构成主题系统
后来我抽了一个 IA2UITheme 接口:
public interface IA2UITheme
{
string Name { get; }
A2UIComponentStyles Components { get; }
A2UIColorColors Colors { get; }
}
public class A2UIComponentStyles
{
public virtual string Button => "a2ui-button";
public virtual string ButtonPrimary => "a2ui-button-primary";
public virtual string TextField => "a2ui-textfield";
// ...
}
然后组件改成:
<button class="@Theme.Components.Button">
ThemeService 负责生成 CSS 变量:
public string GenerateThemeCss()
{
return $"""
:root {{
--a2ui-primary: {_currentTheme.Colors.Primary};
--a2ui-text: {_currentTheme.Colors.Text};
--a2ui-background: {_currentTheme.Colors.Background};
}}
""";
}
这样换主题只需要一行代码:ThemeService.SetTheme("Dark");
七、性能优化的那些事
邻接表的胜利
在组件树存储方式上,我犹豫了很久:是用树结构还是邻接表?
最终我选择了邻接表——就是用字典存所有组件,通过 ID 引用:
public class Surface
{
public Dictionary<string, ComponentNode> Components { get; set; } = new();
}
为什么?
-
更新快:
O(1)直接找到组件改配置 -
内存友好:不需要重复存储父节点引用
-
序列化简单:直接丢给 JSON 序列化器
如果用树结构,每次更新都要遍历整棵树,性能差远了。
只在必要时刷新
Blazor 的 StateHasChanged 不是免费的。我一开始滥用,结果界面卡得要死。
后来学乖了,只在 SurfaceUpdated 事件触发时才刷新:
private void OnSurfaceUpdated(object? sender, SurfaceUpdatedEventArgs e)
{
if (e.SurfaceId == SurfaceId) // 只处理自己的
{
LoadSurface();
InvokeAsync(StateHasChanged);
}
}
这个小小的判断,让性能提升了十倍。
八、未来还能做什么
说实话,这个项目还有很多可以改进的地方。
1. 组件库扩充
现在虽然有 18 个组件了,但跟成熟的 UI 库比还是少。表格、树形控件、图表这些都是刚需。
2. 服务端渲染支持
Blazor Server 用 SignalR,Blazor WASM 用 HTTP + SSE。如果能加上 SSR(服务端预渲染),首屏性能会好很多。
3. 更智能的数据绑定
现在的数据绑定还是单向为主。如果能在 Blazor 里实现类似 Vue 的 computed property,那玩起来就更有意思了。
4. AI 集成层
现在 AgentSDK 还是比较原始的 Builder 模式。如果能做一套 LLM 友好的 DSL,让 AI 直接生成自然语言描述,自动转成 A2UI 消息,那就更强大了。
最后想说的话
这三个月我最大的感受是:架构设计真的太重要了。
一开始我觉得不就是动态 UI 吗,画几个组件就完事了。结果越做越发现,消息驱动、数据绑定、主题系统、性能优化...每一个点都能展开讲半天。
但正是这些挑战,让写代码变得有意思。 如果每天只是搬砖、写 CRUD,那这行也没什么意思了不是吗?
如果你也在做类似的项目,或者对 A2UI 感兴趣,欢迎交流。代码都在 GitHub 上(虽然我还没发完整版,哈哈),有问题尽管提。
最后的最后,给个建议: 接手类似项目前,先把协议文档读三遍。我第一次就没读仔细,导致后面返工了两次。
就这样吧,希望能帮到一些人。码字不容易,觉得有用点个赞呗。
项目链接: A2UI.Blazor(示例)
相关文档:
更多推荐




所有评论(0)