在可重用UI堆栈中,框架边界应位于何处
了解状态机、Web组件以及基于属性的布局与动画如何让UI行为独立于框架存在,以及何时这种可移植性并不值得追求。
大多数团队只为某一个框架精心设计UI。精心构建的Angular组件库、一系列React基础组件、与某个渲染器生命周期深度整合的设计系统:在项目不需要其他工具时,这一切都运作得非常完美,但一旦需要换用不同工具,之前投入的所有努力几乎都付诸东流。将可复用的UI拆分为不同层次(行为层、渲染组件层以及布局、动画等小型HTML功能层),就能让你有意识地决定哪些层次应与特定框架绑定,哪些则不必。
仅为单一框架设计UI的问题
想象这样一个前端团队:他们大部分时间都在使用 Angular,并且很满意该框架的发展方向——信号机制、现代 API,以及为有需求的应用程序提供的丰富结构。他们的内部工具套件遵循 shadcn 模型,即把组件源码复制到每个项目中并由该项目自行管理,同时借助 Angular 特有的无头原生组件来处理底层交互任务。在 Angular 应用程序中,这确实是一种出色的配置。
当下一个项目并非 Angular 项目时,问题就出现了。那些规模庞大、状态复杂、交互频繁的应用程序可能非常适合使用 Angular,而主要以静态内容为主的营销网站则往往更适合用 Astro。有时,仅靠浏览器平台就能提供几乎所有所需功能。如果技术选择应当遵循每个项目的具体需求,而非相反,那么一个后续问题就不可避免地出现了:
当框架发生变化时,你的 UI 基础设施应保留多少?
探究这个问题往往会引出一系列思路:首先是 Web Components,接着是无头 UI,然后是状态机,最后是一种较为创新的尝试——让布局和动画拥有独立的 HTML 属性。这些概念乍看之下似乎毫无关联,但综合起来其实都是对同一个设计问题的不同解答:在应用程序框架不得不渗透到每一个抽象层之前,UI 能在多大程度上实现共享?
无头 UI 并不等同于与框架无关
无头 UI 已经解决了这个问题的大部分。Radix Primitives 就很好地诠释了为何这种模式会流行起来。它不会提供带有他人设定的背景色、间距、阴影和边框半径的对话框组件,而是只提供核心功能,外观部分则交由用户自行决定。
那些棘手的部分才是真正的挑战:焦点管理、键盘导航、正确的 ARIA 属性设置、关闭行为,以及诸多容易被忽视的细节——因为在模态框还只是看起来像一个层叠在另一个 div 之上的结构时,这些细节往往会被忽略。Radix 故意以无样式的形式提供其基础组件,并将它们定位为可访问的 React 基础组件。你可以控制组件的呈现方式,但其底层的组件模型依然是 React。
这听起来似乎很显然,但实际上却有其影响。虽然去掉了样式依赖,但框架依赖依然存在。在 Angular 世界中也是如此:一个基础组件可以完全不涉及颜色、间距和排版等元素,但却完全依赖于 Angular 的指令、信号、依赖注入以及生命周期钩子。
这并非缺陷。很多时候,这恰恰是正确的选择。为 Angular 设计的原始组件能够与 Angular 紧密集成,而 React 的原始组件则可以利用 React 的组合模型。深度集成通常能带来比假装框架不存在时更好的开发者体验。关键在于,有两个概念常常被当作同义词,但实际上并非如此:
headless
≠
framework-agnostic
无头组件摒弃了视觉方面的考量。与框架无关的抽象则更进一步,试图消除对渲染器的各种假设。这是两种不同层次的复用方式,了解自己真正需要哪一种会很有帮助。
行为可以存在于组件之外
以下拉菜单为例。两个下拉菜单在视觉上可能毫无相似之处:一个出现在营销页面上,配有大字体、充足的空白空间以及动画过渡效果;另一个则位于空间狭小的集成开发环境工具栏中,每一像素都至关重要。其中一个可能由 Angular 渲染,另一个由 React 渲染,还有一个则由 Web 组件渲染。
在视觉差异之下,总有相同的问题反复出现:
- 下拉菜单当前是否处于展开状态?
- 哪个选项是激活状态?
- 按 Escape 键会有什么作用?
- 用户能否使用方向键在选项之间切换?
- 如何处理被禁用的选项?
- 下拉菜单关闭后焦点会指向何处?
这些问题都与页面是由 Angular 还是 React 生成的无关,它们首先是交互问题,其次才是渲染问题。
状态机作为通用契约
正是在这里,状态机驱动的 UI 才展现出其强大优势。Zag.js 是这种实现方式最为人熟知的例子。它并不将 React 组件视为唯一的数据来源,而是将交互建模为与框架无关的机器。随后通过独立的框架适配器将这些机器与 React、Vue、Solid、Svelte 等框架处理响应式逻辑、生命周期及 DOM 的方式相连接,其文档还说明了如何为尚未支持的框架编写适配器。
在传统组件中,所有相关功能都被封装在某个特定框架的单元内:
React Dropdown
├─ state
├─ interactions
├─ accessibility
└─ rendering
借助中间的机器,行为只需定义一次,各个渲染器便可直接使用它:
Dropdown behavior
│
state machine
│
┌────────────┼────────────┐
↓ ↓ ↓
React Vue Svelte
最终的组件仍然是独立的组件,每个框架也能保持其作为框架的原有特性。真正发生改变的是行为规范,这一定义比那种认为存在一个无需任何集成工作就能在所有地方运行的神奇组件的幻想更为实用。更好的总结是:只需编写一次行为逻辑,然后根据应用程序的需求将其适配到合适的渲染器上。
一个有用的思维模型是将机器视为对状态、事件和转换的纯粹描述。由于它不包含DOM引用或框架状态,因此很容易进行独立单元测试:只需向其中发送事件并验证产生的状态即可,无需进行任何组件挂载操作。
组件在成为UI之前可以先以行为形式存在
Web组件库中也适用同样的思路。想象一个用Stencil构建的库,其中包含常见的按钮、对话框、下拉菜单和卡片组件。这些组件无需用状态机来替代,而是在其下方添加一个独立的层即可。
按钮工厂负责处理禁用状态、加载状态、点击事件,以及哪些属性应应用到交互元素上。下拉菜单工厂则负责控制其打开、关闭、选择功能以及键盘导航。对话框工厂则管理其生命周期和交互规则。所有这些逻辑都不需要决定组件的外观。
从概念上讲,在做出任何渲染决策之前就可以存在这样的结构:
const button = createButton({
disabled: false,
loading: false,
onClick(event) {
// application behavior
},
});
注意缺少了什么:没有 Stencil,没有 Angular,没有 React 组件,甚至连 CSS 都没有。该工厂函数仅用于描述行为逻辑,后续可以再绑定渲染器。例如,该库中的 Stencil 按钮会利用这些行为逻辑,并生成一个带有样式的自定义元素:
<and-button variant="destructive">
Delete
</and-button>
不同的应用程序可以用完全不同的按钮来实现相同的行为核心。以桌面风格的 IDE 为例,其界面设计通常比普通网站更为密集。这类 IDE 的按钮使用不同的尺寸、设计规范以及视觉风格,因此直接导入通用的 Web Component 按钮显然是不合适的。这类 IDE 会有自己专用的 Angular 按钮,而该 Angular 按钮仍然可以调用相同的 createButton() 工厂函数。
这就是实际的好处。目标并非如此:
ONE BUTTON
↓
use everywhere
目标如下:
shared behavior
│
┌──────────┴──────────┐
↓ ↓
Web Component Angular component
↓ ↓
Web UI IDE UI
界面展示内容仅限于每个产品自身,而繁琐的交互逻辑无需为每个产品重新编写。
Web组件让渲染后的组件具有可移植性
状态机使行为具备可移植性。它们对已渲染的UI没有任何影响,而这正是Web组件的价值所在。如下所示的自定义元素属于Web平台,而非Angular、React或Vue:
<and-button>
Save
</and-button>
Angular可以渲染它,Astro可以输出它,React可以使用它,普通的HTML页面也可以通过脚本标签引入它。尽管围绕它的框架可能会变化,但该元素本身始终保持不变。
在实际应用中,各框架对自定义元素的支持细节各有不同。Angular需要CUSTOM_ELEMENTS_SCHEMA(或类似机制)才能识别未知标签,而React过去习惯将值作为属性而非特性传递,这给复杂数据及自定义事件的处理带来了困难,尽管最新版本已对此进行了改进。在决定使用这一功能之前,建议先查看所选框架当前对自定义元素的支持情况。
由此便产生了两种不同的复用方式。其一为可移植的行为:
State machine
↓
portable behavior
另一种则是可移植的渲染组件:
Web Component
↓
portable rendered component
有时你需要整个 Web 组件。如果某个设计系统中的按钮需要在多个应用中保持完全一致的外观和行为,将其封装为自定义元素是个明智的选择。而有时你根本不需要完整的组件。集成开发环境的情况正是如此:保留交互逻辑,但让应用完全负责渲染和设计。
这些并非相互竞争的架构,只是将抽象边界设定在不同的位置而已。从边界而非库的角度思考还能让我们明白另一点:并非所有可复用的 UI 元素都必须是组件。
布局不需要组件
布局是最典型的例子。这是一个普通的 Tailwind 元素:
<div
class="
flex
flex-col
items-start
gap-6
p-6
rounded-xl
border
bg-card
shadow-sm
"
>
...
</div>
这并没有什么问题。Tailwind的真正优势之一在于,无需在模板和样式表之间来回切换,就能理解大部分元素的功能。但看看那个单一的class属性要承担多少任务——有些类用于描述视觉风格:
rounded-xl
border
bg-card
shadow-sm
另一些类则用于描述元素与其子元素之间的空间关系:
flex
flex-col
items-start
gap-6
p-6
浏览器不会对它们进行区分。class仅仅是一种用于附加标识符的通用机制,CSS和JavaScript可以据此进行选择,而HTML本身并不区分“布局类”与“设计类”。不过从API设计的角度来看,将两者分开反而更为合理。一个实验性的and-layout属性专门用于表示空间相关的特性:
<div
class="card"
and-layout="vertical align:start gap:lg p:lg"
>
...
</div>
其优势并非简洁性;有时这种写法并不会更短。真正的优点在于职责划分更加清晰:class用于描述元素的外观,而and-layout则负责说明元素如何布局空间。
该属性的实现方式
实现这一功能既不需要框架也不需要JavaScript,完全是纯CSS:属性中的每个部分都会通过属性选择器进行匹配(以空格分隔的单词选择器~=尤为合适),随后再应用Flexbox、Grid、间距以及响应式规则。实际上,像下面这样的声明本质上就是带有断点的CSS Grid而已:
<div
and-layout="grid cols:1 cols@md:2 cols@lg:3 gap:lg"
>
背后没有隐藏的布局引擎,也没有Angular指令、React组件,更不存在类似这样的包装结构——除非你确实需要使用Stack:
<Stack direction="vertical" gap="lg">
这一限定很重要。布局组件本身并无问题。<Stack>这种抽象方式非常方便,尤其是在框架设计系统中。属性方法只是另一种选择:当所需的元素已经存在时,无需专门创建一个组件来描述其子元素的布局方式。
需要记住的一个权衡点是:根据规范,没有data-前缀的自定义属性并非有效的HTML,尽管所有浏览器都会正常对其应用样式。验证工具和某些代码检查工具会对此提出警告,而使用data-and-layout这种拼写方式可以避免这个问题,只是会稍显冗长一些。
class可以承担所有功能,但并非必须如此
这背后存在一种更宏观的规律。现代标记语言可以将大量职责交给来承担。再加上实用型CSS和动画插件,一个原本很普通的元素就会变成这样:
<div
class="
card
flex
flex-col
items-center
gap-6
p-8
rounded-xl
border
bg-card
animate-in
fade-in
slide-in-from-bottom-4
duration-500
"
>
这种方法确实可行,而且易于理解的实用类远比强行要求每个界面都必须遵循完美的BEM层级结构要好。真正的问题不在于是否能够承担所有这些职责——显然它可以;问题在于一个属性是否应该代表元素的所有功能。将不同职责分开后,就能得到:
<div
class="card"
and-layout="vertical align:center gap:lg p:xl"
and-motion="slide-in-up"
and-motion-duration="500ms"
>
现在这个元素一眼就能看出包含三种不同的职责:
class → visual identity
and-layout → spatial organization
and-motion → animation
可以把这看作是将单一职责原则应用于标记语言的一种简单体现。HTML本身并不要求这样做,其目的是让代码更易于阅读、审查和修改。
动画拥有独立的声明式通道
动画功能建立在一种多年来行之有效的模式之上。animate.style 上发布的 Animate.css 完全通过类来控制动画:添加基础类再加上动画名称,元素就会开始动画。
<h1 class="animate__animated animate__bounce">
Hello
</h1>
这种方式简单、直观且几乎无需繁琐步骤。以 Tailwind 为方向的动画插件也遵循同样的理念,将动画转化为 class 中的一组可组合的实用功能。
这里值得探讨的问题并非如何构建更强大的动画系统。这与布局问题类似:如果动画属于独立的功能范畴,那么在标记语言中就应该为其设置独立的声明式位置。不必将动画相关功能与其他所有功能混在一起:
<div
class="
card
animate-in
fade-in
slide-in-from-bottom-4
duration-500
"
>
而应单独描述动画效果。
<div
class="card"
and-motion="slide-in-up"
and-motion-duration="500ms"
>
当触发条件很重要时,需要明确指定触发条件以及延迟时间和持续时间:
<div
class="card"
and-motion="slide-in-up"
and-motion-trigger="enter"
and-motion-duration="800ms"
and-motion-delay="200ms"
>
标记语用于声明要运行的动画、运行时间以及计时方式;具体实现则由代码来完成。对于“进入”触发条件,通常意味着使用IntersectionObserver来监控该元素。悬停和点击触发条件则各自使用相应的监听器。重要的是,prefers-reduced-motion媒体查询可以在一个中心位置统一处理,而无需每个组件开发者都记住这一点。
属性并非魔法。它们只是为元素提供了一种声明自身功能的方式,而无需将其纳入框架的组件树中。
在声明式方法不再有效时
这种方法的适用范围是有限的。一个简单的进入动画完全可以通过单个属性来实现:
and-motion="fade-in"
模态框的退出动画属于另一种类型的问题。假设模态框必须先完成动画,之后才能从 DOM 中移除。此时生命周期和执行顺序就变得很重要,而用一段简短的命令式代码能更清晰地表达这一意图:
await player.play('fade-zoom-out');
closeModal();
试图将所有的生命周期关系都编码进不断扩大的 HTML 属性中,只会让声明式 API 变得更糟,而不会更好。核心原则是选择最能准确表达问题的最简单层次结构,无论是纯 CSS、属性、状态机、普通框架服务还是 Web 组件。声明式风格不应成为教条。没人想彻底抛弃 JavaScript 或框架,只是为了避免仅仅因为这些工具存在就将其用于处理每个问题,从而导致抽象层次过度提升。
作为小型功能 API 的属性
一旦布局和动画都遵循这种模式,这些属性就不再像是配置项,而更像是一些小型功能接口。请看这段代码:
<section
class="feature-card"
and-layout="vertical gap:lg p:xl"
and-motion="fade-in"
and-motion-trigger="enter"
>
<h2>Framework-agnostic UI</h2>
<p>At least as much as possible.</p>
</section>
它仍然是一个<section>元素,其语义含义保持不变。要实现间距、展示效果以及进入动画,并不需要使用这样一堆嵌套的包装元素:
<and-stack>
<and-motion-container>
<and-card>
...
</and-card>
</and-motion-container>
</and-stack>
那种嵌套结构并不一定就是错误的。如果这些元素具备有意义的行为并能提供实用的 API,那么它们完全有资格成为组件。两者的区别其实更为细微:可重用的功能并不一定非得变成另一个组件。通常情况下,DOM 节点已经存在,只需要处理布局、动画或工具提示功能即可。框架并不一定非得知晓它的存在,而当抽象层直接建立在 HTML 上时,它就可以无缝集成到 Angular、Astro、React、Vue 中,或者应用于没有框架的页面上。
与框架无关并不意味着无需框架
这里存在一个明显的风险:“与框架无关”的理念很容易演变成另一场纯粹性的较量,从而完全偏离初衷。框架的价值在于能够整合各种功能。Angular 提供了信号系统、模板、依赖注入、表单处理、路由功能以及清晰的应用模型;React 拥有非常成熟的组合式开发生态;Vue 和 Svelte 则做出了不同的取舍。
与框架无关的行为仍需与每个应用的自反应系统相连接。这正是 Zag 提供框架适配器的原因:适配器将某个机制与框架的自反应机制、生命周期规则以及 DOM 规范绑定在一起,而该机制之所以能保持独立,只是因为还有另一层代码负责理解该框架的规则。
此处描述的分层方法同样会带来相应的成本:
- 基于通用状态工厂构建的 Angular 组件可能需要使用
effect()来确保信号输入与外部状态保持同步。 - Web 组件必须将状态层与其自身的生命周期回调相连接。
- 属性驱动的布局方式引入了一种小型 DSL,所有团队成员都需要学习它。
- Motion 属性又增加了另一套 API 接口。
- 将所有内容拆分为独立的包会生成一些协议,这些协议必须长期保持兼容性。
有时,直接使用框架专用的组件才是更好的设计选择。正因如此,即便有这么多其他方案存在,专为 Angular 设计的组件套件依然有其价值。没人应该仅仅因为“无关框架”听起来更高级,就迫使每个 Angular 按钮都要经过四层适配器、两套状态机以及 Web Component 的处理。那其实是避免编写以下代码的一种极其低效的方式:
<volt-button>
当可移植性确实是实际需求时,框架独立性才能发挥优势。而在非此情况之下,深度集成框架往往才是更好的解决方案。
作为技术栈而非库的框架无关 UI
“与框架无关的 UI 库”这一说法通常让人联想到一组能在任何地方正常运行的组件。对于渲染型组件而言,Web Components 已经相当接近这一目标。不过,更实用的模型并非单一的通用库,而是一系列按职责划分的组件,每个组件在不同的环节会变得特定于某个框架:
Application
│
Angular / React / Vue / Astro
│
framework adapters
│
─────────────────────────────────────────
│
headless behavior / state machines
│
Web Components HTML capabilities
│ │
│ layout / motion attributes
│ │
─────────────────────────────────────────
│
Web Platform
没有应用程序必须使用所有层:
- 某个项目直接使用 Web Component,而从不涉及其底层的无头状态。
- 另一个项目仅使用状态机,并在其之上构建自己的 Angular UI。
- 一个静态的 Astro 页面可能只需要布局和动画相关属性即可。
- Angular 应用程序则可以忽略整个组件栈,直接使用基于 Angular 原语的本地工具包,因为这样能带来最流畅的开发体验。
能够自由组合正是其核心理念。所谓“与框架无关”并不意味着禁止使用特定框架,而是意味着由你决定框架的边界所在,而非让框架自动渗透到每一个可复用层中。
将框架限制在应用层
这些观点并非针对 Angular 或其他任何框架,而是探讨框架默认应承担多少职责。请从这一角度重新审视这些示例:
- 下拉菜单的视觉设计可能属于产品团队,渲染工作由 Angular 承担,但其交互模型则不必如此。
- 当需要在多个项目中使用完全相同的按钮时,它可以作为 Web 组件;而当某个产品需要独特的视觉风格时,则可作为仅共享少量行为核心的定制 Angular 组件。
and-layout 布局结构。综合来看,这些要素彼此契合:
- 无头 UI去除了视觉层面的主观设计。
- 状态机使组件行为不受特定渲染器的限制。
- Web 组件让已渲染完成的组件能够在不同框架之间传递。
- 专用属性将布局、动画等轻量功能直接绑定到 HTML 本身,而非框架。
这些想法并非新鲜事物,也并非所有用户界面系统都应如此构建。然而结合起来,它们挑战了一个长期存在的默认观念:即框架必须存在于应用程序的每一个可重用用户界面抽象层之下。
关键要点
- 要区分“无样式”与“与渲染器无关”;大多数无头库仅属于前者。
- 将多个产品共用的交互逻辑放入无需框架的状态机或工厂中,并有意识地承担适配成本。
- 当不仅行为而且渲染出的组件本身也需在不同技术栈中保持一致时,应使用 Web Components。
- 对于布局和简单动画,可使用纯 CSS 的属性功能;一旦生命周期顺序变得重要,则应转而使用命令式代码。
相关阅读
- 当可复用的 React 组件适得其反:属性爆炸问题及解决方案 — 了解过早复用如何将一个简单的 React 组件变成充满冗余属性的负担,以及如何通过避免重复、使用复合组件和遵循“三原则”来防止这一问题。
- 重新思考 React 状态:数据应存储在何处 — 本文阐述了如何通过将状态放在 URL、DOM 或派生值中,而非过度使用 useState,从而减少 React 应用中的错误。