videojs v10 源代码系列解读:05 · `@videojs/utils`:纯函数工具集设计

从这篇开始进入卷二·基础设施层。我们先看整个 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 兼容、帧调度
./eventsAbortSignal 组合、事件鸭子类型
./predicate基础类型谓词(isStringisNilisObject 等)
./object对象操作(shallowEqualdeepEqualpickomitdefaultsflatten
./function函数工具(composeCallbacksthrottletryCatchnoopidentity
./string字符串(大小写转换、HTML 转义、ID 生成)
./number / ./percent / ./time数值/百分比/时间格式化
./styleCSS 长度解析
./typesTS 工具类型(SimplifyUnionToIntersection 这些 gadget)
./jwtJWT 解析(独立功能,不与其他耦合)
./array数组工具(uniqBy

./dom 是大头,06 篇会专门拆它。这篇我先讲贯穿整个 utils 包的设计模式,用几个代表性函数说事。

模式一:生命周期委托给原生 API

这是我觉得 utils 里最值得学的一个模式。看 ./function 里的 composeCallbacksfunction/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;
}

这个函数把多个回调合成一个。但它有三段优化,我看的时候觉得很讲究:

  1. 空数组返回 undefined(L16)——调用方可以 composeCallbacks(a, b)?.() 安全调用,不会因为没回调而炸。
  2. 单个直接返回原函数(L18)——不包一层无谓的闭包,零开销。
  3. 多个才合成新函数(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.tsisEventLike(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-7isShadowRoot:它不仅查 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 反其道而行,所有工具都尽量保类型。看几个例子:

identityfunction/identity.ts:1-3

export function identity<T>(value: T): T { return value; }

泛型 <T> 让返回类型和入参完全一致(不是宽化成 unknown)。用作默认映射函数(如 map(identity))、默认键选择器。

pickobject/pick.ts:8-18:返回类型精确标注为 Pick<T, K>,而且用 Object.hasOwn(ES2022)只拷贝对象自身拥有的键,忽略原型链上的。

rafThrottledom/raf-throttle.ts:9-30RafThrottled<Args> 泛型让节流后的函数签名和原函数一致,不丢参数类型。

这种「工具函数不丢类型」的坚持,让 v10 的类型推导能一路传到消费者那里——比如 store 的 createSelector 能推导出 selector 的返回类型,靠的就是底层 utils 不破坏类型链。

模式五:特征检测 + 优雅降级

utils 里有很多「能用新 API 就用,不能用就 fallback」的设计。最典型的是 ./events/abort.tsanyAbortSignal——这个 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),还伪造一个 IdleDeadlinetimeRemaining: () => 50)让 callback 签名统一。

这个 idle-callback 的 fallback 我读的时候笑了——为了 API 统一,连 IdleDeadline 都造假,50ms 是个粗略的空闲时间近似值。这种「调用方完全无需关心兼容性」的设计,是好工具库的本分。

两个值得单独说的对象工具

./object 里有两个函数的设计我觉得特别用心。

defaultsobject/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」。

deepEqualobject/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,我总结它体现了几条工程品味:

  1. 生命周期委托原生 APIlisten 透传 options)——不重新发明轮子。
  2. SSR 安全(访问全局前先 typeof)——框架中立包的本分。
  3. 鸭子类型与 instanceof 各得其所——跨框架用鸭子,DOM 内部用 instanceof。
  4. 泛型保留类型精度——工具函数不破坏类型链。
  5. 特征检测 + 优雅降级——调用方无需关心兼容性。
  6. 零状态纯函数——每个主题独立入口,tree-shaking 友好。

这套品味贯穿 v10 全部代码。后面读 store、element、core 的时候,你会发现它们的设计手法都是 utils 这些模式的延伸。

下一篇我们钻进 utils 最大的子模块 ./dom,看 Shadow DOM 感知、popover 定位、交互判定这些播放器特别需要的 DOM 工具。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值