Skip to content

组件库契约

组件为什么长成这个样子。加新组件、或者觉得某个 prop 设计得别扭时,先读这一篇—— 下面五条准则都是从具体的失败里长出来的,不是风格偏好。

1. 宿主契约

库里的组件不自带 theme store、不自带取数、不自己决定当前语言。每个 app 启动时 (layout 顶层)调一次:

ts
import { setUiHost } from '$ui';
setUiHost({ locale, theme });
字段类型说明
locale.codestring语言码,库用它选字典
locale.tagstringBCP-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.sveltelocale / 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/apitoasturlstate$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

后端不把长时段下放到全网口径,两边的可选档本来就不同。写死在组件里必有一边是错的。

同理,FlapStatsPanelobsNodes(观测节点数)由宿主给:速率是跨节点求和,一次 churn 在每个看到它的节点各计一次;有这个数才能换算成每节点口径,没有就隐藏换算开关。

判据:这个值会不会因为使用方不同而不同? 会,就做成 prop。


5. 准则四:读写分离优先于双向绑定

ts
range: string;                       // 读
onRangeChange?: (v: string) => void; // 写

而不是 range = $bindable()

理由:调用方常常要「读校验后的值、写原始值」——控制台把它绑在 URL 的 ?range= 上, querystring 天生是任意字符串,需要组件内校验后回落,双向绑定表达不了这一点。

因此几个接受 URL 状态的 prop 一律声明为 string 而不是联合类型,组件内按白名单校验、 非法值回落默认(FlapStatsPanelrange'1h'),调用方不必自己转型。

$bindable 仍然合适的场合是纯 UI 的局部状态,宿主只想持久化它: AppShell.collapsed(侧栏折叠,两个站的 localStorage 键不同)、Toggle.checkedFleetMap.health(控制台绑 URL,只读场景不传就用内部状态)。


6. 准则五:文案分工——组件语义归库,通用词汇归 app

归属例子放哪
组件自有语义topo.zoomInpflap.zone.nearfstat.gauge.avghealth.okui/i18n.svelte.ts,四种语言一起加
应用通用词汇common.updatedAtcommon.noMatchcommon.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..5FlapStatsPanel 里「已告警」占状态红、「抖动中」降级为中性数据色 ——同一张图里两条线都用红,人就分不清哪条才是要处理的。

其余细节:可排序表头带 aria-sortIconButton 的 toggle 模式渲染 aria-pressed; 动效尊重 prefers-reduced-motion