1. 别把 readonly 当“常量”,它是一道编译期的安全防线
我先说一个大多数刚接触 readonly 的人都会踩的坑:以为 readonly 就等于 const,等于“这个值永远不变”。其实不是。readonly 的核心语义是“在初始化之后,不允许再被赋值”,它管的是“赋值”这个动作,而不是“值本身不可变”。如果你把一个对象标记成 readonly,你仍然可以修改对象的属性,只是不能把这个变量重新指向另一个对象。
这个区别非常关键。我见过不少同事在 TypeScript 里写了 readonly config: Config,然后以为 config 里的所有字段都安全了,结果运行时发现内部某个数组还是被 push 进了脏数据。原因就是 readonly 只约束了变量本身,没有约束对象的内部结构。要真正做到深层只读,得配合 Readonly<T>、as const 或者递归冻结方案。这也是为什么我说 readonly 更像是一道“编译期的安全防线”,而不是“运行时的保险箱”。
那这篇博客到底要讲什么?我会从 readonly 在不同语言里的真实面貌讲起,重点放在 TypeScript 和 C# 这两个最常见的场景上,顺带提一下 JavaScript 里模拟只读的几种惯用法,最后结合我实际项目里踩过的坑,聊聊“什么时候该用 readonly、什么时候不该用”。无论你是在写前端、后端还是全栈,只要你的代码里出现过“这个变量不该被改”的想法,这篇文章都值得你花十分钟看完。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 先从语言层面看清 readonly 的不同“变体”
很多人的困惑来自于把不同语言的同名关键字混为一谈。readonly 在 TypeScript、C#、C++ 里都存在,但语义差别不小。我整理了一张表,先帮你把底层逻辑捋清楚。
| 语言 | readonly 作用于什么 | 初始化时机 | 深层只读? | 运行时行为 |
|---|---|---|---|---|
| TypeScript | 变量、属性、索引签名 | 声明处或构造函数中 | 默认不深,需配合 Readonly/Object.freeze | 编译期检查,运行时仅对 getter 有轻微影响 |
| C# | 字段(field) | 声明处或构造函数中 | 不深,只约束字段指向 | 编译期检查 + 运行时只读,反射可绕过 |
| C++ | 成员函数、变量 | 构造函数初始化列表 | 不深,但 const 限定可传递 | 编译期强约束,const_cast 可绕过 |
| JavaScript | 无原生 readonly | 无 | 无 | 无原生支持 |
看到没有,readonly 几乎都是“浅层约束 + 编译期强检查”的组合。它在运行时的存在感很弱,主要价值是在你敲代码的阶段就拦住“不该发生的赋值”。
2.1 TypeScript 里的 readonly:属性、数组、泛型约束
TypeScript 里 readonly 最常见的用法是修饰类的属性,或者接口(interface)里的成员。我举一个典型例子:
typescript复制interface User {
readonly id: string;
name: string;
email: string;
}
class UserService {
private readonly users: Map<string, User> = new Map();
constructor() {
this.users.set('u_001', { id: 'u_001', name: '张三', email: 'zhangsan@example.com' });
}
updateEmail(id: string, newEmail: string) {
const user = this.users.get(id);
if (user) {
// 这里合法:user 是从 Map 里取出来的,readonly 约束的是 users 这个属性的指向
user.email = newEmail;
}
}
}
注意我上面写的注释——users 是 readonly,意味着你不能 this.users = new Map(),但你完全可以通过 this.users.set() 往里塞数据。这是 TypeScript readonly 最容易被误解的地方。
如果你真想保护数组不被修改,有两个选择:一是用 ReadonlyArray<T>,二是直接标注 readonly 元组:
typescript复制const statusList: readonly string[] = ['active', 'inactive', 'pending'];
// statusList.push('deleted') // 报错:类型“readonly string[]”上不存在属性“push”
const roleTuple: readonly [string, number] = ['admin', 1];
// roleTuple[0] = 'user' // 报错:无法分配到 '0',因为它是只读属性
在泛型约束里,readonly 也有一个常见误用:你没法直接用 T extends readonly T[] 这种写法去描述“一个只读数组的泛型参数”,原因涉及 TS 对泛型协变/逆变的设计。标准做法是用类型工具:ReadonlyArray<T>,它可以出现在泛型约束里。所以实际开发中我更推荐写 ReadonlyArray<T> 而不是裸写 readonly T[],两者在类型层面等价,但前者在泛型场景下更少踩坑。
2.2 C# 里的 readonly:字段、struct、ref 返回
C# 的 readonly 是一个真正的运行时关键字,修饰字段时,会在 JIT 层面把该字段当作只读处理,构造函数之外任何写操作都会直接编译报错。这里有两个我实际用过的场景想分享。
首先是只读字段配合构造函数注入,这是依赖注入和不可变配置项里的标配写法:
csharp复制public class EmailSender
{
private readonly SmtpClient _smtpClient;
private readonly string _fromAddress;
public EmailSender(SmtpClient smtpClient, string fromAddress)
{
_smtpClient = smtpClient;
_fromAddress = fromAddress;
}
public void Send(string to, string body)
{
_smtpClient.Send(_fromAddress, to, body);
}
}
为什么不用 const 而是用 readonly?因为 const 要求编译期常量,而 readonly 允许在构造函数里赋值。比如上面 _fromAddress 是从配置读出来的字符串,编译期无法确定,所以只能用 readonly。这是 C# 里 const 和 readonly 最常见的分工:编译期常量用 const,运行期只读用 readonly。
其次是 C# 7.2 之后引入的 readonly struct。它会告诉编译器:这个结构体是不可变的,所有方法都不得修改字段。这么做的收益不仅仅是语义清晰,更能让编译器在很多场景下避免防御性复制,从而提升性能。我在做大量数值计算的中间件时,把核心数据结构改成 readonly struct 后,GC 压力和对象拷贝量都明显下降。这点在 .NET 的高性能场景下极其重要。
2.3 C++ 里的 const 和 readonly 的类比思维
严格来说,C++ 没有 readonly 关键字,它用的是 const。但 C++ 的 const 比 TypeScript 的 readonly 更庞大,它可以修饰变量、指针、引用、成员函数,甚至可以叠加。比如:
cpp复制class Config {
const int maxRetry;
public:
Config() : maxRetry(5) {}
int getMaxRetry() const { return maxRetry; }
};
这里的成员变量 maxRetry 相当于 C# 的 readonly,而 const 成员函数则承诺“调用这个函数不会修改对象”。如果你在 C++ 里写过复杂的继承体系,你一定体会过 const 正确性(const correctness)崩溃时的连锁反应:一个地方漏写 const,调用链上一串函数都需要改签名。这其实是好事情,它把“谁有权限修改”这件事在编译期彻底定死了。
所以在阅读 C++ 代码时,你可以把 const 理解成“readonly + 不可调用非 const 方法”的组合体。这也是为什么很多人从 C++ 转 TypeScript 后初期会很不适应,因为 TypeScript 的 readonly 只挡赋值,不挡方法调用。
3. 从基础到实战:业务代码里如何用好 readonly
3.1 接口定义与 DTO 场景,readonly 是最佳选择
写业务代码时,最容易受益于 readonly 的地方就是接口定义和数据传输对象(DTO)。
我在一个用户中心项目里定义过一个用户资料接口,刚开始没有用 readonly,结果团队里有人误把 userId 给改了,导致后面所有关联查询全部错乱。后来我把关键字段全部标注为 readonly:
typescript复制export interface UserProfile {
readonly userId: string;
readonly createdAt: Date;
readonly userType: 'admin' | 'normal';
nickname: string;
avatarUrl: string;
bio?: string;
}
这里只有 nickname、avatarUrl、bio 是允许修改的,其他字段都加了 readonly。这样做的好处不仅仅是防止误操作,更重要的是语义传达:下次任何协作者看到这个接口,一眼就知道 userId 和 createdAt 是系统生成且不可篡改的,设置了清晰的边界。这种“代码即文档”的收益,在长期维护的项目里会越来越显著。
3.2 用 readonly 保护“全局可访问配置对象”
另一个非常值得用的场景是全局配置对象。比如一个调度系统里,我有一个 SystemConfig 单例,保存着所有运行时参数。以前有人为了图省事直接 config.maxRetry = 10,把运行时参数和静态参数混在一起,排查问题非常痛苦。后来我把静态配置定义成 readonly 结构:
typescript复制const SYSTEM_CONFIG = {
readonly maxRetry: 5,
readonly timeoutMs: 3000,
readonly queueCapacity: 1024,
readonly featureFlags: {
enableNewPipeline: true,
}
} as const;
注意这里我用了一个特殊语法:as const。它会把整个对象树的所有层级都递归地变成 readonly。这是 TypeScript 最实用的只读技巧之一,比你手动在每一层写 readonly 要省事太多,而且天然具备深层约束能力。不过要小心,as const 也会把数组变成 readonly 元组,在对外的公共接口里可能导致类型过于狭窄,需要搭配类型注解一起用。
3.3 状态管理里的只读:React 与 Vue 的不同处理
在 React 里,我非常推崇把“不允许直接修改状态”这件事用 readonly 在类型层面显式表达出来。比如 useState 返回的 setter 是标准做法,但当你把状态对象传到子组件时,很容易出现子组件直接修改 props 对象的情况。这时候类型定义就能起作用:
typescript复制interface Props {
readonly items: Item[];
readonly selectedId: string | null;
onSelect: (id: string) => void;
}
props 本身在运行时其实无法真正禁止修改,但类型层面的 readonly 会在编译期拦住所有直接赋值的操作。配合 ESLint 的 no-param-reassign 规则,双重保障,基本可以杜绝组件里篡改外部状态对象的问题。
Vue 3 里则要看场景。如果用 reactive 创建响应式对象,直接把整个对象做成 readonly 会破坏响应式更新,所以更推荐只对“只读展示型数据”使用 computed(() => ...) 再配合 readonly() 包装:
typescript复制const rawList = ref<Item[]>([]);
const displayList = readonly(rawList);
readonly() 包一层之后,模板里或者子组件里拿到的是只读代理,任何修改都会触发警告,但内部的响应式更新仍然正常。这是 Vue 生态里比较优雅的只读方案。
3.4 数据库字段是关键字,映射层如何规避
这里顺便提一下热搜词里出现频率很高的问题:“mysql 表中字段为关键字怎么办”。这个问题本身和 readonly 没有直接关系,但实际项目里经常同时遇到——你定义了 readonly 字段,到了 SQL 映射层却发现字段名撞了关键字,比如 order、group、readonly 本身在某些数据库方言里也可能有特殊含义。
我自己处理过一张报警表,字段名就叫 readonly,在 MySQL 里它并不是保留字,但在 MyBatis-Plus 的 LambdaQueryWrapper 里用得也顺利。真正的坑出现在 order 这种字段——它在 MySQL 里是保留字,直接写 SELECT order FROM table 会报语法错误。标准做法是反引号包裹:
sql复制SELECT `order`, `group`, `readonly` FROM alert_rule;
如果用 MyBatis-Plus,尽量用注解或者 @TableField 指定列名,避免让框架自动映射这些敏感名称。我也见过有人为了避免所有麻烦,直接给字段加前缀,比如 rule_order、rule_group,这在后端接口里不算优雅,但从数据库规范角度讲是最稳妥的。无独有偶,开发中凡是叫 desc、key、value 的字段,都会在不同数据库里碰到类似问题,建议建表时就避开这些保留字,哪怕加个前缀或者换个同义词,都比后期在 ORM 层各种逃逸字符来得省心。
4. 深层只读方案:从 Object.freeze 到递归不可变
4.1 Object.freeze 与 readonly 的真正关系
Object.freeze 是 JavaScript 运行时层面唯一真正冻结对象的机制。但要注意:它和 TypeScript 的 readonly 是两个维度的东西。Object.freeze 是运行时防篡改,TypeScript 的 readonly 是编译期防赋值。两者组合使用才能得到最完整的效果。
有一个很实用的小技巧是写一个不带泛型的深层冻结函数:
typescript复制export function deepFreeze<T>(obj: T): Readonly<T> {
if (obj && typeof obj === 'object') {
Object.getOwnPropertyNames(obj).forEach((key) => {
const value = (obj as Record<string, unknown>)[key];
if (value && typeof value === 'object') {
deepFreeze(value);
}
});
return Object.freeze(obj);
}
return obj as Readonly<T>;
}
用这个函数包过的对象,运行时和编译时都被保护住了。我在一些需要传给第三方服务且不希望被外部库修改的配置对象上,就用了这套方案。
4.2 认识 Readonly 与 Mutable 类型工具的边界
TypeScript 内置了好几个 readonly 相关的工具类型。最常用的是 Readonly<T>,它把一个对象类型的所有属性变成只读:
typescript复制type ConfigReadonly = Readonly<Config>;
但它同样只做浅层处理。如果想递归地把每一层嵌套都变成只读,需要自己写一个 DeepReadonly:
typescript复制type DeepReadonly<T> = {
readonly [P in keyof T]: T[P] extends object ? DeepReadonly<T[P]> : T[P];
};
反过来,如果你要处理第三方库返回的只读类型,那可能会用到 Mutable<T> 类似物。TypeScript 官方没有提供 Mutable,但自己定义很简单:
typescript复制type Mutable<T> = {
-readonly [P in keyof T]: T[P];
};
这里的 -readonly 语法就是“移除只读修饰符”的意思。这个技巧在我处理一些从 immutable 库或者 Redux 拿到的只读 State 时经常用到。不过要谨慎使用,因为把 readonly 强行变成 mutable,等于自己放弃了编译期的保护,等于向代码里输入了潜在风险。
4.3 immutable 方案:Immer 的偷懒但正确路线
如果你的项目对不可变数据的要求很高,比如频繁做撤销重做、时间旅行调试,那自己手写 readonly 类型还是太累了。业界更成熟的方案是引入 Immer 这类不可变数据库。
Immer 的思路很有意思:你基于当前 state 生成一个 draft,在 draft 上随便改,Immer 在底层帮你生成一个新的不可变 state,原 state 完全不受影响。这在 React 的状态更新里极其常见:
typescript复制import { produce } from 'immer';
interface AppState {
readonly user: User;
readonly items: Item[];
}
const newState = produce(oldState, (draft) => {
draft.items.push(newItem);
});
注意看,即使 oldState 的类型里写了 readonly,在 draft 里你依然可以修改。这是 Immer 刻意为之的设计——它把“修改”拦截在 draft 层,最终产出一个新的对象,等于把不可变性从类型层面转移到了实现层。这种做法在大型前端项目里几乎是标准答案,比单纯依赖 readonly 类型要省心得多。不过我一般不把 Immer 当作 readonly 的替代品,而是把它当作“在复杂业务中维护不可变性的终极手段”。
4.4 运行时反射:readonly 并不是绝对安全
最后必须提醒一点:readonly 在绝大多数语言里都不是“绝对安全”的。
- C# 里可以用反射绕过 readonly,直接给字段赋新值;
- TypeScript 是编译期类型擦除,运行时根本没有 readonly 的概念;
- C++ 的 const_cast 更是明目张胆地允许你去掉 const 限制。
所以请记住:readonly 是给人(和编译器)看的契约,不是给恶意代码准备的保险锁。如果你要保护的是需要对抗攻击或者防止篡改的敏感数据,那就得用到加密签名、访问控制、安全沙箱这些更重的机制。在普通应用层开发里,readonly 已经是成本最低、收益最高的选择了。
5. 常见误区和排查技巧:我踩过的坑,不希望你再踩
5.1 误区一:以为 readonly 能保护对象内部
这个我在开头就说过了,但还是值得单列一节。因为你迟早会在代码评审里遇到有人问:“这个属性已经 readonly 了,为什么数组还能被 push?”
原因很简单。readonly 管的是“这个属性指向哪”,不管“这个对象内部变成什么样”。如果你需要保护数组,要么用 ReadonlyArray<T>,要么在外部就保证不传入可变数组,要么就用 Object.freeze 跑一层深冻结。我的习惯是:如果数组会被多个模块共享,直接返回 ReadonlyArray 类型的副本,绝对不让调用方拿到原始引用。
5.2 误区二:初始化后想再改值,被迫重构
业务代码里经常出现一种尴尬情况:一个字段在构造函数里初始化时逻辑很复杂,后来需求变了,需要在另一个初始化方法里二次赋值。如果当初声明成了 readonly,这时候编译器就会直接报错。
我之前维护过一个支付服务,付款单的状态在构造函数里根本无法确定,结果字段声明成了 readonly,只能被迫在构造函数里调用一个内部方法来满足编译期要求。代码虽然能跑,但很不雅观。这类场景我的建议是:除非你能确定这个字段在整个生命周期内确实只赋值一次,否则不要轻易加 readonly。宁可先不加,等稳定后再加,也比被迫绕弯子强。
5.3 误区三:所有地方都用 readonly,导致代码冗长
有段时间我特别激进,把所有能加 readonly 的地方都加了,还专门写了一个 ESLint 规则。结果发现不少地方加了纯粹是摆设,反而让代码看起来啰嗦。比如局部变量,它本身就只在当前作用域存在,加不加 readonly 意义不大;再比如临时计算的中间结果,加 readonly 会让后续调整逻辑变得僵化。
我的心得是:readonly 应该在“边界位置”使用,比如 API 的入参类型、全局配置、类的不变字段、公共接口的返回值。这些地方是代码对外暴露的契约,保护住这些边界,比保护每个局部变量有价值得多。
5.4 排查技巧:编译期报错时的三个检查方向
当你看到类似 Cannot assign to 'xxx' because it is a read-only property 的报错时,先别急着删 readonly。按照下面三步排查:
- 检查这个字段是否真的需要二次赋值。如果需要,把它从 readonly 中移除,但要想清楚为什么设计变了;
- 检查是否是对象内部的问题。如果报错发生在嵌套对象属性上,需要确认是否需要 DeepReadonly 或运行时冻结;
- 检查是否可以通过“返回副本”而不是“修改原值”来满足需求。这是函数式风格的解法,往往比放宽 readonly 更优。
5.5 避坑技巧:readonly 数组的浅拷贝陷阱
最后分享一个小技巧。ReadonlyArray 上没有 push、splice、pop 这些方法,但如果你直接调用 [...readonlyArray] 得到一个新数组,这个新数组的类型又会变成普通可变数组。这意味着你可以在副本上随意修改,而原数组不受影响。这在很多业务场景里是好事,但也有人不小心把副本直接赋值给了 readonly 原变量,然后各种困惑。实际的规避方法很简单:类型上保持 readonly,别手滑把它改成普通类型。
6. 从“拦截赋值”到“设计思维”:readonly 的价值不止于语法
6.1 用 readonly 表达“不可变契约”
有些开发者把 readonly 当成一个“编译器报错器”,觉得它只是让代码通过不了的约束。但如果你换个视角,readonly 其实是一种代码契约,它告诉所有阅读代码的人:“这些字段由系统负责,禁止外部修改”。它把一次错误拦截从“运行时排查”提前到了“编译期预防”。
在我维护过的一个多端同步服务里,核心同步状态被定义成 readonly 后,前端任何地方都没法直接篡改同步进度,只能通过服务端的统一接口来推进。这大大减少了状态不一致问题,也让我在团队协作时更有底气。用一句话概括:readonly 是你在代码里划出的“安全红线”,它让协作者少犯错误,让维护者少背黑锅。
6.2 关键字不是敌人:学会和编译器的“较劲”
国际上的编程社区有一个很有意思的讨论趋势:语言的类型约束到底应该多强?Haskell 的函数式不可变设计、Rust 的所有权系统、TypeScript 的 readonly/const、C# 的 readonly struct,这些本质上都是在探索同一个问题——如何让程序员在编译期就交付更可信的代码。
所以当你在项目里遇到“编译器不让我写这行代码”的时候,我建议先别急着绕过它,而是停下来问一句:为什么不能这样写?这个设计是否有意为之?很多时候,编译器的抱怨比你自己还诚实。等你真正理解了 readonly 背后的意图,你会发现自己写的代码在架构层面都更清晰了。
6.3 后续可以研究的扩展方向
如果你看完这篇,想继续深挖,可以顺着这几个方向走:
- 学习 TypeScript 的类型逆变与协变,搞懂为什么 readonly 数组在泛型里会有兼容性问题;
- 研究 C# 中的 immutable collections,比如
ImmutableArray<T>和FrozenSet<T>的性能差异; - 探索 JavaScript 的
Object.freeze、Object.seal、Object.preventExtensions三者的区别,完善你运行时不可变的知识矩阵; - 如果你写 React,可以把
useReducer+ Immer + readonly 类型组合起来,体验一把“状态不可变”带来的调试快感。
我个人在实际项目里最常使用的组合是:TypeScript 的 readonly 做编译期约束,Immer 做运行时不可变,Object.freeze 只用在极少数需要对外暴露且不可修改的配置对象上。这套组合兼顾了开发效率和安全性,已经稳定跑了两年多,几乎没有因为非法修改遇到过线上事故。
最后再分享一个小技巧:如果你用 VS Code,可以给代码补全配置里加上 read-only 相关的 suggestion,这样每次声明变量时,IDE 都会提醒你考虑是否需要 readonly。一个简单的提醒,长期下来能帮你减少非常多的意外赋值问题。
