在实现前端状态管理的撤销重做功能时,我们通常需要维护一个操作历史栈,记录每一次状态变更的前后快照,同时还要支持指针移动来实现撤销和重做。传统的做法是在运行时对栈的内容做校验,但是类型层面无法约束栈内元素的顺序、容量或者状态之间的关联关系,很容易出现将无效状态推入栈中或者指针越界的问题。借助TypeScript的类型级编程能力,我们可以在编译阶段就定义好撤销重做栈的类型规则,让不符合要求的代码直接无法通过类型检查。

撤销重做栈的基础类型定义
首先需要明确撤销重做栈的核心结构,它通常包含三个部分:历史状态数组、当前指针位置、可选的最大容量限制。在类型层面,我们需要用类型变量来捕获这些信息的类型,同时使用条件类型和映射类型来约束它们的关系。最基础的状态类型可以是任意用户定义的状态结构,但是栈内的状态序列需要满足顺序性,即指针指向的位置必须是历史数组中的有效索引。
我们可以先定义一个泛型接口来描述撤销重做栈的基础结构,其中历史数组的类型需要是一个只读数组,避免运行时被意外修改,指针类型需要是数字字面量类型或者number类型,但是通过类型运算可以约束指针不能超过历史数组的长度减一。如果用户没有指定最大容量,那么默认不限制,否则需要校验推入新状态后历史数组的长度是否超过最大容量,超过则需要截断旧的记录。
下面是一个基础的类型定义示例,使用泛型参数来捕获状态类型、历史数组和指针信息:
// 基础状态类型,用户可以传入任意状态结构
type State = any;
// 撤销重做栈的基础类型定义
type UndoRedoStack<S extends State, History extends S[] = [], Pointer extends number = 0> = {
history: readonly [...History];
pointer: Pointer;
maxSize?: number;
};
// 示例:初始化一个空栈,状态类型为{ count: number }
type InitialStack = UndoRedoStack<{ count: number }>;
// 对应的初始值结构
const initialStack: InitialStack = {
history: [],
pointer: 0,
};
类型级栈操作的逻辑实现
撤销重做栈的核心操作有推入新状态、撤销、重做三个,每个操作都需要在类型层面做对应的校验和推导。推入新状态的时候,需要判断当前指针是否指向历史数组的末尾,如果不是,说明之前有过撤销操作,此时推入新状态需要截断指针之后的历史记录,避免重做时出现无效状态。同时如果设置了最大容量,需要判断推入后历史数组的长度是否超过最大容量,超过则需要移除最早的记录。
我们可以先实现推入新状态的类型逻辑,这里需要使用TypeScript的类型递归和条件判断。首先判断当前指针是否等于历史数组的长度减一,如果不是,就截取历史数组从0到指针位置的部分,然后追加新状态;如果是,就直接追加新状态。之后判断新的历史数组长度是否超过最大容量,如果超过,就移除数组的第一个元素。指针的位置需要更新为新历史数组的长度减一。
下面是推入新状态的类型实现示例,使用条件类型和数组方法类型来完成逻辑:
// 辅助类型:判断两个数字字面量类型的大小
type GreaterThan<A extends number, B extends number,
Acc extends any[] = []> = Acc['length'] extends A
? false
: Acc['length'] extends B
? true
: GreaterThan<A, B, [0, ...Acc]>;
// 辅助类型:截取数组的前N个元素
type Take<T extends any[], N extends number,
Acc extends any[] = []> = Acc['length'] extends N
? Acc
: T extends [infer First, ...infer Rest]
? Take<Rest, N, [First, ...Acc]>
: Acc;
// 辅助类型:移除数组的第一个元素
type Shift<T extends any[]> = T extends [any, ...infer Rest] ? Rest : [];
// 推入新状态的类型操作
type PushState<
Stack extends UndoRedoStack<any, any, any>,
NewState
> = Stack extends UndoRedoStack<infer S, infer H, infer P>
? {
// 判断指针是否指向末尾,如果不是则截断后面的历史
newHistory: P extends H['length'] - 1
? [...H, NewState]
: [...Take<H, P & 0 extends 0 ? 0 : P, []>, NewState];
// 判断最大容量
maxSize: Stack['maxSize'];
} extends { newHistory: infer NH; maxSize: infer MS }
? MS extends number
? NH extends any[]
? GreaterThan<NH['length'], MS> extends true
? UndoRedoStack<S, Shift<NH>, NH['length'] - 2>
: UndoRedoStack<S, NH, NH['length'] - 1>
: never
: UndoRedoStack<S, NH, NH['length'] - 1>
: never
: never;
// 示例:推入新状态
type Stack1 = UndoRedoStack<{ count: number }, [{ count: 0 }], 0>;
type Stack2 = PushState<Stack1, { count: 1 }>;
// Stack2的history为[{ count: 0 }, { count: 1 }],pointer为1
撤销与重做的类型推导
撤销操作的类型是让指针减一,同时需要校验指针是否大于0,否则无法撤销;重做操作是让指针加一,需要校验指针是否小于历史数组的长度减一,否则无法重做。这两个操作的类型逻辑相对简单,但是需要保证操作后的栈类型仍然是合法的UndoRedoStack类型,并且如果用户尝试在无法撤销或重做的时候调用对应操作,类型系统应该报错。
我们可以分别定义撤销和重做的类型,使用条件类型判断指针的合法性,合法则返回新的栈类型,不合法则返回never类型,这样在类型层面就可以阻止非法操作。同时,获取当前状态的操作可以根据指针位置从历史数组中取出对应的状态,类型层面可以直接推导出当前状态的类型,不需要额外的类型断言。
下面是撤销和重做的类型实现示例:
// 撤销操作类型
type Undo<Stack extends UndoRedoStack<any, any, any>> =
Stack extends UndoRedoStack<infer S, infer H, infer P>
? P extends 0
? never // 指针为0,无法撤销
: UndoRedoStack<S, H, P - 1>
: never;
// 重做操作类型
type Redo<Stack extends UndoRedoStack<any, any, any>> =
Stack extends UndoRedoStack<infer S, infer H, infer P>
? H['length'] extends P | (P & 0 extends 0 ? 0 : P) // 判断指针是否已经是末尾
? never // 指针在末尾,无法重做
: UndoRedoStack<S, H, P + 1>
: never;
// 获取当前状态的类型
type CurrentState<Stack extends UndoRedoStack<any, any, any>> =
Stack extends UndoRedoStack<any, infer H, infer P>
? H[P]
: never;
// 示例:撤销操作
type StackA = UndoRedoStack<{ count: number }, [{ count: 0 }, { count: 1 }], 1>;
type StackB = Undo<StackA>; // 指针变为0,当前状态为{ count: 0 }
type StackC = Undo<StackB>; // 报错,无法再撤销
// 示例:重做操作
type StackD = Redo<StackB>; // 指针变回1,当前状态为{ count: 1 }
type StackE = Redo<StackD>; // 报错,无法再重做
实际场景中的类型安全验证
在实际的状态管理场景中,比如结合React或者Vue使用的时候,我们可以将定义好的类型级撤销重做栈和用户的状态管理逻辑结合起来,确保每一次状态变更都符合栈的规则。比如在React的useReducer中,我们可以把状态的类型定义为UndoRedoStack,reducer中的每一个操作都对应类型级的栈操作,这样在编写代码的时候,如果出现非法的操作,TypeScript就会直接提示错误,而不是等到运行时才发现问题。
我们可以写一个实际的React组件示例,使用前面定义的类型来约束状态和操作。在组件中,初始状态是符合UndoRedoStack类型的空栈,每次点击增加按钮的时候,调用PushState类型对应的逻辑推入新状态,点击撤销和重做按钮的时候,分别调用Undo和Redo对应的逻辑。由于类型层面的约束,我们不可能写出推入错误状态类型或者越界操作指针的代码,因为类型检查不会通过。
下面是结合React的简单示例,展示类型级编程的实际应用:
import { useReducer } from 'react';
// 定义状态类型
type CounterState = { count: number };
// 定义栈的初始状态
const initialStack: UndoRedoStack<CounterState> = {
history: [{ count: 0 }],
pointer: 0,
maxSize: 10,
};
// 定义action类型
type Action =
| { type: 'increment' }
| { type: 'undo' }
| { type: 'redo' };
// reducer函数,这里的逻辑对应类型级的栈操作
function counterReducer(
stack: UndoRedoStack<CounterState>,
action: Action
): UndoRedoStack<CounterState> {
switch (action.type) {
case 'increment': {
const currentState = stack.history[stack.pointer];
const newState = { count: currentState.count + 1 };
// 这里实际逻辑对应PushState类型操作
const newHistory = stack.pointer === stack.history.length - 1
? [...stack.history, newState]
: [...stack.history.slice(0, stack.pointer + 1), newState];
let finalHistory = newHistory;
if (stack.maxSize && newHistory.length > stack.maxSize) {
finalHistory = newHistory.slice(1);
}
return {
...stack,
history: finalHistory,
pointer: finalHistory.length - 1,
};
}
case 'undo':
if (stack.pointer <= 0) return stack;
return {
...stack,
pointer: stack.pointer - 1,
};
case 'redo':
if (stack.pointer >= stack.history.length - 1) return stack;
return {
...stack,
pointer: stack.pointer + 1,
};
default:
return stack;
}
}
// 组件使用
export default function Counter() {
const [stack, dispatch] = useReducer(counterReducer, initialStack);
const currentState = stack.history[stack.pointer];
return (
<div>
<p>当前计数:{currentState.count}</p>
<button onClick={() => dispatch({ type: 'increment' })}>增加</button>
<button
onClick={() => dispatch({ type: 'undo' })}
disabled={stack.pointer <= 0}
>
撤销
</button>
<button
onClick={() => dispatch({ type: 'redo' })}
disabled={stack.pointer >= stack.history.length - 1}
>
重做
</button>
</div>
);
}
通过上面的示例可以看到,类型级编程虽然没有直接参与运行时的逻辑,但是它在编译阶段就约束了栈的操作规则,让整个撤销重做逻辑的类型更加安全。如果我们在reducer中尝试推入一个不符合CounterState类型的状态,或者尝试修改history数组为可变数组,TypeScript都会直接报错,避免了很多潜在的bug。这种方式特别适合复杂的状态管理场景,当状态结构变得复杂的时候,类型级的约束能大大减少调试成本。
TypeScript类型级编程状态管理修改时间:2026-08-29 07:41:24