进入卷二的最后两篇——
@videojs/store。这是 v10 的响应式状态基石。整个播放器的状态都建立在它之上。它有两个最特别的设计:一是「目标附加式」(状态先于 target 存在),二是跨容器的微任务批处理。这篇讲状态原语createState和 store 工厂,下一篇讲 slice/combine/selector。
先理解「目标附加式」
大多数状态库(Redux、Zustand)的状态和「消费者」是绑定的。但 v10 的 store 有个反直觉的设计:状态在 target(媒体元素/容器)存在之前就可以创建。
为什么这么设计?因为播放器经常处于「先有 UI、后加载媒体」的状态。比如页面渲染时播放按钮就显示了,但 <video> 还没 attach。这时候播放器状态(paused、currentTime)得能存在,只是值为初始默认。等媒体 attach 了,再通过 attach(target) 把状态和真实媒体连起来。
反过来,detach 把状态重置回初始值。这让「切换媒体源」变得干净——detach 旧的、attach 新的,状态自动重置。
这种「状态可以脱离 target 存在」的模型,就叫「目标附加式」(target-attached)。
createState:状态容器 + 跨容器批处理
状态原语在 store/src/core/state.ts(111 行)。核心是 StateContainer 类 + 一套模块级批处理设施。
模块级批处理设施(state.ts:20-33)
这是我觉得最值得讲的部分。看顶层这几个绑定:
let isFlushScheduled = false; // L20
function scheduleFlush(): void { // L21-25
if (isFlushScheduled) return; // L22 — 守卫
isFlushScheduled = true; // L23
queueMicrotask(flush); // L24 — 只调度一次
}
const pendingContainers = new Set<StateContainer<any>>(); // L27
export function flush(): void { // L29-33
isFlushScheduled = false; // L30 — 重置
for (const container of pendingContainers) container.flush(); // L31
pendingContainers.clear(); // L32
}
pendingContainers 是个模块级 Set,以 StateContainer 实例为键。scheduleFlush 用 isFlushScheduled 守卫,保证整个进程的 queueMicrotask(flush) 最多入队一次。
这里的精妙在于 Set 的去重:同一个容器的 patch 被调 10 次,第 2-10 次只是 add 已经存在的同一对象——不重复,flush 只遍历它一次。而且这是跨容器的——容器 A 和容器 B 的 patch 会在同一个微任务里一起 flush。
所以你在同一个同步 tick 里写 A.patch(); A.patch(); B.patch(); A.patch();,结果是 pendingContainers = {A, B},一个微任务,A 和 B 各通知监听器一次。十次 patch,两次通知。
StateContainer 类(state.ts:37-102)
三个私有字段(L38-40):
#current: T——实际值,通过Object.freeze保持不可变。#listeners = new Set<StateChange>()——订阅者集合。#pending = false——是否已脏等待 flush。
构造函数(L42-44)冻结初始对象:
this.#current = Object.freeze({ ...initial });
展开会丢弃原型并拷贝自身可枚举属性;freeze 让任何试图改 .current 的消费者静默失败(或严格模式抛错)。不可变是强制的。
patch(partial)(L50-70):
patch(partial: Partial<T>): void {
const next = { ...this.#current }; // L51 — 克隆冻结状态
let changed = false; // L53
for (const key in partial) {
if (!hasOwnProp.call(partial, key)) continue; // L56 — 跳过继承键
if (!Object.is(this.#current[key], partial[key])) { // L60 — Object.is 判变
next[key] = partial[key]; // L61
changed = true; // L62
}
}
if (changed) { // L66 — 没变就什么都不做
this.#current = Object.freeze(next);
this.#markPending();
}
}
关键:L60 用 Object.is 检测每个键是否真变(正确处理 NaN 和 ±0)。只有真变了才赋值。L66 的 if (changed)——partial 里没有任何变化时,什么都不会发生:不 freeze、不入队、不通知。
#markPending()(L97-101):
#markPending(): void {
this.#pending = true;
pendingContainers.add(this); // L99 — 加入模块级集合
scheduleFlush(); // L100 — 调度单次微任务
}
所以 patch 本身从不直接通知监听器。它只把容器注册到 pendingContainers 并确保安排一次 flush。真正的通知在微任务的 flush 里。
flush() 实例方法(L91-95):
flush() {
if (!this.#pending) return; // L92-93
this.#pending = false;
for (const fn of this.#listeners) fn(); // L94 — 通知,无参
}
监听器收不到参数——必须自己从 .current 读新值(这是 StateChange = () => void 的设计,L3)。
subscribe + AbortSignal 取消(state.ts:72-89)
subscribe(callback: StateChange, options?: SubscribeOptions): () => void {
const signal = options?.signal;
if (signal?.aborted) return noop; // L74 — 已 abort 不注册
this.#listeners.add(callback); // L76
if (!signal) return () => this.#listeners.delete(callback); // L78-80
const onAbort = () => this.#listeners.delete(callback);
signal.addEventListener('abort', onAbort, { once: true }); // L83
return () => { // L85-88
signal.removeEventListener('abort', onAbort);
this.#listeners.delete(callback);
};
}
又是 AbortSignal 取消模式(07 篇讲过)。几个细节:
- L74 已 abort 直接返回
noop(来自 utils/function),不注册。 - L83
{ once: true }——abort 监听器触发后自动从 signal 移除。 - L85-88 返回的 detach 函数双向清理——无论 abort 还是手动 detach,监听器注册表和 signal 都清干净。
createState<T>(initial)(L104-106)只是 return new StateContainer(initial) 的薄包装,返回的是 WritableState<T> 接口而非具体类。
store 工厂:柯里化 + 目标附加
store/src/core/store.ts(178 行)是 store 的核心。
为什么是两层柯里化(store.ts:13-17)
export function createStore<Target = unknown>(): <State>(
slice: Slice<Target, State>,
options?: StoreOptions<Target, State>
) => Store<Target, State> {
return <State>(slice, options = {}): Store<Target, State> => { ... };
}
两层函数的原因是类型变量分阶段绑定:调用方先 createStore<MediaEl>() 锁定 Target,再传 slice 时让 State 自动推导。如果用一个函数同时收两个泛型参数,就得手动写 State,丧失推导。零运行时成本,纯类型技巧。
Store 符号 Symbol.for('@videojs/store')(L9)是进程全局的,所以 isStore(L150-152)跨 realm/iframe 也能工作。
闭包状态(store.ts:21-28)
内层函数捕获:
let target: Target | null = null; // L21 — attach 前为 null
let destroyed = false; // L22
const setupAbort = new AbortController(); // L24 — 进程级生命周期信号
const signals = new AbortControllerRegistry(); // L25 — attach 作用域注册表
let state: WritableState<State>; // L28 — 容器,稍后赋值
注意 state 声明时未初始化(L28),在构建 slice 状态后才赋值(L45)。这种声明和赋值分离,是为了让 StateContext.get/set(L41-42)通过闭包引用稍后的 state。
构建初始状态(store.ts:30-45)
validate()(L30-33):destroyed 抛 DESTROYED 错,target 为空抛 NO_TARGET 错。
StateContext 对象(L35-43)字面量构建:
target: () => { validate(); return target! }(L36-39)——每次调都重新校验,attach 前读 target 会抛错。signals(L40)——直接引用注册表。get: () => state.current(L41)。set: (partial) => state.patch(partial)(L42)。
然后 const initialState = slice.state(ctx)(L35),state = createState(initialState)(L45)。
重要顺序:slice 的 state() 在 state = createState(...) 之前调用,但 ctx 的 get/set 关闭了 state 绑定。所以 slice.ts:43-46 的 JSDoc 明确警告:get/set「在 action 闭包内部使用是安全的(在 state() 初始化期间不安全)」——state() 初始化期间别调 ctx.set(),但你返回的 action 闭包可以稍后调。
状态键暴露为 store getter(store.ts:66-71)
for (const key of Object.keys(initialState)) { // L66
Object.defineProperty(store, key, {
get: () => state.current[key], // L68 — 始终读最新值
enumerable: true, // L69
});
}
每个状态键变成 store 上的惰性、实时访问器。每次访问 store.paused 都重新读 state.current.paused,所以批处理 flush 后的修改立即反映。enumerable: true 让键出现在 Object.keys(store) 里,匹配 Store & State 的结构类型。
这就是为什么你能直接 store.paused 而不用 store.state.paused。
attach(store.ts:81-120)—— 04 篇详细讲过,这里强调几个点
- L85
signals.reset()——在设新 target 之前 abort 所有旧附件信号,mint 全新 base。 - L91
signal: signals.base——新的 attach 作用域 base 信号。 - L104
slice.attach?.(attachContext)——触发所有 feature 的 attach(combine 扇出,12 篇讲)。 - L119 返回
detach。
detach vs destroy(store.ts:122-134)
这两个容易混,我特意区分一下:
detach()(L122-127):可恢复。signals.reset() + target = null + state.patch(initialState)——状态重置回初始。store 可以再 attach 新 target。
destroy()(L129-134):终态。destroyed = true + 调 detach() + setupAbort.abort()——终止进程级 setup 信号。之后任何 attach 都在 L82 抛错。
区别就是:detach 后还能复活,destroy 是永久的。这和 element 的 hostDisconnected vs hostDestroyed(10 篇)是同一套思想。
store 的错误处理
reportError(store.ts:140-146):有 options.onError 就调,否则 console.error('[vjs-store]', error)。未处理的 slice 错误会变成可见诊断,不会静默崩溃。combine 的 attach 扇出(12 篇)会用到它。
小结
store 的状态层有几个我觉得很值得学的设计:
- 跨容器微任务批处理——模块级
pendingContainersSet + 单次queueMicrotask(flush),同一 tick 多次 patch 只通知一次。 - 不可变强制——
Object.freeze+ 每次 patch 克隆。 - Object.is 判变——partial 里没真变就不入队、不通知。
- AbortSignal 取消订阅——双向清理防泄漏。
- 目标附加式——状态先于 target 存在,attach/detach 驱动 setup/teardown。
- detach 可恢复 vs destroy 永久——和 element 的双层销毁语义一致。
- 状态键实时 getter——
store.paused直接读最新值。
下一篇讲 slice 契约、combine 组合、selector 切片、以及 AbortControllerRegistry 的完整模型(07 篇讲过 utils 基础,12 篇讲 store 怎么用它)。
334



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



