TypeScript中的只读属性

134 阅读7分钟

只读属性概念图

在TypeScript的类型系统中,只读属性是保证数据不变性(immutability)的关键特性。它允许开发者明确指出某些对象属性在初始化后不可修改,从而避免意外变更,提高代码的安全性和可预测性。

什么是只读属性?

只读属性是TypeScript中的一种类型修饰符,用于标记某些对象属性在创建后不能被重新赋值。这个特性在函数式编程、状态管理和API设计中尤为有用。

基本语法:readonly 修饰符

interface User {
  readonly id: string;      // 只读属性
  name: string;             // 普通属性
  email?: string;           // 可选属性
}

const user: User = {
  id: 'usr-001',
  name: 'Alice Chen'
};

// 允许修改普通属性
user.name = 'Alice Zhang'; // ✅

// 禁止修改只读属性
user.id = 'usr-002'; // ❌ Error: 无法分配到 "id" ,因为它是只读属性

为什么需要只读属性?

  1. 防止意外修改:保护关键标识符(如ID)不被改变
  2. 提高代码可预测性:确保核心数据在程序执行过程中保持不变
  3. 函数式编程支持:促进不可变数据结构的创建
  4. API设计约束:明确哪些属性客户端可以修改
  5. 并发安全:在多线程环境(如Web Workers)中减少竞争条件

只读属性的多种应用场景

1. 在接口和类型别名中

// 接口中使用只读属性
interface Book {
  readonly isbn: string;
  title: string;
  publicationYear: number;
}

// 类型别名中使用只读属性
type Point = {
  readonly x: number;
  readonly y: number;
};

const origin: Point = { x: 0, y: 0 };
origin.x = 5; // ❌ 错误:无法分配到 "x" ,因为它是只读属性

2. 在类中使用只读属性

class Account {
  // 只读属性在构造时初始化
  constructor(
    public readonly id: string, 
    private balance: number
  ) {}

  deposit(amount: number) {
    this.balance += amount;
  }

  // 错误尝试:无法在方法中修改只读属性
  // changeId(newId: string) {
  //   this.id = newId; // ❌ 错误
  // }
}

const acc = new Account('acct-123', 1000);
acc.id = 'acct-456'; // ❌ 禁止修改

3. 只读数组:ReadonlyArray<T>

const colors: ReadonlyArray<string> = ['red', 'green', 'blue'];

// 所有修改操作都被禁止
colors.push('yellow'); // ❌ 错误:属性"push"在类型"readonly string[]"上不存在
colors[0] = 'purple'; // ❌ 通过索引修改也不允许

// 允许读取操作
console.log(colors[1]); // ✅ 输出: green

// 替代方案:使用只读修饰符
interface ImmutableList<T> {
  readonly [index: number]: T;
}

实用工具类型增强只读性

1. Readonly<T>:使所有属性变为只读

interface Config {
  apiUrl: string;
  timeout: number;
}

const appConfig: Readonly<Config> = {
  apiUrl: 'https://api.example.com',
  timeout: 3000
};

// 尝试修改任何属性都会失败
appConfig.timeout = 5000; // ❌ 错误:无法分配到 "timeout" ,因为它是只读属性

2. ReadonlyArray<T>:创建不可变数组

const numbers: ReadonlyArray<number> = [1, 2, 3];
// 等同于 const numbers: readonly number[] = [1, 2, 3];

// 尝试修改会导致编译错误
numbers[0] = 10; // ❌ 索引签名禁止修改

3. 深度只读:递归处理嵌套对象

type DeepReadonly<T> = {
  readonly [P in keyof T]: T[P] extends object 
    ? DeepReadonly<T[P]> 
    : T[P];
};

interface Company {
  name: string;
  departments: {
    name: string;
    employees: number;
  }[];
}

const myCompany: DeepReadonly<Company> = {
  name: 'TechCorp',
  departments: [
    { name: 'Engineering', employees: 50 }
  ]
};

// 所有层级的修改都被禁止
myCompany.name = 'NewCorp'; // ❌ 
myCompany.departments[0].name = 'Research'; // ❌ 

只读属性与const的区别

特性readonly 属性const 变量
作用层级对象属性级别变量绑定级别
作用目标接口/类型/类中的属性变量声明
重新赋值禁止对象属性重新赋值禁止变量重新绑定
数组内容修改禁止修改数组内容(使用ReadonlyArray时)允许修改数组内容
作用范围编译时类型检查编译时和运行时(使用const声明)
// const 示例
const APP_NAME = 'MyApp';
APP_NAME = 'NewApp'; // ❌ 错误:常量声明后不能重新赋值

// 但注意:const对象的内容可以修改
const config = { port: 3000 };
config.port = 4000; // ✅ 允许

// readonly防止了这种行为
const readOnlyConfig: Readonly<{ port: number }> = { port: 3000 };
readOnlyConfig.port = 4000; // ❌ 禁止

只读属性在实际开发中的应用

1. React组件Props和State

type CounterProps = Readonly<{
  initialCount: number;
  max: number;
}>;

type CounterState = Readonly<{
  count: number;
}>;

class Counter extends React.Component<CounterProps, CounterState> {
  state: CounterState = {
    count: this.props.initialCount
  };

  // 安全用法:props和state都是部分只读
}

2. Redux状态管理

type State = Readonly<{
  user: {
    id: string;
    name: string;
    email: string;
  };
  loading: boolean;
}>;

function reducer(state: State, action: any): State {
  // 必须返回新状态,而不是修改原状态
  return {
    ...state,
    user: { ...state.user, name: action.payload }
  };
}

3. API响应数据保证

interface APIResponse<T> {
  readonly status: number;
  readonly data: Readonly<T>;
  readonly timestamp: string;
}

function processResponse(response: APIResponse<UserData>) {
  // 确保响应数据不被意外修改
  logResponse(response.status);
  // response.data = ... // ❌ 禁止修改
}

4. 配置对象安全

interface AppConfig {
  readonly apiEndpoint: string;
  readonly maxRetries: number;
  readonly debugMode: boolean;
}

function initializeApp(config: AppConfig) {
  // 配置在初始化后保持不变
}

// 初始化后无法修改配置
const config: AppConfig = {
  apiEndpoint: 'https://api.example.com',
  maxRetries: 3,
  debugMode: false
};

config.debugMode = true; // ❌ 禁止修改

只读属性的最佳实践

1. 默认使用只读属性

// 优先将不应改变的值设为只读
interface Product {
  readonly id: string;
  readonly sku: string;
  name: string;
  price: number;
}

2. 合理使用工具类型

// 创建可重用工具类型
type Entity<T> = Readonly<{
  id: string;
  created: Date;
  updated: Date;
} & T>;

type Product = Entity<{
  name: string;
  price: number;
}>;

3. 区分可变与不可变数据

interface MutableUser {
  preferences: any;
}

interface ImmutableUserData {
  readonly id: string;
  readonly joinDate: Date;
}

type User = ImmutableUserData & MutableUser;

4. 函数参数约束

function processOrder(order: Readonly<Order>) {
  // 函数内部无法修改传入的订单
  // 明确表示函数不会产生副作用
  const total = calculateTotal(order);
  // ...
}

5. 正确使用只读数组

// 偏好使用 readonly Type[] 语法
const COLORS: readonly string[] = ['red', 'green', 'blue'];

// 或使用泛型形式
const NUMBERS: ReadonlyArray<number> = [1, 2, 3];

只读属性的局限性及解决方案

1. 编译时检查 vs 运行时保护

只读属性仅在编译时有效,JavaScript运行时仍可修改:

interface Point {
  readonly x: number;
  readonly y: number;
}

const p: Point = { x: 10, y: 20 };
(p as any).x = 30; // 绕过类型检查

console.log(p.x); // 30(实际值被修改)

解决方案

  • 使用Object.freeze()增强运行时保护:
    const p = Object.freeze({ x: 10, y: 20 });
    p.x = 30; // ❌ 运行时TypeError(严格模式下)
    

2. 只读属性与类继承

基类的只读属性在派生类中仍为只读:

class Base {
  readonly id: string = "base";
}

class Derived extends Base {
  // 错误尝试:无法覆盖只读属性
  // id = "derived"; // ❌
  
  constructor() {
    super();
    // 只能在构造函数中初始化
    (this as any).id = "derived"; // 不推荐的方法
  }
}

正确方法

class BetterBase {
  protected _id: string;
  get id() { return this._id; }
  
  constructor(id: string) {
    this._id = id;
  }
}

class BetterDerived extends BetterBase {
  constructor() {
    super("derived");
  }
}

3. 只读与深度不可变结构

默认的只读是浅层的:

interface Container {
  readonly data: {
    value: number;
  };
}

const container: Container = {
  data: { value: 100 }
};

container.data = { value: 200 }; // ❌ 禁止(浅层只读)
container.data.value = 200; // ✅ 允许(深层可变)

解决方案

// 使用自定义DeepReadonly类型(见上文)
const container: DeepReadonly<Container> = {
  data: { value: 100 }
};

container.data.value = 200; // ❌ 禁止

高级模式:可修改的内部状态

class TemperatureSensor {
  // 公开的只读接口
  public get current() {
    return this._getInternalValue();
  }
  
  // 内部可变状态
  private _lastReading = 0;
  
  // 受保护的数据访问
  private _getInternalValue() {
    this._lastReading = readHardwareSensor(); // 内部修改
    return this._lastReading;
  }
}

const sensor = new TemperatureSensor();
console.log(sensor.current); // ✅
sensor.current = 25; // ❌ 错误:current是只读的

只读属性在函数式编程中的应用

// 使用只读属性确保纯函数
type Vector = Readonly<[number, number]>;

function addVectors(v1: Vector, v2: Vector): Vector {
  return [v1[0] + v2[0], v1[1] + v2[1]];
}

function scaleVector(scalar: number, vector: Vector): Vector {
  return [vector[0] * scalar, vector[1] * scalar];
}

// 创建不可变数据流
const v1: Vector = [2, 3];
const v2: Vector = [1, 4];
const v3 = addVectors(scaleVector(2, v1), v2); // [5, 10]

TypeScript只读属性的演进

版本只读属性特性
TypeScript 2.0引入readonly关键字
TypeScript 3.4引入readonly修饰符用于数组和元组类型
TypeScript 4.3引入类readonly支持自动类型推导
TypeScript 4.5改进只读工具类型的性能
TypeScript 5.0+增强只读类型与函数参数的互操作性

只读属性的4大价值

  1. 安全性:防止意外修改关键数据
  2. 清晰性:明确表达设计意图
  3. 契约性:定义稳定可靠的API接口
  4. 不可变性:支持函数式编程范式
graph LR
    A[代码设计] --> B[标识核心属性]
    B --> C{需要防止修改}
    C --> |是| D[添加readonly修饰符?]
    C --> |否| E[使用普通属性]
    D --> F[获得编译时保护]
    F --> G[增强代码可靠性]

TypeScript之父Anders Hejlsberg指出:"只读属性是TypeScript类型系统的重要组成部分,它们为JavaScript的动态世界带来了一层额外的安全保障,使我们能够构建更健壮、更易于维护的应用。"

通过合理使用只读属性,开发者可以:

  • 创建自文档化的代码接口
  • 减少由意外修改引起的bug
  • 简化状态变更追踪
  • 构建更安全的并发应用