03 篇用
PlayButtonCore介绍过「无头 Core」的概念,这篇深入它的全貌。我会用三个代表性 Core 做对比:PlayButtonCore(03 篇讲过,简单按钮)、SliderCore(滑块,纯计算型)、CaptionsRadioGroupCore(列表型,内部有响应式状态)。看完你会发现这个「纯逻辑组件」范式有清晰的分层规则。
范式的统一契约
先看 core/src/core/ui/types.ts(36 行)定义的共享类型:
// types.ts:4-6 — 所有 *-data-attrs.ts 的约束来源
export type StateAttrMap<State> = { [Key in keyof State]?: string };
// types.ts:9-13 — UI 组件的最小契约
export interface UIComponent<State> {
getState(): State;
setProps?(props): void;
getAttrs?(state): object;
}
// types.ts:16-18 — 媒体感知的组件加一个 setMedia
export interface MediaUIComponent<State> extends UIComponent<State> {
setMedia(media: object): void;
}
// types.ts:25-29 — 按钮型再加响应式状态和 getLabel
export interface MediaButtonComponent<...> extends MediaUIComponent<...> {
readonly state: State<ComponentState>;
getLabel(state): Text | string;
}
一个梯度递进的契约族:UIComponent(getState/setProps/getAttrs)→ MediaUIComponent(+setMedia)→ MediaButtonComponent(+响应式 state/getLabel)。
还有类型抽取器(L32-35):InferMediaState(从 setMedia 参数提取)、InferComponentState(从 getState 返回值提取)——平台层包装时用它们拿到精确类型,不用手写。
三种 Core 形态
我读完多个 Core 类后发现,它们分成三种形态,复杂度递增。
形态一:纯逻辑按钮(PlayButtonCore)
03 篇讲过:createState 持有状态、getAttrs 产出 ARIA、toggle 是动作。内部有响应式状态(readonly state = createState(...)),但状态由外部推入(getState() 里 patch)。
形态二:纯计算器(SliderCore)
core/src/core/ui/slider/slider-core.ts(194 行)是另一种形态——完全无状态计算。
它的输入输出分离得很干净:
// slider-core.ts:31-42 — 输入由外部 DOM controller 推入
export interface SliderInput {
pointerPercent: number; // 指针位置百分比
dragPercent: number; // 拖拽位置
dragging: boolean;
pointing: boolean;
focused: boolean;
}
// 注释 L30:"typically provided by a DOM controller"
键盘语义在 props 层声明(L12-14 的 JSDoc 明确写 “arrow keys”/“Page Up/Down keys”),DOM 层执行——Core 只算,不收事件。
核心全是纯函数(我挑几个):
// slider-core.ts:145-149 — 百分比转值,带步长取整
valueFromPercent(percent) {
const raw = min + (percent / 100) * (max - min);
return roundToStep(clamp(raw, min, max), step, min);
}
// slider-core.ts:157-161 — 值转百分比,防除零
percentFromValue(value) {
if (max === min) return 0; // L159
return ((value - min) / (max - min)) * 100;
}
还有一对「取整/不取整」的函数:valueFromPercent(L145,吸附步长——释放时用)和 rawValueFromPercent(L152,只 clamp 不取整——拖动跟手时用)。拖动中要实时跟随(不吸附),释放才吸附到步长。这种细节就是好交互的来源。
adjustPercentForAlignment(L177-186)处理 thumb 对齐:thumbAlignment === 'edge' 模式下,把百分比映射进 [thumbHalf, 100 - thumbHalf] 区间——保证拇指不越出轨道。
getAttrs(L131-143)产出 role: 'slider'、tabIndex: disabled ? -1 : 0、aria-valuemin/max/now——键盘可达性全靠 ARIA 声明,键盘事件在 DOM 层。
形态三:内部持有 slice 的列表组件(CaptionsRadioGroupCore)
core/src/core/ui/captions-radio-group/captions-radio-group-core.ts(147 行)最复杂——内部有 createState,还要从 media 派生列表。
// captions-radio-group-core.ts:56-63 — 内部响应式状态
readonly state = createState<...>({ value: 'off', availability: 'unavailable', /* ... */ });
它的 getState()(L98-120)做派生:
// 简化
const showingIndex = captionTracks.findIndex((track) => track.mode === 'showing'); // L101
// 每项 value: track.id || String(index) L103 — id 缺失回退索引
// availability: 有轨道即 available L107-108
// disabled: props.disabled || length === 0 L114
this.state.patch({ /* 数据 */ }); // L110-116
this.state.patch({ label: resolveText(...) }); // L117 — i18n 文本此刻定稿
注意两次 patch 的分离:先 patch 数据,再 patch 解析后的 label——因为 label 依赖刚 patch 进去的数据。
标签回退链(L35-39)也很实用:track.label → track.language → kind === 'captions' ? captionsText : subtitlesText——轨道没名字时用语言码,连语言都没有就用「字幕」默认文案。
select(media, value)(L122-136)的处理链:props disabled return → 空列表 return → 'off' → media.selectSubtitlesTrack('off') → 值不存在 return → media.selectSubtitlesTrack(value)。
三种形态的选型规则
| 形态 | 代表 | 内部状态 | 适用 |
|---|---|---|---|
| 纯逻辑按钮 | PlayButtonCore | createState(外部推) | 简单开关型控件 |
| 纯计算器 | SliderCore | 无(#input 外部设) | 数值映射型控件 |
| 持状态列表 | CaptionsRadioGroupCore | createState + media 派生 | 需要从 media 派生列表的控件 |
共同点:零 DOM。三种形态都不 import DOM API,都能在 html 和 react 两个平台复用。
label 解析的四态压缩
core/src/core/ui/utils/resolve-label.ts(13 行):
export function resolveLabel<State>(label, state) {
if (isFunction(label)) return label(state) || undefined; // L8-10
return label || undefined; // L11
}
把 Text | string | ((state) => Text | string) | undefined 四态压成 Text | string | undefined。label 可以是常量、i18n Text、或根据状态计算的函数(如播放按钮的 label 根据 paused 返回播放/暂停)。SliderCore 和 CaptionsRadioGroupCore 的 getLabel 都先走它,再各自回退默认文案。
和平台层的分工
回忆 04 篇的渲染链:平台元素在 update() 里调 core.setMedia(media) → core.getState() → 把结果写到 DOM。Core 只负责「算」,平台负责「画」。
- html(44 篇详讲):
PlayButtonElement extends MediaButtonElement<PlayButtonCore>,update 里 applyStateDataAttrs。 - react(49 篇详讲):
createMediaButton工厂包装同一个 Core。
这篇先埋个引子,卷六卷七展开。
小结
无头 UI Core 是「哲学一」(03 篇)的落地。带走几个点:
- 契约族递进——UIComponent → MediaUIComponent → MediaButtonComponent。
- 三种形态——纯逻辑按钮 / 纯计算器 / 持状态列表,按需选择。
- 输入输出分离——SliderCore 的
SliderInput由 DOM controller 推入,Core 不收事件。 - 取整/不取整分离——拖动跟手用 raw,释放吸附用 step。
- label 四态压缩——
resolveLabel统一处理。 - i18n 在 getState 定稿——patch 数据后再 patch 解析的 label。
下一篇讲 Core 和皮肤之间的桥——data-attrs 序列化和 CSS 变量。
316



被折叠的 条评论
为什么被折叠?



