一、技术难点:组件库难的不是"写组件",是"写契约"
业务组件写完就结束,组件库写完才刚开始。它要面对的是"不知道谁会怎么用"、并且一旦发布就无法收回。这才是组件库真正的难点所在。
难点一:API 是公共契约,加一个 prop 就是一笔不可逆的负债
业务代码改错了,改回来就行;组件库的 prop 一旦发布,就有下游项目在写。删掉它意味着破坏性变更,改它的语义意味着更隐蔽的破坏性变更。于是组件库的 API 设计天然偏向"少而正交":能用组合表达的能力,就不新增 prop。
但"少"和"够用"是矛盾的。最典型的是受控/非受控双模式 :搜索框要有内部状态(非受控,开箱即用),也要能被父组件完全接管(受控,用于联动、校验、持久化)。如果只做一种,组件库就永远进不了复杂表单。而这两种模式的实现必须同源------受控和非受控不能走两条代码路径,否则行为差异会在某个边界条件下暴露成线上 bug。
难点二:状态的"双重所有权"与同值死循环
受控模式的本质是:状态的所有权在父组件,组件只是个受控的执行器 。所以组件调用 onChange 之后,绝不能擅自改自己的内部状态------它必须等父组件把新 value 传回来,否则出现"UI 已变、数据没变"的假象。
反面还有一种更阴险的情况:父组件在 onChange 里因为某些原因(值被拦截、防抖、校验失败)没有更新 value,此时组件如果天真地 setState 并触发 onChange,就会形成 onChange → setState → onChange 的无限循环,把浏览器卡死。所以"同值不再通知"不是优化,而是必须的护栏。
难点三:复合组件的隐式状态 vs 重渲染地狱
现代组件库的写法是复合组件(Compound Components):
tsx
<Menu.Root>
<Menu.Trigger>打开</Menu.Trigger>
<Menu.List>
<Menu.Item>Apple</Menu.Item>
</Menu.List>
</Menu.Root>
Root 持有状态,Item 要读到"我是不是当前高亮项"。最直觉的实现是把状态塞进 Context------但这会亲手制造渲染灾难 :Context 一旦变化,所有消费它的组件一起重渲染。键盘上下移动时,高亮从第 1 项挪到第 2 项,Context 变了 → 20 个菜单项全部重渲染,只为把两个 className 换一下。菜单越大越卡,而且这个代价是"结构带来的必然代价",不是写错代码。
所以真正的难点是:如何让状态共享出去,却只让真正关心的那一部分重渲染。
难点四:样式隔离与主题定制的三方拉锯
组件库要用在别人的页面里,必须解决三件事,而它们的诉求互相冲突:
| 诉求 | 朴素做法 | 代价 |
|---|---|---|
| 不污染外部样式 | 全局类名 .button |
与业务样式直接撞车 |
| 外部能定制主题 | 暴露一堆 props 控制颜色 | props 爆炸,且无法覆盖伪类/子元素 |
| 运行时别太贵 | CSS-in-JS 动态插样式 | 每次渲染都要序列化 + 插入,首屏无样式闪烁 |
再加上一个容易被忽略的点:主题切换必须是"换一个变量"而不是"重渲染整棵树"。如果主题存在 React state 里,切暗色模式就等于让整个应用重渲染一遍。
难点五:a11y 与键盘交互的状态爆炸
一个看起来平平无奇的下拉菜单,按 ARIA APG 规范要实现:ArrowUp/ArrowDown 循环移动、Home/End 跳首尾、自动跳过 disabled 项 、打字定位(typeahead,且 500ms 内多次键入要累积成字符串、连按同一字符要在匹配项间轮转)、Esc 关闭、失焦关闭、点击外部关闭、关闭后焦点归还触发器。
这些逻辑有二十多个状态迁移,而且和视觉毫无关系 。把它们散落在 onKeyDown 里,测试无从下手,换一个框架就得全盘重写。
难点六:可推导的泛型与"多态组件"
组件库的类型是最难写的一类类型体操。三件事必须同时成立:
<Menu.Item onClick={...}>要能推导出onClick的event类型;<Button as="a" href="...">要能推导出href存在、type="button"(button 专有属性)不存在;- 错误用法要在编辑器里报错,而不是运行时炸。
这需要泛型组件 + Omit + 条件类型配合(正是 Day09/Day10 那套类型体操的真实落点)。
难点七:打包出口与"样式被摇掉"
组件库发布时要同时满足:ESM 用户能 tree-shaking、CJS 老项目能 require、类型能被 TS 找到。而最经典的坑是:package.json 里写了 "sideEffects": false(为了 tree-shaking),结果用户 import 'ui/dist/style.css' 这行 CSS 导入被当成"无副作用"直接摇掉------组件渲染出来但没有样式,且只在生产构建复现。
二、完整解法:Headless 内核 + 视图适配层
2.1 架构总览:把"逻辑"和"视觉"彻底切开
现代组件库(Radix / Headless UI / Zag.js / Ark UI)都收敛到同一个架构:三段式。
┌─────────────────────────────────────────────┐
│ ① 纯函数状态机 交互语义(键盘/ARIA/禁用/循环)│ ← 零框架依赖,可单测
├─────────────────────────────────────────────┤
│ ② 细粒度 store 状态 + 选择器订阅 │ ← 零框架依赖
├─────────────────────────────────────────────┤
│ ③ 框架适配层 useSyncExternalStore / 事件绑定 │ ← React / Vue / Svelte 各写薄薄一层
└─────────────────────────────────────────────┘
好处是三重的:交互逻辑可以脱离框架单测;同一份逻辑可以同时产出 React 和 Vue 版本;样式和 DOM 结构完全交给使用者,组件库不再和业务抢 DOM。
2.2 核心:细粒度订阅,让"高亮移动"不惊动整棵树
难点三的答案是选择器订阅:订阅的不是"状态变了",而是"我关心的那个值变了"。
ts
// 订阅粒度 = selector,selector 结果 Object.is 相等就跳过通知
subscribe(selector, listener) {
const l = { selector, listener, memo: selector(state) };
listeners.add(l);
return () => listeners.delete(l);
}
setState(patch) {
if (!shallowChanged(state, next)) return false; // 无变化不广播
state = { ...state, ...next };
for (const l of [...listeners]) {
const selected = l.selector(state);
if (Object.is(selected, l.memo)) continue; // 命中不变 → 不回调 = 不重渲染
l.memo = selected;
l.listener(selected);
}
}
对应到 React 就是 useSyncExternalStore:
tsx
function useSelector<S, T>(store: Store<S>, selector: (s: S) => T): T {
return useSyncExternalStore(
(cb) => store.subscribe(selector, cb), // 命中不变时根本不会回调
() => selector(store.getState()),
() => selector(store.getInitialState()), // SSR 快照,保证水合前后一致
);
}
于是:Trigger 只订阅 open,Item 只订阅 activeIndex === 自己下标(返回布尔值,天然可 Object.is 比较)。高亮移动时,只有两个 Item 重渲染,Root 与其余 Item 一次都不渲染。 这是"结构级"的优化,不是靠 memo 兜底。
两条必须记住的约束:
- selector 必须返回原始值或稳定引用 。
s => ({ a: s.a })每次都产生新对象,Object.is永远不等,等于订阅了全部变化(这也是useSyncExternalStore报 "getSnapshot should be cached" 警告的根因)。 - 需要返回对象时,用
useSyncExternalStoreWithSelector传入isEqual(如shallowEqual)。
2.3 受控/非受控同源:useControllableState
tsx
function useControllableState<T>({ value, defaultValue, onChange }: {
value?: T;
defaultValue: T;
onChange?: (v: T) => void;
}): [T, (next: T | ((prev: T) => T)) => void] {
const isControlled = value !== undefined; // 唯一的判定依据
const [inner, setInner] = useState(defaultValue);
const current = isControlled ? (value as T) : inner;
const ref = useRef(current);
ref.current = current; // 始终读到最新值,避免闭包陷阱
const setValue = useCallback((next: T | ((p: T) => T)) => {
const resolved = typeof next === 'function'
? (next as (p: T) => T)(ref.current)
: next;
if (Object.is(resolved, ref.current)) return; // 同值不通知 → 斩断死循环
if (!isControlled) setInner(resolved); // 只有非受控才自己持有状态
onChange?.(resolved); // 两种模式都必须对外上报
}, [isControlled, onChange]);
return [current, setValue];
}
三个细节是这段代码的全部价值:isControlled 只看 value === undefined(而不是看有没有传 onChange);Object.is 幂等护栏;受控时只上报不落库。走完这三条,受控与非受控的行为差异就被压缩到"状态存在哪"这一件事上,其余逻辑完全共用。
2.4 可运行的 Headless 内核(零依赖,node 直接跑)
下面是一个完整可跑的 Dropdown 内核,包含 store、键盘状态机、typeahead、受控/非受控,以及一组自检断言。保存为 mini-headless.mjs,执行 node mini-headless.mjs。
js
// mini-headless.mjs ------ Headless 组件内核:零依赖,node 直接跑
// 覆盖三件事:细粒度订阅的 store / 纯函数键盘状态机 / 受控-非受控统一
/* ======================= 1. 细粒度订阅 store ======================= */
// 对应 React 侧的 useSyncExternalStore:订阅粒度 = selector
function createStore(initial) {
let state = initial;
const listeners = new Set();
return {
getState: () => state,
setState(patch) {
const next = typeof patch === 'function' ? patch(state) : patch;
if (!shallowChanged(state, next)) return false; // 无变化不广播 → 不触发渲染
state = { ...state, ...next };
for (const l of [...listeners]) {
const selected = l.selector(state);
if (Object.is(selected, l.memo)) continue; // 选择器结果没变 → 跳过通知
l.memo = selected;
l.listener(selected);
}
return true;
},
subscribe(selector, listener) {
const l = { selector, listener, memo: selector(state) };
listeners.add(l);
return () => listeners.delete(l);
},
};
}
function shallowChanged(a, b) {
for (const k in b) if (!Object.is(a[k], b[k])) return true;
return false;
}
/* ======================= 2. 键盘导航纯函数 ======================= */
const enabledIndexes = (items) =>
items.map((it, i) => (it.disabled ? -1 : i)).filter((i) => i >= 0);
/** 在可用项之间循环移动,自动跳过 disabled */
function step(items, from, dir) {
const idx = enabledIndexes(items);
if (!idx.length) return -1;
const pos = idx.indexOf(from);
if (pos === -1) return dir > 0 ? idx[0] : idx[idx.length - 1];
return idx[(pos + dir + idx.length) % idx.length];
}
const firstEnabled = (items) => enabledIndexes(items)[0] ?? -1;
const lastEnabled = (items) => {
const idx = enabledIndexes(items);
return idx.length ? idx[idx.length - 1] : -1;
};
/** 打字定位(typeahead):从 from 之后开始找,允许绕回,跳过 disabled */
function typeahead(items, query, from) {
const q = query.toLowerCase();
const n = items.length;
const start = from < 0 ? 0 : from + 1;
for (let k = 0; k < n; k++) {
const i = (start + k) % n;
if (items[i].disabled) continue;
if (items[i].label.toLowerCase().startsWith(q)) return i;
}
return from; // 无匹配保持原位
}
/* ======================= 3. Dropdown 状态机 ======================= */
function createDropdown({ items, defaultValue = null, value, onChange, now = () => Date.now() }) {
const store = createStore({ open: false, activeIndex: -1, value: defaultValue });
const controlled = value !== undefined; // 受控与否,只取决于 value 是否为 undefined
let buffer = '';
let bufferAt = 0;
const readValue = () => (controlled ? value : store.getState().value);
const commit = (v) => {
if (!controlled) store.setState({ value: v }); // 非受控:内部自持
onChange?.(v); // 两种模式都对外通知
};
function dispatch(type, payload = {}) {
const s = store.getState();
switch (type) {
case 'OPEN': {
if (s.open) return;
const selected = items.findIndex((it) => it.id === readValue());
const active =
selected >= 0 && !items[selected].disabled ? selected : step(items, -1, 1);
return store.setState({ open: true, activeIndex: active });
}
case 'CLOSE':
if (!s.open) return;
buffer = '';
return store.setState({ open: false, activeIndex: -1 });
case 'TOGGLE':
return dispatch(s.open ? 'CLOSE' : 'OPEN');
case 'NEXT':
if (!s.open) return;
return store.setState({ activeIndex: step(items, s.activeIndex, 1) });
case 'PREV':
if (!s.open) return;
return store.setState({ activeIndex: step(items, s.activeIndex, -1) });
case 'HOME':
if (!s.open) return;
return store.setState({ activeIndex: firstEnabled(items) });
case 'END':
if (!s.open) return;
return store.setState({ activeIndex: lastEnabled(items) });
case 'POINTER_ENTER': {
const i = payload.index;
if (!s.open || items[i].disabled || i === s.activeIndex) return;
return store.setState({ activeIndex: i });
}
case 'TYPE': {
if (!s.open) dispatch('OPEN');
const t = now();
buffer = t - bufferAt > 500 ? payload.char : buffer + payload.char;
bufferAt = t;
// APG 约定:连按同一字符 = 在匹配项之间轮转,而非拼成一个长串
const repeated = [...buffer].every((c) => c === buffer[0]);
const cur = store.getState().activeIndex;
const next = typeahead(items, repeated ? buffer[0] : buffer, repeated ? cur : -1);
if (next === -1 || next === cur) return;
return store.setState({ activeIndex: next });
}
case 'SELECT': {
const it = items[store.getState().activeIndex];
if (!it || it.disabled) return;
commit(it.id);
dispatch('CLOSE');
return;
}
default:
throw new Error(`unknown event: ${type}`);
}
}
return { dispatch, subscribe: store.subscribe, getState: store.getState, readValue };
}
配上自检(同一文件末尾追加断言),实测输出:
sql
--- ① 细粒度订阅:高亮变化不该惊动整棵组件树 ---
OPEN 后:根=1 列表=1 值=0
连按 3 次 ↓ 后:根=1 列表=4 值=0
✓ 高亮移动未触发根组件重渲染 → 1
✓ 列表按需重渲染 4 次(OPEN+3) → 4
✓ 选中值未变 → 显示区 0 次 → 0
--- ② 键盘导航:循环 + 跳过 disabled + Home/End ---
✓ 打开时高亮首个可用项 Apple → 0
Banana →↓ 应跳过 Cherry(disabled): Coconut
✓ 跳过 disabled 落到 Coconut → 3
✓ End 落到最后一个可用项 Fig → 5
✓ 末尾再 ↓ 循环回 Apple → 0
✓ 开头 ↑ 循环到 Fig → 5
✓ 未展开时 ↓ 不改变任何状态 → -1
--- ③ 打字定位(typeahead)---
✓ 键入 'd' 定位 Date → 4
✓ 500ms 内 'd'+'o' 累积成 'do' → 无匹配,高亮原地不动 → 4
✓ 键入 'c' 跳过 disabled Cherry → Coconut → 3
✓ 连按同一字符 = 匹配项间轮转,无其它 c 项 → 原地 → 3
✓ 超时后缓冲重置,键入 'f' → Fig → 5
--- ④ 无效状态变更不广播 ---
✓ 重复悬停同一项只通知 1 次(悬停 disabled 项亦被忽略) → 1
✓ 悬停 disabled 项不改变高亮 → 3
✓ 重复 CLOSE 静默返回 → 0
--- ⑤ 受控 / 非受控同源 ---
✓ 非受控:打开时高亮已选中的 Banana → 1
✓ 非受控:选择后内部状态更新为 Coconut → coconut
✓ 非受控:选择后菜单自动关闭 → false
✓ 受控:onChange 收到新值 → fig
✓ 受控:内部状态不被擅自改写 → fig
结果: 24 通过, 0 失败
第 ① 组数据就是难点三的答案:连按三次方向键,根组件渲染次数始终是 1,选中值订阅者 0 次 。滚动高亮这件事在真实 DOM 上只改两个 data-active 属性。
2.5 视图适配层:薄薄一层 React
内核不碰 DOM,React 层只负责"把状态翻译成属性、把事件翻译成 dispatch"。
tsx
const MenuCtx = createContext<DropdownApi | null>(null);
const useMenu = () => {
const ctx = useContext(MenuCtx);
if (!ctx) throw new Error('[ui] <Menu.Item> 必须放在 <Menu.Root> 内');
return ctx;
};
export function MenuRoot({ items, value, defaultValue, onChange, children }: MenuRootProps) {
const api = useMemo(
() => createDropdown({ items, value, defaultValue, onChange }),
[items, value, defaultValue, onChange],
);
return <MenuCtx.Provider value={api}>{children}</MenuCtx.Provider>;
}
/* 触发器:只订阅 open,高亮怎么动都与它无关 */
function Trigger({ children, ...rest }: TriggerProps) {
const api = useMenu();
const open = useSelector(api.store, (s) => s.open);
return (
<button
type="button"
aria-haspopup="listbox"
aria-expanded={open}
onMouseDown={(e) => { e.preventDefault(); api.dispatch('TOGGLE'); }}
onKeyDown={(e) => {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
api.dispatch('OPEN');
}
}}
{...rest}
>
{children}
</button>
);
}
/* 列表:焦点停在容器上,用 aria-activedescendant 表达高亮(只改属性,不搬焦点) */
function List({ children, ...rest }: ListProps) {
const api = useMenu();
const open = useSelector(api.store, (s) => s.open);
const id = useId();
if (!open) return null;
return (
<ul
id={id}
role="listbox"
tabIndex={-1}
aria-activedescendant={
api.store.getState().activeIndex >= 0
? `${id}-opt-${api.store.getState().activeIndex}`
: undefined
}
onKeyDown={(e) => {
const map: Record<string, () => void> = {
ArrowDown: () => api.dispatch('NEXT'),
ArrowUp: () => api.dispatch('PREV'),
Home: () => api.dispatch('HOME'),
End: () => api.dispatch('END'),
Escape: () => api.dispatch('CLOSE'),
Enter: () => api.dispatch('SELECT'),
};
const handler = map[e.key];
if (handler) {
e.preventDefault();
handler();
} else if (e.key.length === 1 && !e.ctrlKey && !e.metaKey) {
api.dispatch('TYPE', { char: e.key }); // 单字符 → 交给 typeahead
}
}}
{...rest}
>
{children}
</ul>
);
}
/* 选项:只订阅"我是否高亮"这个布尔值 */
function Item({ id: itemId, index, children, ...rest }: ItemProps) {
const api = useMenu();
const active = useSelector(api.store, (s) => s.activeIndex === index);
const selected = useSelector(api.store, (s) => s.value === itemId);
return (
<li
id={`${useId()}-opt-${index}`}
role="option"
aria-selected={selected}
aria-disabled={rest.disabled || undefined}
data-active={active || undefined}
onPointerEnter={() => api.dispatch('POINTER_ENTER', { index })}
onClick={() => api.dispatch('SELECT')}
{...rest}
>
{children}
</li>
);
}
export const Menu = { Root: MenuRoot, Trigger, List, Item };
两个值得单独说的实现选择:
aria-activedescendant而非"搬焦点" :虚拟焦点方案下 DOM 焦点始终停在列表容器,高亮靠改一个属性表达。省掉几十次element.focus(),也避免焦点在 DOM 里跳来跳去触发滚动的连锁反应。data-active而非style={{ background }}:高亮是纯属性切换,样式在 CSS 里用属性选择器描述。React 不需要为高亮跑一次样式计算,用户也能用 CSS 覆盖------比暴露activeColor这类 prop 更灵活、更少 API 面。
2.6 样式与主题:三层 CSS 变量 + 属性选择器
难点四的答案是把主题变成变量,而不是状态。三层 token,职责严格分离:
css
:root {
/* ① primitive 原始层:只描述"是什么颜色/尺寸",不含语义 */
--ui-blue-600: #2563eb;
--ui-radius-md: 6px;
--ui-space-2: 8px;
/* ② semantic 语义层:主题切换只改这一层,业务定制主要在这一层 */
--ui-color-primary: var(--ui-blue-600);
--ui-color-surface: #ffffff;
--ui-color-text: #111827;
/* ③ component 组件层:组件只消费这一层,不直接碰上面两层 */
--ui-menu-radius: var(--ui-radius-md);
--ui-menu-item-height: 32px;
--ui-menu-shadow: 0 8px 24px rgb(0 0 0 / 0.12);
}
/* 主题切换 = 覆盖语义层,一次重绘,零组件重渲染 */
[data-theme='dark'] {
--ui-color-surface: #111827;
--ui-color-text: #f9fafb;
}
.ui-menu {
border-radius: var(--ui-menu-radius);
background: var(--ui-color-surface);
color: var(--ui-color-text);
box-shadow: var(--ui-menu-shadow);
}
.ui-menu__item { height: var(--ui-menu-item-height); }
/* 高亮 = 一个属性,样式由 CSS 承接 */
.ui-menu__item[data-active] {
background: color-mix(in oklab, var(--ui-color-primary) 12%, transparent);
}
.ui-menu__item[aria-disabled='true'] { opacity: 0.4; pointer-events: none; }
相比 CSS-in-JS,这套方案在组件库里优势明显:样式是静态文件(可缓存、可 CDN、构建期提取、SSR 零 FOUC),主题切换只是给 <html> 换个 data-theme,不进入 React 渲染流程 。代价是失去了"样式跟着 props 走"的自由,所以需要动态值的场景(虚滚动位移、拖拽坐标)仍然用内联 style------静态用 CSS,动态用内联,这条分工线很关键。
2.7 类型:受控 + 多态的组合推导
tsx
type AsProp<C extends React.ElementType> = { as?: C };
type PropsOf<C extends React.ElementType> = React.ComponentPropsWithoutRef<C>;
type PolymorphicProps<C extends React.ElementType, P = unknown> =
AsProp<C> & P & Omit<PropsOf<C>, keyof (AsProp<C> & P)>;
export function Button<C extends React.ElementType = 'button'>({
as,
variant = 'solid',
...rest
}: PolymorphicProps<C, { variant?: 'solid' | 'ghost' }>) {
const Comp = (as ?? 'button') as React.ElementType;
return <Comp data-variant={variant} {...rest} />;
}
// 用量触发推导:
<Button as="a" href="/docs">文档</Button> // ✅ href 合法
<Button as="a" type="button">文档</Button> // ❌ type 是 button 专有属性,报错
Omit<PropsOf<C>, keyof (AsProp<C> & P)> 是整段的核心:它把"组件自己的 prop"从宿主元素的 prop 里减掉,同时保证同名 prop 由组件定义胜出(P 优先级高于宿主)。这就是"用类型把错误用法挡在编辑器里"的标准做法。
2.8 发布:双出口、按需加载,以及别把 CSS 摇掉
json
{
"sideEffects": ["*.css"],
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
},
"./styles.css": "./dist/styles.css"
},
"peerDependencies": { "react": ">=18", "react-dom": ">=18" }
}
三条工程约定:
sideEffects必须白名单 CSS ,不能写false。写false会让import 'ui/styles.css'在生产构建被 tree-shaking 摇掉------组件在、样式没了,且只在生产复现(这就是难点七的坑)。peerDependencies而非dependencies声明 React 。否则用户项目里出现两份 React,useContext拿到两个不同的 Context 实例,复合组件直接报"必须在 Root 内使用"------而代码明明写对了。这是组件库最高频的线上事故。- 每个组件一个 ESM 文件 ,构建输出保留模块结构(如 Vite/Rollup 的
preserveModules)。barrel 文件(export * from './button')虽方便,但会让 dev 环境的冷启动随组件数线性变慢(Node 要逐个解析),组件库别把成本转嫁给用户。
三、应用场景
- 企业级中后台:一套组件库服务几十个业务系统,"API 不可逆"的约束最真实。受控/非受控统一让组件既能开箱即用,又能被表单引擎、联动校验接管;三层 token 让不同产品线用同一份代码换皮肤。
- 多端 / 多框架共享 :Headless 内核最大价值所在。同一份交互逻辑(
2.4那 200 行)分别配 React 与 Vue 适配层,行为完全一致,不用维护两套菜单逻辑和两套测试。 - 无障碍合规改造 :a11y 需求(如政企、金融的合规审计)往往要求在既有组件上补齐键盘与 ARIA。把交互收进状态机后,改造变成"给内核补一条规则 + 加一条测试",而不是满世界找
onKeyDown。 - 大型列表 / 表格的交互性能:选择器订阅让"高亮一行"从"整表重渲染"降到"改两个属性"。表格行数上千时,这是能否稳定 60fps 的分水岭。
- 设计系统落地:设计稿 → token(primitive/semantic/component 三层)→ 组件,设计变更只改语义层变量,不动组件代码。
四、总结
组件库的本质不是"把 UI 封装起来",而是设计一份长期可维护的契约:API 要少而正交,状态所有权要清晰(受控只上报、非受控自持有、同值不通知),样式要能被外部定制却不污染外部(三层 token + 属性选择器),交互语义要能用测试表达(纯函数状态机)。
架构上收敛到一句话:Headless 内核(状态机 + 细粒度订阅 store)+ 薄适配层(翻译状态与事件)+ token 化样式 。其中最能立竿见影的是细粒度订阅------subscribe(selector) 让"高亮移动"只重渲染真正关心的那几个节点,而不是整棵子树;本文那段 200 行内核的自检已经把它量化了:连按三次方向键,根组件渲染 1 次,选中值订阅者 0 次。
三个吃透的标志:能解释"为什么 sideEffects: false 会让样式消失"、能说清受控模式为什么必须"只上报不落库"、能在写下 useContext 之前先想清楚"这个状态变化会唤醒多少消费者"。下一篇 Day23 从组件库走向框架性能优化,看渲染预算、列表虚拟化与更新调度如何系统性地把长任务切成帧内可完成的碎片。
运行环境
Node.js ≥ 16(内核示例零依赖,直接 node mini-headless.mjs 即可)。React 侧示例需 React ≥ 18(依赖 useSyncExternalStore)。