[译]<<Effective TypeScript>> 高效TypeScript62个技巧 技巧17

207 阅读5分钟

一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第10天,点击查看活动详情

本文的翻译于<<Effective TypeScript>>, 特别感谢!! ps: 本文会用简洁, 易懂的语言描述原书的所有要点. 如果能看懂这文章,将节省许多阅读时间. 如果看不懂,务必给我留言, 我回去修改.

技巧17: 使用 readonly 避免变量修改产生的错误

这有一段代码打印三角数:

function printTriangles(n: number) {
  const nums = [];
  for (let i = 0; i < n; i++) {
    nums.push(i);
    console.log(arraySum(nums));
  }
}

这段代码很直观, 但是当你运行后:

> printTriangles(5)
0
1
2
3
4

那是因为你错误的认为: arraySum 不会修改 nums. 但是这是我的实现:

function arraySum(arr: number[]) {
  let sum = 0, num;
  while ((num = arr.pop()) !== undefined) {
    sum += num;
}
  return sum;
}

这个函数能够计算数组的和, 但是它会去清空数组.如果能够保证 arraySum不会去修改 nums,这个问题就解决了. 这就是 readonly 的作用:

function arraySum(arr: readonly number[]) {
  let sum = 0, num;
  while ((num = arr.pop()) !== undefined) {
                 // ~~~ 'pop' does not exist on type 'readonly number[]'
    sum += num;
  }
  return sum;
}

readonly number[]number[]区别在于如下几点:

  • 你可以读取数组中的元素但是不能修改
  • 你可以读取数组的length, 但是不能修改length
  • 你不能使用 pop,或者其他会修改这个数组的函数

由于 number[]readonly number[]更为严格, 可以说 number[]readonly number[]的子集(如果不理解这一点, 见 技巧7). 所以你可以指定 可变array 给 readonly array, 反过来就不行:

const a: number[] = [1, 2, 3];
const b: readonly number[] = a;
const c: number[] = b;
   // ~ Type 'readonly number[]' is 'readonly' and cannot be
   //   assigned to the mutable type 'number[]'

如果你在函数f中定义了参数p为 readonly , 有几件事发生:

  • ts 检查 该函数f中不会改变参数p
  • 调用函数f的人, 能够相信参数p不会被改变
  • 调用函数f的人可以传递一个 readonly 数组

js 和 ts 通常有个约定, 除非明确指出, 函数不会改变其参数. 但是这个没有约束力的约定, 往往导致很多错误. 最好让这个约定有强制约束力, 同时有利于 人们和tsc理解.

ok, 我们来修复arraySum 函数:

function arraySum(arr: readonly number[]) {
  let sum = 0;
  for (const num of arr) {
    sum += num;
  }
  return sum;
}

printTriangles 也能正确执行:

> printTriangles(5)
0
1
3
6
10

所以尽可能的将只读参数标记为: readonly , 这么做几乎没有什么缺点. 唯一的缺点可能就是: 你可能调用一个函数, 这个函数还没有来得及标记成 readonly.

readonly 可能会引起连锁反应.一但你用readonly标记了一个函数 ,其他人想要调用这个函数后, 返回值也必须标记为 readonly. 这种连锁反应非常好. 但是如果你调用了其他库的函数, 你无法修改他的 类型申明. 那么你只好求助于类型断言.

readonly 也可以捕捉由局部变量引起的整个class的计算异常.比如你正在写一个工具处理一篇小说, 你得到lines组成的数组, 你想将他们按照段落进行分组:

Frankenstein; or, The Modern Prometheus
by Mary Shelley

You will rejoice to hear that no disaster has accompanied the commencement
of an enterprise which you have regarded with such evil forebodings. I arrived
here yesterday, and my first task is to assure my dear sister of my welfare and
increasing confidence in the success of my undertaking.

I am already far north of London, and as I walk in the streets of Petersburgh,
I feel a cold northern breeze play upon my cheeks, which braces my nerves and
fills me with delight.

这是第一次尝试:

function parseTaggedText(lines: string[]): string[][] {
  const paragraphs: string[][] = [];
  const currPara: string[] = [];

  const addParagraph = () => {
    if (currPara.length) {
      paragraphs.push(currPara);
      currPara.length = 0;  // Clear the lines
    }
  };
for (const line of lines) {
    if (!line) {
      addParagraph();
    } else {
      currPara.push(line);
    }
  }
  addParagraph();
  return paragraphs;
}

但是当你运行这个例子后得到:

[ [], [], [] ]

这段代码问题在于 aliasing(技巧24) 和 mutation 的同时组合. aliasing发生在:

paragraphs.push(currPara);

你将 currPara 引用push到了paragraphs中, 然后将currPara修改为空[], 那么paragraphs中也将收到空[]. 总之下列代码就是错误的根源:

paragraphs.push(currPara);
currPara.length = 0;  // Clear lines

你可以通过声明readonly 来让 currPara不可变:

function parseTaggedText(lines: string[]): string[][] {
  const currPara: readonly string[] = [];
  const paragraphs: string[][] = [];

  const addParagraph = () => {
    if (currPara.length) {
      paragraphs.push(
        currPara
     // ~~~~~~~~ Type 'readonly string[]' is 'readonly' and
     //          cannot be assigned to the mutable type 'string[]'
      );
      currPara.length = 0;  // Clear lines
            // ~~~~~~ Cannot assign to 'length' because it is a read-only
            // property
    }
  };

  for (const line of lines) {
    if (!line) {
addParagraph();
    } else {
      currPara.push(line);
            // ~~~~ Property 'push' does not exist on type 'readonly string[]'
    }
  }
  addParagraph();
  return paragraphs;
}

你可以通过申明用let 来申明 currPara配合是有非变动的 方法:

let currPara: readonly string[] = [];
// ...
currPara = [];  // Clear lines
// ...
currPara = currPara.concat([line]);

不同与push, concat返回一个新 array, 让原来的currPara没有改变. 你现在可以自由改变currPara的指向array, 但是不改变这些array本身.

产生的错误有三种办法解决:

  1. 你可以复制一份currPara:
    paragraphs.push([...currPara]);
    
  2. 改变paragraphs的声明, 变成 readonly
    const paragraphs: (readonly string[])[] = [];
    
    注意: 这里的paragraphs 是一个可变array, 只是paragraphs每个元素是 readonly array.
  3. 可以使用断言消除错误:
    paragraphs.push(currPara as string[]);
    

有一个很重要的警告: readonly 是浅层的.比如你有一个 readonly array , array的元素是object, 那么object是可变的.

const dates: readonly Date[] = [new Date()];
dates.push(new Date());
   // ~~~~ Property 'push' does not exist on type 'readonly Date[]'
dates[0].setFullYear(2037);  // OK

类似还有 readonly的object, 那么object的 属性的属性是可变的:

interface Outer {
  inner: {
    x: number;
  }
}
const o: Readonly<Outer> = { inner: { x: 0 }};
o.inner = { x: 1 };
// ~~~~ Cannot assign to 'inner' because it is a read-only property
o.inner.x = 1;  // OK

用ts-essentials里面的泛型: DeepReadonly可以实现 深度readonly,

你也可以将 readonly用于索引签名:

let obj: {readonly [k: string]: number} = {};
// Or Readonly<{[k: string]: number}
obj.hi = 45;
//  ~~ Index signature in type ... only permits reading
obj = {...obj, hi: 12};  // OK
obj = {...obj, bye: 34};  // OK

这也能解决aliasing(技巧24) 和 mutation 的组合产生的错误.