写在前面

说实话,接手这个项目的时候我心里是打鼓的。

你知道那种感觉吗?老板丢过来一个需求:"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。听起来不难对吧?

但问题是:

  1. 相对路径怎么处理?当前组件的数据上下文是什么?

  2. 嵌套组件的上下文如何传递?

  3. 路径不存在时怎么处理?默默失败还是抛异常?

我那段时间对着 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 更新都必须通过消息:

  1. BeginRendering:初始化一个 Surface,设置根组件

  2. SurfaceUpdate:更新/添加组件配置

  3. DataModelUpdate:更新数据模型

  4. 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();
}

为什么?

  1. 更新快:O(1) 直接找到组件改配置

  2. 内存友好:不需要重复存储父节点引用

  3. 序列化简单:直接丢给 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(示例)

相关文档:

更多AIGC文章

RAG技术全解:从原理到实战的简明指南

更多VibeCoding文章

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐