水合空窗期
流式SSR在首次绘制与水合完成之间留有空窗期;使用Suspense时,每个延迟加载的边界都有自己的空窗期。此时,基于useState的折叠面板虽然可见,却无法交互,而基于<details>的折叠面板从首次绘制起就能使用。
这两个文档渲染同一个折叠面板,均不包含任何<script>标签:一个基于`<details>`,另一个基于`useState`。分别打开:前者能够展开,后者不能。查看源代码即可确认。
目标是消除不必要的渲染、由provider向所有消费者扩散的更新,以及库JavaScript引发的同步布局工作,而非完全不渲染——状态总要在某处渲染。默认不提供JavaScript,只在无障碍需要时加入少量代码。
分级
每个组件都在清单中声明级别,构建时读取该清单。清单决定体积预算、RSC纯度检查、Playwright测试套件,以及各组件页面上的级别徽标。
| 级别 | 预算 | 禁用JavaScript时 | 含义 |
|---|---|---|---|
| Tier 0 RSC——零客户端JavaScript | 0 kB | 完整通过禁用JavaScript的测试套件。 | 状态保存在原生元素中,或组件没有状态。禁止使用任何React Hook,包括useMemo和useRef。 |
| Tier 1 RSC外壳+轻量客户端孤岛 | ≤ 1 kB | 行为有所降级,各组件分别说明。 | 状态仍由原生元素管理。JavaScript只同步ARIA,或补足平台缺少的能力,例如悬停意图判断或自动关闭。 |
| Tier 2 客户端组件——水合前SSR | 按组件确定 | 水合前渲染有意义的标记。 | 单一客户端入口。服务端尽可能输出原生优先的标记;键盘导航、过滤和ARIA同步需等到水合后。 |
分级也用于迁移:门禁按组件启用,让架构要求逐个组件收紧。
分层结构
@gunkin/uiify/hooks SSR-safe React hooks — the substrate
@gunkin/uiify/core headless behaviour: focus scope, dismiss layers, anchoring
@gunkin/uiify/elements native-mechanism wrappers — zero client JavaScript
@gunkin/uiify/components unstyled components, RSC by default
@gunkin/uiify/styles optional CSS design layer下层绝不导入上层。@gunkin/uiify/elements的限制最严格:只能导入@gunkin/uiify/core/render和React类型。
为什么将elements公开为独立层
大多数应用不会直接导入@gunkin/uiify/elements。将其公开发布有两个原因。
首先,让设计可以验证。当组件页面标注*基于elements/dialog构建*时,对应的元素页面会展示底层的确切标记和data-*契约。
其次,便于组合:如果组件的组成结构不适合你的设计,可以沿用相同的连接方式自行构建,无须重新实现底层机制。
模块只有满足以下五条规则才能进入这一层:只封装一种原生机制、不包含JavaScript行为、至少被两个@gunkin/uiify/components组件使用、状态可由CSS选择器读取,以及减少重复标记而非增加间接层。
模块按平台功能命名,如details、dialog、popover和invoker,而非按UI概念命名。组件与元素绝不同名,因此Modal基于dialog构建。浏览元素
为什么Tier 0禁用React context
如果组成部分仍通过React context通信,将状态移入DOM也无济于事。Server Component不能使用context,因此共享provider的Root和Item无法在服务端渲染,无论它们的标记多么原生。
连接关系必须能在序列化后保留,因此组成部分通过HTML连接:用<details name>分组,用commandfor和popovertarget建立由浏览器解析的引用,并从props生成id。没有context,useId的值就无法传递给兄弟部分。
<Accordion id="faq">
<Accordion.Item value="shipping"> {/* id="faq-shipping" */}代价是根元素需要一个id。不需要组合时,组件接收数据并自行渲染整棵树。查看Server Component