使用 readonly 避免与突变相关的错误
核心建议是利用 readonly 修饰符来防止对象和数组的意外突变,突变是 JavaScript 中许多难以追踪的 bug 的常见根源。
突变带来的问题
JavaScript 中的数组和对象在默认情况下都是可变的。当一个函数修改了其参数时,如果调用者没有预料到这种副作用,就会导致程序逻辑错误。
例如,一个求和函数可能会意外地清空数组,导致调用它的外部函数产生错误行为。
function arraySum(arr: number[]) {
let sum = 0, num;
// 此处使用了 pop(),它会修改原始数组
while ((num = arr.pop()) !== undefined) {
sum += num;
}
return sum;
}
function printTriangles(n: number) {
const nums = [];
for (let i = 0; i < n; i++) {
nums.push(i);
// 第一次调用 arraySum 后,nums 就会被清空
console.log(arraySum(nums));
}
}
// 尽管 printTriangles(5) 的预期输出是 0, 1, 3, 6, 10,但由于突变,它会输出 0, 1, 2, 3, 4。使用 readonly 避免突变
readonly 关键字可以帮助我们在类型系统层面明确契约,防止对类型进行未预期的修改。
- 原始值: JavaScript 中的原始值(如
string,number,boolean)本身是不可变的,其方法不会导致突变。 - 对象属性: 将
readonly放置在对象属性上,可以防止对该属性进行重新赋值。
interface PartlyMutableName {
readonly first: string;
last: string;
}
const jackie: PartlyMutableName = { first: 'Jacqueline', last: 'Kennedy' };
jackie.last = 'Onassis'; // OK
// @ts-expect-error
jackie.first = 'Jacky'; // 错误:无法分配到 'first',因为它是一个只读属性- 工具类型
Readonly<T>: TypeScript 提供了泛型工具类型Readonly<T>,用于将类型T的所有属性设置为只读。
interface FullyMutableName {
first: string;
last: string;
}
type FullyImmutableName = Readonly<FullyMutableName>;
// 结果类型中的 first 和 last 都是只读的。Readonly 的局限性
-
readonly是浅层的: 类似于const声明,readonly只防止属性本身被重新赋值,但不会阻止该属性指向的内部对象发生突变。interface Outer { inner: { x: number; } } const obj: Readonly<Outer> = { inner: { x: 0 }}; // @ts-expect-error obj.inner = { x: 1 }; // 错误:inner 属性是只读的 obj.inner.x = 1; // OK:内部对象可以被突变 -
不影响方法:
Readonly只影响属性,但对可能改变底层数据的对象方法无效。例如,对Readonly<Date>类型的对象调用setFullYear()仍然是允许的,但会导致对象突变。
数组和 ReadonlyArray
标准库中提供了 Array<T>(可变数组)和 ReadonlyArray<T>(只读数组)两个接口来应对突变问题。
-
ReadonlyArray<T>移除了所有突变方法(如pop和shift)。 -
它的
length属性和索引签名([n: number]: T)都带有readonly修饰符,从而防止调整数组大小或修改元素。 -
子类型关系:
T[]是readonly T[]的子类型,因为它具有更多的功能(突变能力),因此可变数组可以安全地赋值给只读数组。const a: number[] =; const b: readonly number[] = a; // OK // @ts-expect-error const c: number[] = b; // 错误:不能将只读类型分配给可变类型 -
修复求和函数: 通过将参数类型更改为
readonly number[],我们可以强制arraySum成为一个非突变函数。function arraySum(arr: readonly number[]) { let sum = 0; // 如果尝试使用 pop() 或 shift(),会得到类型错误。 for (const num of arr) { sum += num; // 使用非突变循环 } return sum; } // 现在 printTriangles 调用 arraySum 时,类型检查器将确保 arr 不被修改。
使用 Readonly 的实践建议
-
明确函数契约: 如果您的函数不修改其参数,应将其参数声明为
readonly(针对数组)或Readonly(针对对象类型)。- 这使函数的契约更清晰。
- 这会阻止在函数实现中发生不经意的突变。
- 这允许调用者传入
readonly数组或Readonly对象,拓宽了函数可接受的参数类型集合(遵循鲁棒性原则)。
-
const与readonly的区别:const阻止变量重新赋值。readonly阻止对象的属性或数组突变。- 函数参数即使被声明为
readonly,仍然可以在函数体内部被重新赋值(像let变量一样),因为这种重新赋值对调用者不可见。
-
readonly的传染性:readonly倾向于具有传染性,一旦开始使用,许多调用该函数的上游函数可能都需要使用readonly来保持类型安全。这通常是一件好事,因为它带来了更清晰的契约和更好的类型安全。
上次更新于