Timeless UI 采用三层架构设计,将组件的状态管理、交互逻辑和视觉呈现完全分离。这种架构使得创建功能相同但样式不同的组件变得非常简单。
┌─────────────────────────────────────────────────────────┐
│ shadcn 层 │
│ (样式 + 组件组装) │
│ - 使用 headless 提供的原语组装完整组件 │
│ - 添加 CSS 类名和样式 │
│ - 定义组件的视觉呈现 │
└────────────────┬────────────────────────────────────────┘
│
┌────────────────▼────────────────────────────────────────┐
│ headless 层 │
│ (无样式原语 + 事件绑定) │
│ - 提供组件的基础构建块(Trigger, Content, Item 等) │
│ - 绑定 DOM 事件到 UI 层的状态管理 │
│ - 处理可访问性(ARIA)和键盘导航 │
│ - 不包含任何样式 │
└────────────────┬────────────────────────────────────────┘
│
┌────────────────▼────────────────────────────────────────┐
│ ui 层 │
│ (状态管理 + 业务逻辑) │
│ - 管理组件的所有状态(open, focused, disabled 等) │
│ - 实现组件的核心业务逻辑 │
│ - 提供状态变更的事件系统 │
│ - 与视图层完全解耦 │
└─────────────────────────────────────────────────────────┘
职责:纯状态管理和业务逻辑
核心类:
MenuCore: 菜单状态管理MenuItemCore: 菜单项状态管理DropdownMenuCore: 下拉菜单状态管理PopperCore: 定位逻辑PresenceCore: 显示/隐藏动画状态
特点:
- 继承自
BaseDomain,提供事件系统 - 不依赖任何 DOM 或视图框架
- 可以在任何环境中使用(浏览器、Node.js、测试环境)
- 通过事件通知状态变化
示例:
class MenuItemCore extends BaseDomain {
_focused = false;
_open = false;
get state() {
return {
focused: this._focused || this._open,
open: this._open,
};
}
handlePointerEnter() {
this._focused = true;
this.emit(Events.Change, { ...this.state });
}
onStateChange(handler) {
return this.on(Events.Change, handler);
}
}职责:连接 UI 层和 DOM,提供无样式的组件原语
核心函数:
DropdownMenuPrimitive.Trigger: 触发器DropdownMenuPrimitive.Content: 内容容器DropdownMenuPrimitive.Item: 菜单项DropdownMenuPrimitive.Portal: 传送门MenuPrimitive.*: 菜单相关原语
特点:
- 接收
store参数(UI 层的实例) - 绑定 DOM 事件到 store 的方法
- 监听 store 的状态变化,更新视图
- 处理可访问性和键盘交互
- 不包含任何样式
示例:
export function Item(
props: ViewProps & { store: MenuItemCore },
children: ViewChildren,
) {
return View(
{
...props,
onClick() {
props.store.handleClick();
},
onMouseEnter() {
props.store.handlePointerEnter();
},
onMouseLeave() {
props.store.handlePointerLeave();
},
},
children,
);
}职责:组装组件并添加样式
特点:
- 使用 headless 层的原语
- 添加 Tailwind CSS 类名
- 定义组件的视觉呈现
- 可以创建多个样式变体
示例:
export function DropdownMenu(
props: ViewProps & { store: DropdownMenuCore },
children?: ViewChildren,
) {
return Show({
when: !!children,
ok() {
return [
DropdownMenuPrimitive.Trigger({ store: props.store }, children),
DropdownMenuPrimitive.Portal({ store: props.store.menu }, [
DropdownMenuPrimitive.Content(
{
store: props.store,
class: "min-w-[8rem] rounded-md border bg-white p-1 shadow-md",
},
[
For({
each: computed(state_, (t) => t.items),
render(item: MenuItemCore) {
return DropdownMenuItem({ store: item });
},
}),
],
),
]),
];
},
});
}用户点击
↓
DOM 事件 (onClick)
↓
headless 层调用 store.handleClick()
↓
ui 层更新状态 (this._open = true)
↓
ui 层触发事件 (this.emit(Events.Change))
↓
headless 层监听到状态变化
↓
更新 DOM (添加/移除类名、显示/隐藏元素)
// UI 层:提供状态和事件
class MenuCore {
state = { open: false };
show() {
this.state.open = true;
this.emit(Events.StateChange, { ...this.state });
}
}
// Headless 层:订阅状态变化
const state_ = refobj(props.store.state);
props.store.onStateChange((v) => {
state_.as(v);
});
// Shadcn 层:使用响应式状态
View({
class: computed(state_, (t) =>
t.open ? "block" : "hidden"
),
})// 父菜单引用子菜单
class MenuCore {
cur_item: MenuItemCore | null = null;
}
class MenuItemCore {
menu: MenuCore | null = null; // 子菜单
}
// 建立双向引用
menu.listen_item(item);
if (item.menu) {
item.menu.parent_menu = menu; // 子菜单引用父菜单
}// 子菜单项进入时,清除父菜单的定时器
item.onEnter(() => {
if (this.parent_menu && this.parent_menu.hide_sub_timer) {
clearTimeout(this.parent_menu.hide_sub_timer);
this.parent_menu.hide_sub_timer = null;
}
});// packages/ui/src/my-component/index.ts
export class MyComponentCore extends BaseDomain {
state = {
open: false,
value: "",
};
toggle() {
this.state.open = !this.state.open;
this.emit(Events.StateChange, { ...this.state });
}
onStateChange(handler) {
return this.on(Events.StateChange, handler);
}
}// packages/timeless/src/my-component.ts
export function Trigger(
props: ViewProps & { store: MyComponentCore },
children: ViewChildren,
) {
const state_ = refobj(props.store.state);
props.store.onStateChange((v) => {
state_.as(v);
});
return View(
{
onClick() {
props.store.toggle();
},
},
children,
);
}
export function Content(
props: ViewProps & { store: MyComponentCore },
children: ViewChildren,
) {
const state_ = refobj(props.store.state);
return Show(
{ when: computed(state_, (t) => t.open) },
[View(props, children)],
);
}// packages/shadcn/src/my-component.ts
export function MyComponent(
props: ViewProps & { store: MyComponentCore },
children?: ViewChildren,
) {
return [
MyComponentPrimitive.Trigger(
{
store: props.store,
class: "px-4 py-2 bg-blue-500 text-white rounded",
},
children,
),
MyComponentPrimitive.Content(
{
store: props.store,
class: "mt-2 p-4 border rounded shadow-lg",
},
["Content here"],
),
];
}const myComponent = new MyComponentCore();
MyComponent(
{ store: myComponent },
["Click me"],
);- 关注点分离:状态、交互、样式完全分离
- 可测试性:UI 层可以独立测试,不需要 DOM
- 可复用性:同一个 UI 层可以配合不同的样式层
- 灵活性:可以轻松创建样式变体(Material Design、Ant Design 等)
- 类型安全:TypeScript 提供完整的类型检查
- 框架无关:UI 层不依赖任何视图框架
DropdownMenuCore: 管理下拉菜单的打开/关闭状态MenuCore: 管理菜单项列表、当前选中项、子菜单MenuItemCore: 管理单个菜单项的 focused、open 状态
DropdownMenuPrimitive.Trigger: 绑定点击事件到store.toggle()DropdownMenuPrimitive.Content: 监听store.state.open,控制显示/隐藏DropdownMenuPrimitive.Item: 绑定鼠标事件到store.handlePointerEnter/Leave()
DropdownMenu: 组装 Trigger + Portal + Content- 添加 Tailwind 样式类
- 使用
For循环渲染菜单项 - 处理子菜单的递归渲染
- UI 层只管理状态:不要在 UI 层操作 DOM
- Headless 层只绑定事件:不要在 Headless 层添加样式
- Shadcn 层只负责呈现:不要在 Shadcn 层实现业务逻辑
- 使用响应式状态:通过
refobj和computed实现自动更新 - 事件命名规范:使用
onXxx命名事件监听器 - 状态不可变:每次状态变化都创建新对象
{ ...this.state }
- 启用调试日志:在 Core 类中设置
debug = true - 追踪事件流:在
emit和on中添加console.log - 检查状态:在浏览器控制台访问
store.state - 使用 React DevTools:查看组件树和状态变化
- Radix UI: 类似的 headless 组件库
- Headless UI: Tailwind 团队的 headless 组件
- shadcn/ui: 本架构的灵感来源