外观
组件库契约
组件为什么长成这个样子。加新组件、或者觉得某个 prop 设计得别扭时,先读这一篇—— 下面五条准则都是从具体的失败里长出来的,不是风格偏好。
1. 宿主契约
库里的组件不自带 theme store、不自带取数、不自己决定当前语言。每个 app 启动时 (layout 顶层)调一次:
ts
import { setUiHost } from '$ui';
setUiHost({ locale, theme });| 字段 | 类型 | 说明 |
|---|---|---|
locale.code | string | 语言码,库用它选字典 |
locale.tag | string | BCP-47 标签,喂 Intl / toLocaleString |
theme.mode | 'light' | 'dark' | 已解析的明暗态。图表只把它当重绘信号——颜色本身走 CSS 变量运行时解析 |
t? | (key, ...args) => string | 可选。库查不到的通用词汇回落到宿主字典 |
未注入时有兜底(英文、亮色),孤立渲染与首帧不会崩,但正常 app 必须注入。
⚠️ 传引用,不要解构
ts
setUiHost({ locale, theme }); // ✅
setUiHost({ locale: { ...locale }, theme: { ...theme } }); // ❌ 反应性被切断库侧导出的 locale / theme 是逐层读穿到宿主对象的 getter,因此在组件的反应上下文里 读 locale.code / theme.mode 能被追踪。解构会取一次快照,表现为用户切语言或切 主题时库内的图表不重绘。
非响应式宿主是合法的
首页注入的是两个常量({ code: 'en', tag: 'en-US' } / { mode: 'dark' })——它是纯 静态页,没有语言切换器,明暗全交给 CSS 的 prefers-color-scheme。契约做成可选注入, 正是为了容纳这种场景。
2. 准则一:库不许反向依赖 app
ui/ 里不能出现 $lib/...、$app/...。
单包结构没有机制强制这一条(多包结构可以靠 exports 挡住),靠约定与 review 守。 违反的表现是构建期解析失败,或者更糟:组件只能在某一个站里跑。
需要宿主能力时的三条替代路径:
| 需要什么 | 怎么拿 |
|---|---|
| 当前语言 / 明暗态 | $ui/host.svelte 的 locale / theme |
当前路径(AppShell 需要判断激活项) | 由宿主传 pathname prop |
| 导航、toast、API 调用、确认框 | 由调用方传函数或 snippet |
AppShell 是这条准则最完整的示范:它是应用外壳,却完全不认识路由——导航项、当前 路径、品牌标、顶栏控件、侧栏页脚全部由宿主提供。
3. 准则二:写操作是可选 prop,不传即不渲染
svelte
let { nodes, onRequestSnapshot }: {
nodes: FleetMapNode[];
onRequestSnapshot?: (nodeId: string) => void | Promise<void>;
} = $props();
…
{#if onRequestSnapshot}
<button onclick={() => onRequestSnapshot(n.node_id)}>…</button>
{/if}控制台传真函数,门户和首页不传,按钮根本不进产物——不是渲染出来再拒绝,也不是 靠 CSS 藏起来。
这是同一个组件能在一处可写、在另外几处只读的全部机制。
合仓前首页为了复用地图,要用四个 shim 桩掉
$lib/api、toast、urlstate、$app/environment,还得写 CSS 把按钮藏起来。可选 prop 契约把这些全消掉, 产物小了 47%。
复杂动作用 snippet,不用动作数组
PrefixFlapBoard 的处置入口是两个 snippet:
ts
prefixActions?: Snippet<[GroupedPrefixRow, PrefixFlapLocalizationCore | null | undefined]>;
peerActions?: Snippet<[GroupedPrefixPeer]>;理由:菜单组件(RowMenu)与确认框(confirmDialog)都是 app 侧的东西,库不该为了 渲染它们把那整套机制也搬进来。snippet 让 app 在自己的上下文里渲染动作,库只负责给 出位置和上下文数据。
不传即整列为空——门户是只读的,处置属于管理面的写路径。
4. 准则三:策略参数由宿主给,不写死在组件里
FleetTraffic 的时段档:
ts
ranges: readonly string[]; // 管理面 24h/7d/30d;门户只有 6h/24h后端不把长时段下放到全网口径,两边的可选档本来就不同。写死在组件里必有一边是错的。
同理,FlapStatsPanel 的 obsNodes(观测节点数)由宿主给:速率是跨节点求和,一次 churn 在每个看到它的节点各计一次;有这个数才能换算成每节点口径,没有就隐藏换算开关。
判据:这个值会不会因为使用方不同而不同? 会,就做成 prop。
5. 准则四:读写分离优先于双向绑定
ts
range: string; // 读
onRangeChange?: (v: string) => void; // 写而不是 range = $bindable()。
理由:调用方常常要「读校验后的值、写原始值」——控制台把它绑在 URL 的 ?range= 上, querystring 天生是任意字符串,需要组件内校验后回落,双向绑定表达不了这一点。
因此几个接受 URL 状态的 prop 一律声明为 string 而不是联合类型,组件内按白名单校验、 非法值回落默认(FlapStatsPanel 的 range 落 '1h'),调用方不必自己转型。
$bindable 仍然合适的场合是纯 UI 的局部状态,宿主只想持久化它: AppShell.collapsed(侧栏折叠,两个站的 localStorage 键不同)、Toggle.checked、 FleetMap.health(控制台绑 URL,只读场景不传就用内部状态)。
6. 准则五:文案分工——组件语义归库,通用词汇归 app
| 归属 | 例子 | 放哪 |
|---|---|---|
| 组件自有语义 | topo.zoomIn、pflap.zone.near、fstat.gauge.avg、health.ok | ui/i18n.svelte.ts,四种语言一起加 |
| 应用通用词汇 | common.updatedAt、common.noMatch、common.refresh | 各 app 字典,库通过 UiHost.t 回落读到 |
判据:这个词 app 自己也在到处用吗?
自带字典的收益是「搬得越多越省」:加一个组件只动一个文件。若改成注入,每个借用方都得 把同一批 key × 四种语言抄一遍。
反过来,通用词汇留在 app 侧,避免「同一个词在同屏两处渲染,只改了一边」的自相矛盾。
npm run check:i18n 会把这条约定变成可执行的检查。它防的是一类不会报错、类型检查 也发现不了的回退——组件搬进库、key 却留在原 app 字典里,于是另一边整块回显 key。
7. 无障碍与视觉的两条底线
颜色永远不单独承载信息。 抖动区徽章的颜色旁边永远写着区名(RootCauseBadge); 根因构成条的每一段都带标签 + 计数(RootCauseSummary);图表的状态色系列一律配图例。
状态色是稀缺资源。 --c-ok/warn/bad/down 只表示健康状态,不进数量序列;数量序列 走 --c-data-1..5。FlapStatsPanel 里「已告警」占状态红、「抖动中」降级为中性数据色 ——同一张图里两条线都用红,人就分不清哪条才是要处理的。
其余细节:可排序表头带 aria-sort;IconButton 的 toggle 模式渲染 aria-pressed; 动效尊重 prefers-reduced-motion。