从这篇开始进入卷二·基础设施层。我们先看整个 monorepo 的地基——
@videojs/utils。它没有一丁点状态,全是纯函数,几乎所有包都依赖它。别小看这个工具包,它里面的设计巧思(生命周期委托给原生 API、SSR 安全、类型不丢精度)是 v10 整个工程品味的缩影。
先看导出结构:为什么没有根入口
我打开 packages/utils/package.json,发现它有 13 个子路径导出,但故意没有根入口:
./array ./dom ./events ./function ./jwt ./object
./predicate ./percent ./string ./style ./time ./number ./types
这意味着你必须写 import { listen } from '@videojs/utils/dom',不能偷懒写 from '@videojs/utils'。
一开始我觉得这有点烦,后来想明白了:这是为了 tree-shaking。每个主题是独立入口,你只用 DOM 工具,打包时就不会把 JWT 解析、时间格式化那些东西打进去。v10 对体积很敏感(后面会看到 skins 的双轨令牌、CDN 专用导出都是为了省体积),所以连 utils 都要做到「用多少付多少」。
构建配置也印证了这点:utils 用的是 neutralLibraryConfig(单一产物),不像别的包分 dev/default 两份——因为纯函数没有 __DEV__ 警告的需求。
13 个子路径分别管什么
我把这 13 个目录的职责列一下,你心里有个地图:
| 子路径 | 干什么的 |
|---|---|
./dom | 最大的模块。DOM 操作、事件监听、聚焦、Shadow DOM、popover 定位、WebKit 兼容、帧调度 |
./events | AbortSignal 组合、事件鸭子类型 |
./predicate | 基础类型谓词(isString、isNil、isObject 等) |
./object | 对象操作(shallowEqual、deepEqual、pick、omit、defaults、flatten) |
./function | 函数工具(composeCallbacks、throttle、tryCatch、noop、identity) |
./string | 字符串(大小写转换、HTML 转义、ID 生成) |
./number / ./percent / ./time | 数值/百分比/时间格式化 |
./style | CSS 长度解析 |
./types | TS 工具类型(Simplify、UnionToIntersection 这些 gadget) |
./jwt | JWT 解析(独立功能,不与其他耦合) |
./array | 数组工具(uniqBy) |
./dom 是大头,06 篇会专门拆它。这篇我先讲贯穿整个 utils 包的设计模式,用几个代表性函数说事。
模式一:生命周期委托给原生 API
这是我觉得 utils 里最值得学的一个模式。看 ./function 里的 composeCallbacks(function/compose-callbacks.ts:13-23):
export function composeCallbacks<T extends (...args: any[]) => void>(
...fns: (T | undefined | null)[]
): T | undefined {
const defined = fns.filter((fn): fn is T => !isNil(fn)); // L14
if (defined.length === 0) return undefined; // L16
if (defined.length === 1) return defined[0]; // L18
return ((...args: Parameters<T>) => {
defined.forEach((fn) => fn(...args)); // L21
}) as T;
}
这个函数把多个回调合成一个。但它有三段优化,我看的时候觉得很讲究:
- 空数组返回
undefined(L16)——调用方可以composeCallbacks(a, b)?.()安全调用,不会因为没回调而炸。 - 单个直接返回原函数(L18)——不包一层无谓的闭包,零开销。
- 多个才合成新函数(L20-22)——所有回调收相同 args 顺序调用。
这种「能不包就不包」的克制,在工具库里是好品味。
但真正的精华在 ./dom/listen.ts。这个我在 04 篇提到过,这里完整说。listen() 的实现签名(dom/listen.ts:45-53):
// L51-52 是核心
target.addEventListener(type, listener, options);
return () => target.removeEventListener(type, listener, options);
注意 options 在 add 和 remove 两处原样透传。这意味着:
- 你传
{ signal }→ 浏览器在 signal abort 时自动移除监听器。 - 你传
{ once: true }→ 浏览器自动一次性。 - 你传
{ capture: true }→ remove 时也用捕获阶段,保证配对。
listen() 自己不做任何 options 解析,完全把生命周期管理委托给原生 API。返回的 cleanup 函数闭包捕获同一份 options,保证 add/remove 配对一致。
这个设计的哲学是:浏览器已经有的能力,绝不重新发明。v10 整个清理机制(store 的 AbortControllerRegistry、feature 的事件绑定)都建立在这个 listen 之上,全靠 { signal } 让浏览器管生命周期。这是「取消作为一等公民」的基石。07 篇会深入讲。
模式二:鸭子类型 vs instanceof,各用各的地方
utils 里有个很有意思的对比:什么时候用 instanceof,什么时候用鸭子类型。
用 instanceof 的地方:./dom/predicates.ts 里的 isHTMLMediaElement(L17-19)、isHTMLVideoElement(L9-11)——直接 instanceof。因为 DOM 全局类型保证存在,instanceof 既安全又精确。
用鸭子类型的地方:./events/event-like.ts 的 isEventLike(L14-18):
export function isEventLike(value: unknown): value is EventLike {
return (
isObject(value) && 'type' in value && isString(value.type)
&& 'timeStamp' in value && isNumber(value.timeStamp) // L16
);
}
为什么这里不用 instanceof Event?源码注释说得很清楚:它要兼容 DOM Event、React SyntheticEvent、还有 React Native 的事件对象——后者根本不是真 Event。只要「长得像」(有 string 类型的 type + number 类型的 timeStamp)就认。
这里有个值得学的细节:检查是三层渐进的——isObject 先排除 null,'type' in value 检查属性存在,再校验值的类型。每步都收窄类型,让 TypeScript 的类型守卫生效。
还有一个微妙的点在 predicates.ts:5-7 的 isShadowRoot:它不仅查 nodeType === 11,还要额外 'host' in value。因为 DocumentFragment 也是 nodeType 11,必须靠 host 属性区分真正的 ShadowRoot。这种细节,不读源码根本想不到。
模式三:SSR 安全——访问全局变量前先 typeof
utils 里所有访问 navigator/window/document/CSS/HTMLElement 的地方,都先 typeof 守卫。看 ./dom/platform.ts:1-3:
export function isMacOS(): boolean {
return typeof navigator !== 'undefined' && /mac/i.test(navigator.userAgent); // L2
}
./dom/supports.ts 的能力探测也全是这套:
supportsIdleCallback() // typeof requestIdleCallback === 'function'
supportsAnchorPositioning() // typeof CSS !== 'undefined' && CSS.supports('anchor-name: --a')
supportsPopoverAPI() // typeof HTMLElement !== 'undefined' && 'popover' in HTMLElement.prototype
为什么这么讲究?因为 v10 的核心包是框架中立的,理论上要在 SSR(Node 环境,没有 DOM)下也能 import。如果不加守卫,一 import 就 ReferenceError: navigator is not defined,整个模块图就崩了。
isMacOS 还有个小细节:正则用 /mac/i,会同时命中 Mac 和 iPad(iPad OS 13+ 的 UA 报告为 Mac)。所以这个函数测的是「类 Mac 平台」,调用方要区分 macOS 和 iOS 得另查触屏标志。
模式四:泛型保留类型精度
工具函数最容易丢类型精度。utils 反其道而行,所有工具都尽量保类型。看几个例子:
identity(function/identity.ts:1-3):
export function identity<T>(value: T): T { return value; }
泛型 <T> 让返回类型和入参完全一致(不是宽化成 unknown)。用作默认映射函数(如 map(identity))、默认键选择器。
pick(object/pick.ts:8-18):返回类型精确标注为 Pick<T, K>,而且用 Object.hasOwn(ES2022)只拷贝对象自身拥有的键,忽略原型链上的。
rafThrottle(dom/raf-throttle.ts:9-30):RafThrottled<Args> 泛型让节流后的函数签名和原函数一致,不丢参数类型。
这种「工具函数不丢类型」的坚持,让 v10 的类型推导能一路传到消费者那里——比如 store 的 createSelector 能推导出 selector 的返回类型,靠的就是底层 utils 不破坏类型链。
模式五:特征检测 + 优雅降级
utils 里有很多「能用新 API 就用,不能用就 fallback」的设计。最典型的是 ./events/abort.ts 的 anyAbortSignal——这个 07 篇会详细讲,这里先提一句:它优先用 AbortSignal.any,老浏览器(Chromium ≤115)走手动 fallback。
帧调度三件套也是这路子:
animation-frame.ts:最简,requestAnimationFrame+ 返回 cancel 函数。raf-throttle.ts:trailing-coalesce 模式,一帧内多次调用只排一次 rAF,用最后一次的 args 触发。idle-callback.ts:Safari 长期不支持requestIdleCallback,fallback 用setTimeout(..., 1),还伪造一个IdleDeadline(timeRemaining: () => 50)让 callback 签名统一。
这个 idle-callback 的 fallback 我读的时候笑了——为了 API 统一,连 IdleDeadline 都造假,50ms 是个粗略的空闲时间近似值。这种「调用方完全无需关心兼容性」的设计,是好工具库的本分。
两个值得单独说的对象工具
./object 里有两个函数的设计我觉得特别用心。
defaults(object/defaults.ts:16-26):
type PartialWithUndefined<T> = { [K in keyof T]?: T[K] | undefined }; // L4
export function defaults<T extends object>(object, defaultValues: T): T {
const result = { ...defaultValues }; // L17 — 先拷全套默认值
for (const key in object) {
if (!isUndefined(object[key])) { // L20 — 只覆盖非 undefined 的
result[key as keyof T] = object[key];
}
}
return result;
}
关键在那个 PartialWithUndefined 映射类型(L4)——它显式允许 undefined 值。为什么重要?因为 { label: undefined } 在普通 Partial<T> 下也合法,但语义上要被 default 覆盖。比如 defaults({label:undefined, disabled:true}, {label:'', disabled:false}) → {label:'', disabled:true}。undefined 被当作「没设置」,而不是「设置为 undefined」。
deepEqual(object/deep-equal.ts:15-29) 有条规则:{a: 1, b: undefined} 和 {a: 1} 视为相等。源码里(L26-27)用 Object.keys(a).filter(key => !isUndefined(a[key])) 过滤掉 undefined 键,和 JSON 语义对齐。这避免了可选 props 合并时的「undefined 字段污染相等性判定」——前端开发都踩过这个坑。
小结:utils 的工程品味
读完 utils,我总结它体现了几条工程品味:
- 生命周期委托原生 API(
listen透传 options)——不重新发明轮子。 - SSR 安全(访问全局前先 typeof)——框架中立包的本分。
- 鸭子类型与 instanceof 各得其所——跨框架用鸭子,DOM 内部用 instanceof。
- 泛型保留类型精度——工具函数不破坏类型链。
- 特征检测 + 优雅降级——调用方无需关心兼容性。
- 零状态纯函数——每个主题独立入口,tree-shaking 友好。
这套品味贯穿 v10 全部代码。后面读 store、element、core 的时候,你会发现它们的设计手法都是 utils 这些模式的延伸。
下一篇我们钻进 utils 最大的子模块 ./dom,看 Shadow DOM 感知、popover 定位、交互判定这些播放器特别需要的 DOM 工具。

364

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



