先记住这个答案
TypeScript 通常允许把较具体元素数组赋给较宽元素数组,例如带点击坐标的事件数组可以交给通用事件数组变量。这种协变兼容方便读取,但可变数组存在漏洞:通过较宽引用写入不带坐标的事件后,原数组仍被静态视为点击事件数组,后续读取可能失败。strict 并不会完全消除这类取舍。只读取的 API 应接收 readonly T[],限制自身通过该接口写入;但 readonly 主要是静态且浅层的约束,不会冻结对象或阻止其他可写别名改变原数组。
- 较宽可变数组视图可能写入较窄数组不接受的值
- 严格模式仍保留部分容器兼容取舍
- 只读参数限制当前使用者写入而非冻结所有别名
同一个引用如何形成不一致假设
点击事件包含通用事件的字段,所以从读取角度看,把点击数组当成通用数组没有困难。问题出现在写入:通用数组允许加入只有基础字段的事件,这个操作会修改同一个底层数组,而最初变量的静态类型没有因此同步变宽。
示例故意展示一次被允许的较宽写入,再提供只读观察函数。写入后数组实际包含没有坐标的元素,说明不能从最初变量类型推断每个运行值都可靠。示例没有继续调用缺失坐标的方法,而是把风险停留在可观察的数据变化上。
type EventBase = { kind: string };
type ClickEvent = EventBase & { x: number };
const clicks: ClickEvent[] = [{ kind: 'click', x: 10 }];
const events: EventBase[] = clicks;
events.push({ kind: 'focus' });
function count(items: readonly EventBase[]): number {
// @ts-expect-error 只读观察接口没有 push
items.push({ kind: 'blur' });
return items.length;
}
const size = count(clicks);events 与 clicks 是同一数组,宽引用写入后长度为二,第二项没有 x。只读参数阻止观察函数直接 push,但不会撤销已经发生的别名写入或替所有调用者冻结数据。
为什么 strict 没有自动解决一切
TypeScript 的结构兼容与方法参数规则保留了一些方便既有 JavaScript 使用的取舍,可变数组也受到这些规则影响。把数组描述为严格模式下完全不变,或声称编译通过就没有写入漏洞,都不符合实际行为。应以当前编译器最小例子验证。
如果 API 只遍历、查找或计算统计值,readonly 输入更准确地表达它不需要写入能力,还能接受只读元组。如果 API 要排序或追加元素,可以先创建自己的可变副本,或明确要求调用者交出可变容器,并说明是否会修改原引用。
浅只读与共享元素仍有边界
readonly T[] 限制数组层面的修改,不会自动把每个 T 的字段变成只读。即使不能替换某个元素,元素对象本身仍可能被修改。需要保护深层结构时,应按实际数据模型设计只读视图、复制或不可变更新,不能只在最外层加修饰符。
其他持有可变引用的代码也仍能改变原数组。跨异步阶段依赖稳定集合时,可能需要快照,而不是只把参数类型改成 readonly。测试应覆盖同一数组的多个别名和元素对象共享,确认观察函数的假设在真实生命周期内成立。
容易答错的地方
- 开启 strict 后可变数组就一定不变
- 严格检查并没有消除所有历史兼容取舍,较具体数组仍可能通过宽引用被写入不相容元素;需要理解并限制实际写入路径。
- readonly 数组意味着所有元素深度冻结
- 它主要限制这个静态数组接口的修改能力,元素对象和其他可写别名仍可能变化;深层不可变需要另外设计和实现。
面试官还会怎么问?
普通数组可以传给 readonly 参数吗?
通常可以,观察者不需要写入能力,所以可变数组能够满足较小的只读使用契约;这也让只读参数更适合纯计算函数。
复制数组就完全隔离了吗?
浅复制能隔离数组增删与元素替换,但其中的对象元素仍然共享;若会修改元素字段,还要决定是否复制更深层或采用不可变更新。
为什么 const clicks 仍然可以 push?
const 限制变量不能重新指向另一个数组,数组内容仍可变;限制当前接口的内容写入需要 readonly 类型或相应运行时措施。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。