设计模式——论UI中的组合与OOP

论UI中的组合与OOP

回顾组合与OOP

在web届曾经有一场旷日战争,争论UI到底是组合还是继承。最终的结论显而易见,组合优于继承现在几乎成了一种教条,那么,对于UI来说,组合真的由于继承吗?

组合

要重新看待这个问题,我们首先得给UI开发里的组合下一个完整的定义。相比传统的OOP,到底组合了什么?

更具体的来说,在UI开发中的组合,通常分为两类:

  • UI 组合(组件复用): 将大型 UI 拆解为细小的原子组件。父组件决定如何布局,子组件决定如何渲染。
  • 逻辑组合(行为复用): 编写 useXXX 这样的 Hook。需要该功能的组件通过"组合" Hook 来获得能力。

二者本质上描述了两件事:使用组合创建层级与使用组合创建新功能。

对于后者毫无疑问的这是组合,但是对于前者 ,创建层级,真的属于组合吗?

OOP

OOP 的核心精华在于: "行为的抽象"与"类型的组合" 。与其讨论单纯的OOP,不如讨论这两个精华在现代语言中,是怎么存在的。

接口 (Interface/Trait):行为的抽象

OOP 中继承的主要目的之一是多态,但继承强行引入了"数据结构的传递"和"隐式层级关系",导致了脆弱基类问题。

  • 现代方式 :接口(尤其是像 Rust 的 Trait 或 Go 的 Interface)把"类型能做什么"彻底从"类型是什么"中剥离了出来。它不再关心你的内存布局,只关心你实现了什么行为。
  • 好处:接口保留了 OOP 中"调用方无需关心具体实现"的灵活性,但去掉了 OOP 必须通过继承链来共享实现的强制要求。
代数类型 (ADT):闭合的多态

OOP 中多态的另一个面貌是:通过 if/elseswitch 判断对象类型,或者通过继承树来处理不同的子类。这在 OOP 中通常通过"虚函数"自动分发。

  • 现代方式 :代数类型配合模式匹配,把 OOP 中那种散落在各个子类 override 方法里的逻辑,收拢回一个清晰的类型定义中。它解决了 OOP 最痛的"Expression Problem"(扩展操作难,扩展类型难)。
  • 好处:ADT 保留了 OOP 中"处理多种类型可能性"的逻辑表达力,但通过编译器的强制检查,消灭了运行时出现"未定义行为"的可能性。

UI的本质

所以,现在来看,组件复用属于组合吗?在动态语言中这样的区别不够明显,但是如果在静态语言中如何描述一个UI树?显然解法是要么利用OOP的继承,要么利用接口和代数类型,但是其二者其实本质上是相同的,都是为了实现多态。

显然,UI组合不是真正的组合,其本质是多态。

开放多态树

利用开放多态(继承),可以很好的描述一个UI的树结构,这已经在OOP中得到了大量的应用。利用继承,我们可以在不更改UI库源代码的情况下,创建自己的新UI组件类型,并能够将其兼容进UI框架中通用的UIComponent类型容器中。

typescript 复制代码
 // 统一的语义接口(开放多态)
 interface UIComponent {
     render(): void;
 }
 ​
 // 叶子节点(原子语义)
 class Button implements UIComponent {
     render() { console.log("  渲染一个按钮"); }
 }
 ​
 // 组合节点(容器,既是 UIComponent)
 class Panel implements UIComponent {
     private children: UIComponent[] = [];
 ​
     public add(child: UIComponent) { this.children.push(child); }
 ​
     render() {
         console.log("渲染一个面板,开始向下递归:");
         // 核心:一视同仁,客户端和父节点都不需要写 switch/case 或者是 match
         this.children.forEach(child => child.render());
     }
 }

闭合多态树

那么,进一步,利用接口和代数类型怎么表达UI的层级结构?

在前面我们说,OOP中的多态在现代语言中通过代数数据类型与接口实现了同样的功能,因此可以写出这样的代码来描述一个树形结构。但是------这里的关键是代数类型是一种封闭的多态,如果要添加新的UI组件,必须更改整个代数类型,这意味着,如果你在写一个通用 UI 框架 ,麻烦就大了。如果用户想自定义一个自己的组件并塞进框架的容器里,他们无法做到!因为他们没有权限去修改框架源码的enum类型,而且增加一个新的类型,意味着要大动干戈地修改原有的枚举定义,并且所有 match 它的地方全都要改一遍。这对框架设计来说是毁灭性的。

rust 复制代码
 // 用代数类型穷尽节点的所有可能性(闭合多态)
 #[derive(Debug)]
 pub enum UIWidget {
     Button { text: String },
     Text { content: String },
     Panel { children: Vec<UIWidget> },
 }
 ​
 impl UIWidget {
     // 统一的行为方法,内部通过模式匹配(Pattern Matching)分发逻辑
     pub fn draw(&self) {
         match self {
             UIWidget::Button { text } => {
                 println!("  [渲染按钮]: {}", text);
             }
             UIWidget::Text { content } => {
                 println!("  [渲染文本]: {}", content);
             }
             UIWidget::Panel { children } => {
                 println!("[开始渲染面板] ->");
                 // 一视同仁地向下递归遍历
                 for child in children {
                     child.draw();
                 }
                 println!("<- [面板渲染结束]");
             }
         }
     }
 }

而OOP是开放多态,从不假设数量。这就导致了一个天然的结果------OOP天然就是适合用来组建真正的UI树。很多UI框架早以证实了这一点,Flutter、早期的React,源代码里都是OOP。

有很多框架采用了一种讨巧的方式,比如SwiftUI或者Slint等,将修改结构后需要更改源代码的问题,交给了编译器来自动化工作,让你写起来感觉有开放多态的好处,但是实际上运行时却没有OOP的开销和问题。但这往往导致编译器报错出来的类型会嵌套几十层如同天数,同时也无法良好的支持热重载。

但是!(这是一个重要的转折)。这并不代表这是OOP的功劳,因为继承仍然是一种很糟糕的方式,因为在获得开放多态的能力同时,继承还捆绑了一大堆方法和属性等等无用的捆绑大礼包功能。如果我们能够拥有一种继承之外的开放多态实现方法。或许也就不需要承担继承带来的额外成本了。

或许你有很多的方法仍然可以绕着来实现这样的开放多态,但是绝对不会优雅,最终你会发现自己绝对又结合类型擦除,重新实现了OOP中的虚表。比如写成下面这样。

rust 复制代码
 // 开放多态的接口
 trait Widget {
     fn draw(&self);
 }
 ​
 // 组合节点:不再持有具体类型,而是通过 Box<dyn Widget> 强行开辟开放多态的后门
 struct Container {
     children: Vec<Box<dyn Widget>>, // 类型擦除,通过虚表动态分发
 }
 ​
 impl Widget for Container {
     fn draw(&self) {
         for child in &self.children {
             child.draw(); // 递归调用
         }
     }
 }

因此,可以说,最好的UI方式其实是使用动态语言而不是静态语言来实现UI。 现实里往往也都是这么做的。比如游戏里大量使用Lua来描述UI,JS天生就是动态类型的语言。归根到底,动态语言里根本就没有多态的问题,因为他天生就是多态的。 (比如QML就是这样的典型例子,使用一个极简的js运行时来描述UI)。

bevy_ui与Rust的失败

bevy虽然很强大,但是bevy_ui却是公认的难用,这是为什么?

归根到底,因为bevy_ui试图强行在ecs里模拟树形结构,使用with_children这样的组件,来构建实体的层级关系,这犯了本体论上的根本错误。

首先,UI是一个语义和控制流驱动的,而不是一个数据驱动的系统。

虽然主流的观点认为UI=f(state),但是其实state往往根本不属于UI而属于业务本身(这是ECS的领域,控制数据与持久化状态),一个良好的软件不能因为UI出错了整个系统就跑不起来了,在任何复杂的软件里这都是极其严重的架构问题(除了"独树一帜"的web领域喜欢把业务逻辑和状态和UI塞到一起)。

事实上,f才是保留式UI的关键,这是一个高度语义化的动态树形结构。因此ECS在描述UI上就天然的处于弱势,不适合来描述UI。

其次,适合描述层级关系的是Rust的enum或者Box,但是封闭多态正好撞上了通用UI框架的死区。

因此可以说,bevy_ui的失败是必然的。

甚至可以进一步说,由于Rust还拥有借用检查器,这对于保留式UI的节点相互借用更是毁灭性打击。同样的封闭多态问题,对于Go语言来说也是存在的,且Go语言的类型表达能力比Rust还弱一些,因此可以得出一个显然结论:

Rust和Go这样的语言,根本就不适合用来描述语义化的、状态纠缠的、需要开放多态的、保留式的UI。

这样的语言,如果硬要去构建可拓展的保留式UI,要么大量使用类型擦除并手动管理多态,要么走Slint那样的路,使用DSL编写UI并编译构建。要么,走立即式或者混合式UI的路子。

只要UI在内存里还是一个需要不断变化和添加新类型的树,闭合多态语言就会迎来不可能三角:要在"类型安全、无性能开销、通用扩展性"中三选二。保留式的通用UI框架必须选扩展性,于是Rust/Go就必须为了强行实现扩展性而付出代价。

相关推荐
zavoryn1 小时前
后端接入 AI Agent:Tool Calling 网关、幂等与审计日志实战
后端·架构
冰雪情缘long1 小时前
Android架构分层+架构模式+设计模式的关系理解
架构
小程故事多_802 小时前
拆解Hermes Agent技术架构,会自我迭代的开源智能体如何突破AI传统局限
人工智能·架构·开源
运维成长记2 小时前
关于“有x86镜像,没有Dockerfile” 怎么制作arm架构的镜像
arm开发·架构
uzong3 小时前
分布式下的系统,什么是算是好的架构设计
后端·架构
数据库小学妹3 小时前
HTAP混合负载架构:如何用一个数据库同时搞定交易和分析
数据库·经验分享·架构·dba
狼爷4 小时前
百万QPS多场次秒杀系统架构全解:解耦设计、防超卖、流量防护体系
后端·架构
hz567894 小时前
2026 年 RTC 音视频 SDK 解析:技术架构、主流厂商与选型指南
架构·云计算·音视频·webrtc·实时音视频·信息与通信
LONGZETECH4 小时前
架构师实战拆解|无人机智慧实训SaaS中台:断电续考、AI组卷、多端同步核心设计
大数据·人工智能·架构·系统架构·无人机