将引用稳定性变为一种类型

Hacker News Top 工具

摘要

一篇关于使用幻影品牌将引用稳定性编码为 TypeScript 类型的博客文章,从而使 React props 能够强制要求数组、回调函数和对象在多次渲染中保持稳定。

暂无内容
查看原文
查看缓存全文

缓存时间: 2026/07/31 20:00

# 将引用稳定性变成类型的一部分 来源: https://jovidecroock.com/blog/referential-stability-types/ 写于 2026 年 7 月 24 日 如果你在任何一个有一定规模的 \(P\)React 代码库上工作过,你肯定已经无数次经历过关于引用稳定性的讨论。有人加了一个 `useMemo`,另一个人加了一个 `useCallback`,一条 exhaustive-deps 警告被静默掉,然后我们都祈祷传给下一个组件的 prop 是稳定的。代码通常是对的,但对于这种隐式的稳定性,我们并没有得到完全的保证。 我花了太多时间在排查一个重新渲染或副作用执行上——最后发现原因不过是把一个新建的数组字面量传给了被 memo 化的子组件。类型声明是 `Item[]`,一个 `Item[]` 类型的 prop 告诉我的是类型的形状以及里面*装了什么*,却完全没有告诉我下一次渲染时我拿到的是不是一个稳定的数组。 所以我一直在琢磨一个小想法:如果引用稳定性的意图本身就是类型的一部分呢? ## 类型本身 整个方案建立在一个私有的幻影品牌(phantom brand)之上: `` declare const stableBrand: unique symbol type Stable = T extends object ? T & { readonly [stableBrand]: true } : T `` 对象、数组和函数会被打上品牌标记。原始类型直接穿透,因为 React 和 Preact 已经按值比较它们了,一个 `string` 永远是"足够稳定"的。 这里的核心是 `unique symbol`。如果我用的是一个字符串键比如 `_stable: true`,那么任何恰好具有这种形状的对象都会意外地满足品牌要求,而且迟早会有人写出 `{ _stable: true }`。一个从不离开包的 `unique symbol` 无法通过应用代码的结构性赋值来伪造。你可以*命名* `Stable`,但你无法伪造它。 这就让一个 prop 能够描述比数据本身更多的东西: `` type ItemListProps = { items: Stable onSelect: Stable<(id: string) => void> title: string } `` 数组和回调是以"稳定"这一意图构建出来的引用,所以它们携带品牌。现在,内联构建的数组就是错误的类型了: `` select(id)} title="Visible items" /> `` 组件边界把要求推回给了调用方,这正是它应该在的地方。 这也是我们忘记的 `memo()` 和 `PureComponent` 的另一半。你把组件包进 `memo`,然后假设没有值变化它就不会重新渲染,然而复杂类型的稳定性并不是一种保证,这可能导致这个优化被完全抵消。 ## 保留 hooks,替换导入 我第一版尝试生成这些证明用的是模块增强(module augmentation)。你以副作用的方式导入这个包,它会增强 React 的 hook 类型,当所有依赖都稳定时,`useMemo` 开始给结果打上品牌标记。 它只成功了一半,而那没成功的一半值得理解,因为在我深入研究*原因*之前,它也让我觉得奇怪。 模块增强允许你*添加*重载到某个声明上,它永远不会让你移除或替换 `@types/react` 自带的重载。增强之后,`useMemo` 有两个重载并存:严格的 `(factory, deps: StableDeps) => Stable`,以及 React 原始的 `(factory, deps: DependencyList) => T`。TypeScript 会先尝试严格的那个,当每个依赖都携带证明时,很好,你得到 `Stable`。但一旦某个依赖不满足,严格重载就停止匹配,解析过程会悄悄落回 React 的原始重载,而它愉快地接受任何 `DependencyList`: `` const unstable = {} const value = useMemo(() => ({ answer: 42 }), [unstable]) // 没有错误。value 只是 { answer: number },没有品牌标记 `` 依赖列表这里没有报错——而这正是我想要报错的地方。证明只是没有被产生出来。 所以我放弃了这条路。严格路径是一个独立的入口: `` import { useCallback, useEffect, useMemo } from 'stableref/react' `` 这些在运行时就是真正的 React hooks,只是暴露在更严格的类型声明之下。依赖元组会被推断出来,每个未经验证的元素在期望类型中会被替换为一个可操作的字符串字面量。因为这个入口不包含 React 那种宽松的重载,没有任何东西可以回退,裸引用会在写它的那个依赖处直接失败: `` const stableFilter = useCallback(filter, [query]) useMemo(() => source.filter(stableFilter), [source, stableFilter]) // Stable useMemo(() => source.filter(rawFilter), [source, rawFilter]) // ^ 类型错误 useEffect(syncSelection, [selection, page]) // ^ 当 page 是未经验证的对象时,类型错误 `` 这个包不提供增强模式。静默丢失证明太容易被误认为是真正的强制执行了。从 `stableref/react` 导入会让有问题的依赖列表在你写它的地方失败,而不是在下游的某个地方。 ## 为 React 已经保证的东西加冕 不是每个稳定引用都来自 `useMemo`。React 免费给你几个,类型可以直接说明这一点: `` const [state, setState] = useState(initialState) // state: Stable // setState: Stable>> const element = useRef(null) // element: Stable> `` `Stable` 值得一点澄清,因为乍一看它像在说谎。它*并不*意味着状态永远不会变化。它的意思是,React 不会为没有变化的状态制造一个新的身份。真正的状态转换仍然应该使 memo 失效并重新运行副作用,这正是状态的全部意义。品牌关乎身份,而不是不可变性。 初始化闭包也不需要证明,它们不是依赖列表: `` const [state] = useState(() => buildInitialState(props)) const [reduced] = useReducer(reducer, props, createInitialState) `` React 只会调用它们来产生初始值。这个契约给结果的状态和 dispatch 函数打上品牌,它不关心初始化闭包本身是否稳定,所以我也不要求这一点。 对于构造上就稳定(stable by construction)的模块级作用域值,有一个极简的身份辅助函数: `` export const EMPTY_ITEMS = stable([] as Item[]) `` 在运行时,`stable()` 原封不动地返回该值,它就是 `x => x`。有用的是证明,而不是函数本身。把它放在模块作用域,在那里这个声明才真正成立。 ## Context 是回报最快的地方 内联 context 值是让整个子树悄悄做不必要工作的最简单方式之一: `` {children} `` `createStableContext` 把这个要求转移到了 provider 上。它是框架特定的,所以它来自 React 入口而不是包根目录: `` import { createStableContext } from 'stableref/react' const ThemeContext = createStableContext(null) `` 现在 provider 只接受 `Stable`。拥有这个值的地方同时也拥有 memo 化它的职责,而深埋在树中的消费者永远不需要知道这个值是如何组装起来的。它只需要拿到这个契约。 这就是我希望从类型中得到的那种边界:知识停留在责任所在之处。 ## Preact 同样的想法,单独的入口,因为我当然不会把 Preact 排除在外: `` import { useEffect, useMemo, type Stable } from 'stableref/preact' `` 这些保留原始的 `preact/hooks` 引用,并应用同样的严格依赖签名。证明属于值契约的一部分,它从来都不取决于你恰好选了哪个渲染库。 ## 这个方案解决不了什么 一个下定决心的人总是可以写: `` value as Stable `` 没有任何品牌能扛过类型断言,这对于我们在 TypeScript 中建模的几乎所有保证来说都是如此。我不会为此发布一个 ESLint 插件,断言仍然是一个显式的逃生舱口,需要靠代码审查和约定来盯住。`stable()` 也是同一种承诺,所以不要到处乱撒它来让编译器闭嘴。 React Compiler 是另一件人们会提起的事情——它难道不是让手动 memo 化过时了吗?很多情况下,是的。但我认为它并不会让这个想法变得毫无意义,因为两者回答的是不同的问题。编译器问的是"我能否自动保留这个身份"。`Stable` 说的是"另一个组件可以把这个身份用作优化契约"。一个是存在于构建过程中的实现细节,另一个是存在于类型图中的契约,调用方可以看到它。 ## 类型错误作为代理的护栏 现在很多 React 代码是由编码代理(agent)编写的,而把内联数组传给 memo 化的子组件恰恰是它们整天都在做的事情。它读起来没问题,跑起来也没问题,只是悄悄做了比它应该做的更多的工作——这大概是最难用肉眼发现的那一类 bug。 代理看不到它从未渲染过的重新渲染,但它完全能够响应类型错误,那正是它已经在运行的反馈循环。 这也是为什么错误本身值得花些功夫。当依赖未被证明时,严格 hooks 不会用一个光秃秃的 `never` 来拒绝它,而是把它映射为一个说明修复方法的字符串字面量: `` useMemo(() => source.filter(rawFilter), [source, rawFilter]) // 类型 '(item: Item) => boolean' 不能赋值给类型 // '此依赖不是 Stable:使用 useMemo/useCallback 进行 memo 化,' // '从 useState 获取,依赖 useRef 容器而不是其可变的' // 'current 值,或者用 stable() 包裹一个模块作用域常量。' `` 指令随诊断信息一起出现,光标精确地落在出问题的那个依赖上。人类在编辑器里读到它,代理从 `tsc` 的输出里读到它,两者都不需要盯着"不能赋值给 never"去猜测类型系统到底想要什么。 这个检查从"希望有人能在 code review 中发现"变成了"构建保持红色,直到 props 变得稳定"——当代码生成的速度快到没人能读完时,这是唯一能站得住的护栏。 ## 更宏大的想法 我真正关心的部分比 memo 化更大。我们永远在手动搬运那些类型系统*本可以*携带的属性,却把它们留作注释、lint 规则和部落知识。引用稳定性是其中之一,但绝不是唯一的一个。 一旦它变成携带证明的,组件就可以要求它,hook 可以交出它,context 可以在整个子树中保持它。运行时的工作本来就在发生,我们本来就在调用 `useMemo`。缺失的那块拼图是让这个保证在它被制造出来的那一行之后依然存活。 我已经把这个东西打包成了 `stableref`(https://github.com/JoviDeCroock/stableref)。目前它更像一个实验而不是一个库,所以我很想听到它在哪些地方对你失效。

相似文章

解析,而非验证——在并不鼓励你这样做的语言中

Hacker News Top

一篇探讨在TypeScript中应用“解析,而非验证”原则的博客文章,展示了如何使用品牌类型(branded types)在解析后保留类型信息,尽管TypeScript的结构类型系统使得这种做法不如在Elm或Haskell等语言中那样自然。

结构正确性

Hacker News Top

一篇技术博客文章,讨论类型系统和构建系统等结构描述如何利用图结构模型来确保正确性,并强调像Bazel这样的系统的定义性本质。

TypeScript 如何分配联合类型

Lobsters Hottest

一篇深度文章,解释 TypeScript 如何在重载函数、方法接收者和条件类型中分配联合类型,并包含示例和解决方法。

用 Object.assign 移除类型断言

Lobsters Hottest

一篇博客文章,展示了如何通过使用真实的 Response 对象配合 Object.assign 来消除 TypeScript 的 `as unknown as` 类型断言,并解释了与 `unknown` 的交叉类型如何使类型检查更加安全。