在Redux或者Zustand这类状态管理方案里,选择器(selector)几乎是绕不开的概念。它的职责很单纯:从整个状态树中挑出组件真正需要的那一小块数据。但真正把选择器写好并不容易,除了要处理记忆化带来的缓存失效问题,还要保证选择器的输入输出类型能准确推导,避免在业务代码里到处写as断言。这篇文章就从TypeScript类型级编程的角度,聊聊怎么给选择器构建一套既安全又省心的类型体系。

选择器为什么需要记忆化:先搞清楚问题本质
先看一个最朴素的选择器写法。假设有一个电商应用的状态树,包含商品列表和过滤条件,我们需要派生出“过滤后的商品”这个数据:
interface Product {
id: number;
name: string;
price: number;
category: string;
}
interface AppState {
products: Product[];
filter: {
category: string | null;
maxPrice: number | null;
};
}
// 未记忆化的选择器:每次调用都返回新数组
const selectFilteredProducts = (state: AppState): Product[] => {
return state.products.filter(p =>
state.filter.category === null || p.category === state.filter.category
);
};这段代码在类型上是没问题的,但有一个隐蔽的性能陷阱:filter每次执行都会产生一个全新的数组引用。如果这个选择器被用在useSelector里,React-Redux默认使用严格相等比较来判断是否需要重新渲染,引用一变,组件就会被判定为“数据变了”,哪怕数组里的内容一个字节都没改。组件树一旦深起来,这种无意义的渲染会迅速堆积。
记忆化的核心思路是:缓存上一次的输入和输出,如果输入没变(浅比较相等),就直接返回上次的结果,保证引用稳定。Reselect库就是这个思路的经典实现。但问题在于,一旦引入记忆化,类型推导的复杂度也随之上升——选择器可能由多个输入选择器组合而成,每个输入的返回类型都要参与最终类型的计算,手写类型几乎不可维护,这时候就必须依赖TypeScript的类型级编程能力。
用泛型和条件类型构建类型安全的选择器工厂
手写每个选择器的类型既重复又容易出错,更优雅的做法是写一个选择器工厂函数,让TypeScript自动完成推导。先从一个简单的记忆化实现开始:
function createSelectorSimple<S, R>(
input: (state: S) => R,
compute: (input: R) => R
): (state: S) => R {
let lastInput: R | undefined;
let lastOutput: R | undefined;
return (state: S): R => {
if (lastInput !== undefined && lastInput === input(state)) {
return lastOutput!;
}
lastInput = input(state);
lastOutput = compute(lastInput);
return lastOutput;
};
}这个实现里,<S, R>两个类型参数分别代表状态类型和返回值类型。TypeScript会根据传入的实际函数自动推导它们,使用者不需要显式标注。这就是类型级编程最基础的形态:用泛型把类型信息“流动”起来。
但真实场景中的选择器往往是多输入的。比如计算“购物车总金额”,既要读商品列表,又要读购物车的数量映射。这时可以用元组类型配合infer来提取每个输入选择器的返回类型:
type SelectorReturns<T extends readonly unknown[]> = {
[K in keyof T]: T[K] extends (state: any) => infer R ? R : never;
};
function createSelector<
State,
Inputs extends Array<(state: State) => unknown>
>(
...inputs: Inputs
) {
return function <Result>(
compute: (...args: SelectorReturns<Inputs>) => Result
): (state: State) => Result {
let cache: { inputs: unknown[]; result: Result } | null = null;
return (state: State): Result => {
const currentInputs = inputs.map(fn => fn(state));
if (cache && cache.inputs.every((v, i) => v === currentInputs[i])) {
return cache.result;
}
cache = { inputs: currentInputs, result: compute(...currentInputs) };
return cache.result;
};
};
}这里的SelectorReturns是关键。它是一个mapped type,遍历元组Inputs的每一个位置,用条件类型加infer R把每个输入选择器的返回类型“抽”出来,组成一个新的元组类型。这样一来,compute函数的参数类型就能被精确推导——第一个参数是什么类型、第二个参数是什么类型,全部由输入选择器决定,写错参数类型会直接在编译期报错。
这种方式的收益非常直观:选择器的组合逻辑变化时,类型自动跟着变,不需要人工同步维护。代价是类型代码的初始编写门槛较高,团队里如果没人熟悉泛型推导,出问题排查起来会比较吃力。建议在公共基础库中集中封装这类工具类型,业务层只消费现成的工厂函数。
深入类型推导:递归类型与深层状态的提取
状态树往往嵌套很深,直接对每个层级手写选择器非常枯燥。借助递归的条件类型,我们可以让类型系统自动“理解”状态树的结构。比如定义一个工具类型,自动为状态树的每个叶子路径生成选择器类型:
type Primitive = string | number | boolean | null | undefined;
// 递归提取所有可到达的叶子路径
type LeafPaths<T, Prefix extends string = ""> = T extends Primitive
? Prefix
: {
[K in keyof T & string]: LeafPaths<
T[K],
Prefix extends "" ? K : `${Prefix}.${K}`
>
}[keyof T & string];
type AppLeafPaths = LeafPaths<AppState>;
// 推导结果: "products" | "filter.category" | "filter.maxPrice"这段代码综合运用了三个类型级编程技巧:递归类型让LeafPaths可以深入任意嵌套层级;模板字面量类型`${Prefix}.${K}`用字符串拼接的方式构造路径名;索引访问[keyof T & string]把对象所有属性的映射结果合并成一个联合类型。最终AppLeafPaths是一个由所有合法路径字符串组成的联合类型。
有了这个联合类型,就能实现类型安全的“路径选择器”:
function selectByPath<State, Path extends LeafPaths<State>>(
path: Path
): (state: State) => PathToType<State, Path> {
return (state: State) => {
return path.split(".").reduce((obj, key) => (obj as any)[key], state);
};
}
const selectCategory = selectByPath<AppState>("filter.category");
// 返回类型自动推导为 string | null调用selectByPath时,如果传入的路径字符串不在联合类型范围内,编译器立刻报错,从源头上杜绝了拼错路径导致的运行时undefined问题。PathToType的实现同样是递归条件类型,通过infer逐段拆解路径字符串,每拆一段就深入一层对象类型,最终精确返回叶子节点的类型。值得注意的是,这类递归类型在状态树特别庞大时可能触发TypeScript的递归深度限制,实际项目里要控制状态树的层级,或者对超深的部分单独处理。
记忆化的边界问题与类型层面的防护
记忆化不是银弹,它有明确的适用边界。第一个坑是输入选择器返回对象或数组的情况——如果上游输入本身就是每次都新建的引用,缓存比较永远不相等,记忆化完全失效。第二个坑是缓存大小,Reselect默认只缓存一组输入输出,多个组件使用同一个参数化选择器时会互相顶掉缓存。
类型层面其实可以给出一些防护。比如用条件类型对compute函数的参数做约束,当检测到参数类型是对象时,在文档类型上给出提示:
type WarnOnUnstableInput<T> = T extends object
? { __warning: "对象输入需要保证引用稳定,否则记忆化会失效" }
: T;
// 在开发环境下对不稳定输入打标记
type CheckInputs<Args extends unknown[]> = {
[K in keyof Args]: WarnOnUnstableInput<Args[K]>;
};当然,更实用的策略是在运行时配合shallowEqual做浅比较,类型系统负责静态约束,运行时库负责动态校验,两者配合才能形成完整的防护。另外,对于参数化选择器(比如根据商品ID查询详情),建议采用工厂模式让每个调用方持有独立的选择器实例,避免共享缓存:
function makeProductByIdSelector(id: number) {
return createSelector(
(state: AppState) => state.products
)((products): Product | undefined =>
products.find(p => p.id === id)
);
}
// 组件内部用useMemo保证选择器实例稳定
// const selector = useMemo(() => makeProductByIdSelector(props.id), [props.id]);总结来看,TypeScript的类型系统在状态管理中远不只是“标注一下类型”这么简单。通过泛型推导、条件类型、映射类型和模板字面量类型的组合,可以把选择器的输入输出关系、路径合法性、记忆化约束等信息全部编码进类型层,让大量潜在错误在编译期就被拦截。代价是工具类型的编写门槛和一定的编译开销,因此在架构上建议把类型工具集中到基础模块,业务开发者只需要享受类型推导带来的便利。当类型系统和运行时记忆化各司其职,选择器这一层就能真正做到既高性能又高可维护。
TypeScript类型系统选择器记忆化修改时间:2026-09-06 13:26:55