2026-04-09 | 组件库
原子化组件库的 Space,为什么最终去掉了那层 DOM
Helen
从老版 TDesign 的 Space 设计出发,梳理 atomic 版本里移除 `space-item` 包裹层的原因,以及在 separator 与 Svelte children 处理上踩过的坑。
其实原子化组件库最初并不打算提供 layout 类组件,直接使用
gap等原子布局能力即可,额外增加一层 DOM 反而会带来负担;但考虑到后续 A2UI 对语义化表达的需求,仍有必要补充一层轻量的语义化封装。
一开始的疑问:为什么要多一层 DOM?
老版 TDesign 的 Space,每个子元素外面都会包一层:
<div class="t-space-item">
{child}
</div>
第一反应其实是,这一层是不是多余的。 后面对着功能和源码看了一圈,基本能确认它的作用:
- 用
margin模拟gap(历史兼容) - 控制子项宽度 / stretch
- 插入
separator - 做一层样式隔离
也就是说,这层不是随便加的,是为了兜住早期浏览器能力不足的问题。
但问题来了:现在还需要吗?
放到现在这个 atomic + 现代浏览器 的前提下再看一遍:
- CSS
gap已经全支持了 flex/grid本身就能控制布局separator可以直接插节点- 样式隔离在容器层已经足够
再对照一遍,会发现这些问题都已经不成立了。
所以 space-item 的角色就变成:
只是维持一个不再需要的中间层。
直接结论: 多余 DOM,可以去掉。
删完之后遇到的第一个问题:separator 不对
把 wrapper 干掉之后,第一个出问题的是 separator 里的 Divider:
它不撑开了。
一开始的处理方式是给它加一条 CSS:
align-self: stretch;
功能上没问题,但不太确定是不是最合理的方式。 回头看了 TDesign Next 的实现,它的策略更简单:
- 水平方向不处理(依赖默认
stretch) - 垂直方向加一条
width: 100%
本质是:尽量不干预 flex 默认行为,只在必要的时候补一条规则。
最后改成:
direction === 'vertical' ? { width: '100%' } : {}
这个写法更明确,也更接近原实现的意图。
又来个坑:Svelte 这边没法“插 children”
React / Vue 这类框架可以直接处理 children:
- React:
Children.toArray() - Vue:
slots.default?.()
可以拿到数组,然后在相邻元素之间插 separator。
但 Svelte 5 不一样。
children 是一个 Snippet,本质是渲染函数,只能整体执行:
{@render children()}
不能拆开,也不能遍历。
这个限制是怎么确认的
对 Svelte 不太熟悉,一开始其实不太确定是不是我用法不对。
直觉上应该是可以像 React 一样,把 children 拆成数组处理的,所以去查了一下。
很快搜到一个官方 issue(sveltejs/svelte#11566),讨论的就是:
能不能像 React 一样遍历 children
官方结论比较明确:
Snippet是编译时概念,不是运行时数组- 不支持把
children当作可迭代结构使用 - 也不会自动聚合为数组
看到这里基本就能确认: 不是写法问题,是框架本身就不支持这条路径。 中间也尝试过在外面包一层再处理,但本质还是拿不到子节点结构,最后就放弃了这条思路。
换一条路
既然不能操作 children,那就只能从 DOM 和布局层绕。
最后用了一个比较直接的方案:
方案
- 正常渲染所有
children - 同时预渲染一组
separator - 在运行时统计真实子节点数量
querySelectorAll(':scope > :not([data-slot="space-separator"])')
- 用 CSS
order排序
- child:偶数
- separator:奇数
最终视觉效果是:
child separator child separator child
但 DOM 实际是平铺的。
最后的结果
这一轮调整之后:
- 去掉了
space-item这一层 DOM - 间距完全交给
gap separator作为兄弟节点参与布局- 多框架实现可以对齐
小结
这次改完之后,反而更容易理解原来的实现。
space-item 本身没有问题,它只是针对当时的约束做出的设计。
现在约束变了,实现自然也应该跟着变。