Waterfall(瀑布流)用于任何有开始时间、有耗时、还会嵌套的东西:浏览器的网络请求、OpenTelemetry 调用链、构建的各个步骤。每个 span 是一行,条形落在同一条时间轴上;键盘与屏幕阅读器用户得到一个可以逐行走的 treegrid。这里说的是时间轴瀑布图,不是图片墙那种瀑布流布局。
组合
Waterfall
├── WaterfallHeaderRow
│ ├── WaterfallColumnHeader
│ └── WaterfallColumnHeader
│ ├── WaterfallAxis
│ │ └── WaterfallTick
│ └── WaterfallMark
├── WaterfallRow
│ ├── WaterfallRowHeader
│ │ └── WaterfallIndicator
│ ├── WaterfallCell
│ └── WaterfallBar
└── WaterfallRow
├── WaterfallRowHeader
└── WaterfallBar所有 span 作为 items 传入,并为每个 span 渲染一个带其 value 的 WaterfallRow。行是平级兄弟节点:层级、在兄弟中的位置与子级都由组件从 items 读出。记得给瀑布流一个可访问名。
WaterfallRowHeader 命名 span,有子级时里面放 WaterfallIndicator;WaterfallBar 是时间轴上的那个单元格;WaterfallCell 是其他列,例如状态或大小。表头行、时间轴与标记都可选。
用法
每个 span 有 value、label、以毫秒计的 start 与 duration,以及它发起的子 span children:
import {
Waterfall,
WaterfallBar,
WaterfallRow,
WaterfallRowHeader,
type WaterfallData,
} from "@ui-sdk/react/waterfall";
const requests: WaterfallData = [
{ value: "document", label: "/", start: 0, duration: 182 },
{ value: "styles", label: "app.css", start: 190, duration: 96 },
{ value: "script", label: "app.js", start: 192, duration: 410 },
];
function Example() {
return (
<Waterfall aria-label="网络请求" items={requests}>
{requests.map((request) => (
<WaterfallRow key={request.value} value={request.value}>
<WaterfallRowHeader>{request.label}</WaterfallRowHeader>
<WaterfallBar />
</WaterfallRow>
))}
</Waterfall>
);
}每行写出 --waterfall-start 与 --waterfall-size(span 在轴上的起点与占比,百分比),条形位置纯用 CSS 完成,不需要 JavaScript 布局:
[data-slot="waterfall-bar"] {
position: relative;
}
[data-slot="waterfall-bar"]::before {
content: "";
position: absolute;
inset-block: 6px;
inset-inline-start: var(--waterfall-start);
width: var(--waterfall-size);
border-radius: 3px;
background: var(--color-info);
}不写子内容时,瀑布流会为每个 span 渲染一行,含行头与条形。
时间轴、刻度与标记
WaterfallHeaderRow 与 WaterfallColumnHeader 组成表头。WaterfallAxis 在整数时刻渲染 WaterfallTick,最多 maxTicks 个;WaterfallMark 画一条贯穿所有行的时刻线(例如 DOMContentLoaded),位置由 --waterfall-mark 给出。时间轴对屏幕阅读器隐藏,它们听到的是每个条形自己的时间。
const sizes: Record<string, string> = { document: "14 kB", styles: "8 kB", script: "212 kB" };
<Waterfall
aria-label="网络请求"
items={requests}
format={{ style: "unit", unit: "millisecond", unitDisplay: "narrow" }}
getBarLabel={(request, { start, duration }) =>
`${sizes[request.value]},${start} 开始,耗时 ${duration}`
}
>
<WaterfallHeaderRow>
<WaterfallColumnHeader>名称</WaterfallColumnHeader>
<WaterfallColumnHeader>大小</WaterfallColumnHeader>
<WaterfallColumnHeader aria-label="时间轴">
<WaterfallAxis maxTicks={5} />
<WaterfallMark time={620}>DOMContentLoaded</WaterfallMark>
</WaterfallColumnHeader>
</WaterfallHeaderRow>
{requests.map((request) => (
<WaterfallRow key={request.value} value={request.value}>
<WaterfallRowHeader>{request.label}</WaterfallRowHeader>
<WaterfallCell>{sizes[request.value]}</WaterfallCell>
<WaterfallBar />
</WaterfallRow>
))}
</Waterfall>;format 接受 Intl.NumberFormat 选项或返回文本的函数,决定轴上与屏幕阅读器里的时间写法;locale 默认跟随页面 lang,服务端首帧用英文。getBarLabel 请按页面语言设置。WaterfallAxis 的子内容也可以是接收 ticks 的函数,用来渲染自己的 WaterfallTick(每个 tick 有 time、label、position、outside)。
嵌套与选择
带 children 的 span 可以展开与折叠:点击它的指示器,或在行上按左右方向键。只渲染当前可见的行时,把 expanded 放进状态,交给 visibleWaterfallRows(items, expanded) 按顺序摊平已展开的 span。点击行或在行上按 Enter / Space 会选中它,value 与 onValueChange 保存选中的 span,例如供详情面板使用。
import { createWaterfallController, visibleWaterfallRows } from "@ui-sdk/react/waterfall";
function Example() {
const [spans] = React.useState(() => createWaterfallController());
const [expanded, setExpanded] = React.useState(["checkout"]);
const [value, setValue] = React.useState<string | null>(null);
const rows = visibleWaterfallRows(trace, expanded);
return (
<div data-slot="stack">
<div data-slot="controls">
<button type="button" onClick={() => spans.expandAll()}>全部展开</button>
<button type="button" onClick={() => spans.collapseAll()}>全部折叠</button>
</div>
<Waterfall
aria-label="结账调用链"
items={trace}
controller={spans}
expanded={expanded}
onExpandedChange={setExpanded}
value={value}
onValueChange={setValue}
>
{rows.map(({ item }) => (
<WaterfallRow key={item.value} value={item.value}>
<WaterfallRowHeader>
{item.children && (
<WaterfallIndicator>
<svg width="12" height="12" viewBox="0 0 16 16" aria-hidden="true">
<path d="m6 3 5 5-5 5" fill="none" stroke="currentColor" />
</svg>
</WaterfallIndicator>
)}
{item.label}
</WaterfallRowHeader>
<WaterfallBar />
</WaterfallRow>
))}
</Waterfall>
</div>
);
}trace 是带 children 的 WaterfallData。也可以渲染全部行,让组件隐藏折叠 span 内部的行(被隐藏的行带 hidden),这样 expanded 可以保持非受控。选择与展开互相独立:value / onValueChange 管选中,expanded / onExpandedChange 管展开,非受控用 defaultValue 与 defaultExpanded,回调里 details.cancel() 可以拒绝变更。控制器由 createWaterfallController() 创建,reveal(value) 会展开某个 span 的所有祖先并聚焦它的行。
缩放与平移
range 是轴显示的时间区间 [start, end](毫秒)。改它就是缩放或平移:条形与刻度跟着走,超出区间的条形收缩到边缘。不传时轴从最早的 start 跑到最晚的 end。
const full: [number, number] = [0, 1200];
const [range, setRange] = React.useState(full);
<Waterfall aria-label="页面渲染" items={trace} range={range} defaultExpanded={["render"]}>
…
</Waterfall>;大调用链的虚拟化
调用链可能有上万个 span。只渲染视口附近的行:用 visibleWaterfallRows 摊平已展开的 span,再按偏移渲染附近几行。组件仍会报告每行的层级与在兄弟中的位置,屏幕阅读器因此能听到完整层级。给根 aria-rowcount(可见行总数),给每行 aria-rowindex(索引加一),读屏软件数的是整条调用链而不是页面上这几行。
onHighlight 报告键盘移到的行索引,在这里把它滚进视口,尚未渲染的行才能随后拿到焦点。无论调用链多长,一次方向键只改动它经过的两行。
const rows = visibleWaterfallRows(trace, expanded);
const rowHeight = 32;
<Waterfall
aria-label="构建调用链"
aria-rowcount={rows.length}
items={trace}
expanded={expanded}
onExpandedChange={setExpanded}
onHighlight={(_value, { index }) => {
const el = scroller.current;
if (!el) return;
const top = index * rowHeight;
if (top < el.scrollTop) el.scrollTop = top;
else if (top + rowHeight > el.scrollTop + el.clientHeight)
el.scrollTop = top + rowHeight - el.clientHeight;
}}
style={{ height: rows.length * rowHeight }}
>
{rows.slice(start, end).map(({ item, index }) => (
<WaterfallRow key={item.value} value={item.value} aria-rowindex={index + 1} style={{ top: index * rowHeight }}>
<WaterfallRowHeader>{item.label}</WaterfallRowHeader>
<WaterfallBar />
</WaterfallRow>
))}
</Waterfall>;scroller 是外层滚动容器的 ref,start 与 end 由滚动位置算出。
样式与状态
行的状态由 data-selected、data-expanded、data-branch、data-level 表达,行头、指示器、单元格与条形都带着它们。焦点在行与单元格之间移动,用 :focus-visible 画焦点态。示例里的 data-slot="stack" 与 data-slot="controls" 只是布局容器,用 flex 或 grid 自己定义。
waterfall-row、waterfall-row-header、waterfall-cell、waterfall-bar:data-level(从 1 起的深度)、data-branch(有子级)、data-expanded、data-selected。waterfall-indicator:data-level、data-branch、data-expanded。waterfall-mark:data-outside(时刻在区间外,不显示在轴上)。
CSS 变量:--waterfall-level(深度,用于缩进行头)、--waterfall-start(条形起点,夹取到区间内)与 --waterfall-size(条形宽度,span 在区间外时为 0),以及 --waterfall-tick 与 --waterfall-mark(刻度与标记在轴上的百分比位置)。
[data-slot="waterfall-row-header"] {
padding-inline-start: calc((var(--waterfall-level) - 1) * 16px);
}
[data-slot="waterfall-row"][data-selected] {
background: var(--color-brand-wash);
}
[data-slot="waterfall-row"][data-selected] [data-slot="waterfall-bar"]::before {
background: var(--color-brand);
}
[data-slot="waterfall-indicator"] {
transition: transform 120ms ease;
}
[data-slot="waterfall-indicator"][data-expanded] {
transform: rotate(90deg);
}
[data-slot="waterfall-tick"],
[data-slot="waterfall-mark"] {
position: absolute;
}
[data-slot="waterfall-tick"] {
inset-inline-start: var(--waterfall-tick);
}
[data-slot="waterfall-mark"] {
inset-inline-start: var(--waterfall-mark);
border-inline-start: 1px dashed var(--color-warning);
}
[data-slot="waterfall-mark"][data-outside] {
display: none;
}详见样式与动画。
API
Waterfall:
| 属性 | 类型 | 说明 |
|---|---|---|
items |
WaterfallData |
全部 span,必填。 |
range |
[number, number] |
轴显示的毫秒区间,改它即缩放或平移。 |
value / defaultValue |
string | null |
选中的 span,null 表示不选。 |
onValueChange |
(value, details) => void |
选中变化前触发,可 details.cancel()。 |
expanded / defaultExpanded |
string[] |
展开的 span 值列表。 |
onExpandedChange |
(expanded, details) => void |
展开或折叠前触发,可 details.cancel()。 |
onHighlight |
(value, { index }) => void |
键盘移到某行时触发,用于把行滚进视口。 |
format |
Intl.NumberFormatOptions | ((ms: number) => string) |
轴上与读屏时的时间写法。 |
locale |
string |
时间格式化的地区。 |
getBarLabel |
(item, details) => string |
条形对屏幕阅读器说的话。 |
controller |
object |
createWaterfallController() 创建的控制器。 |
debug |
boolean |
开发构建里把每次状态变化与原因打到控制台。 |
items 里每个 span:value(唯一标识)、label(行上显示,也是键入定位匹配的内容)、start、duration(毫秒,同一时钟)、可选 children。WaterfallAxis 有 maxTicks;WaterfallTick 与 WaterfallMark 有必填的 time;WaterfallRow 有必填的 value。
visibleWaterfallRows(items, expanded) 返回 { item, parent, level, index }:span 原对象、父 span 的值(顶层为 null)、深度(顶层为 1)、在可见行中的位置(从 0 起)。
控制器方法:getValue / setValue / getExpanded / setExpanded / isExpanded / expand / collapse / toggle / expandAll / collapseAll / reveal / focus。写入方法接受可选的 { reason, event },被取消时返回 false;collapse 会把焦点移出行内,reveal 展开祖先并聚焦该行。参见控制器。
键盘与无障碍
遵循 WAI-ARIA treegrid 模式:行是 row,带 aria-level、aria-posinset、aria-setsize,选中行带 aria-selected="true",有子级的 span 带 aria-expanded。WaterfallRowHeader 是 rowheader,焦点沿一列下移时屏幕阅读器会重复 span 名称;每个 WaterfallBar 是 gridcell,以开始时间与耗时命名,例如「120 毫秒开始,持续 340 毫秒」,时间用 Intl 按页面语言格式化。
整个瀑布流只有一个 Tab 停靠点:Tab 进入选中行(没有则第一行),离开后再回来回到上次聚焦的行或单元格。
| 按键 | 动作 |
|---|---|
ArrowDown / ArrowUp |
聚焦下一行或上一行;在单元格里保持同一列。 |
ArrowRight |
在行上展开它,或进入第一个单元格;在单元格里右移。 |
ArrowLeft |
在单元格里左移,再回到行;在行上折叠它,或聚焦父级。 |
Home / End |
聚焦第一行或最后一行;在单元格里到本行首尾单元格。 |
Ctrl + Home / End |
聚焦第一行或最后一行,保持当前列。 |
PageUp / PageDown |
移动一页行。 |
Enter / Space |
选中该行。 |
| 键入字符 | 聚焦下一个 label 以该文本开头的行。 |
Mac 上用 Command 代替 Ctrl;从右到左的文本里左右键交换。