TypeScript 越早知道越好的三个技巧

399 阅读5分钟

原文链接:www.cstrnt.dev/blog/three-…

技巧一:只读属性 Readonly<T>

先看看几个例子: 下面一个简单的函数可以对一个数组进行排序,然后返回一个经过排序后的数组。

function sortNumbers(array: Array<number>) {
  return array.sort((a, b) => a - b)
}

下面的代码看上去没有问题,让我们先想想在控制台会输出怎样的结果,一定要花时间想想。

const numbers = [7, 3, 5]

const sortedNumbers = sortNumbers(numbers)

console.log(sortedNumbers)
console.log(numbers)

第一个输出的结果显而易见是 [3, 5, 7]。但是第二个输出的结果竟然跟第一个输出的结果一样,你可能在想:明明我们已经用 const 定义这个数组,为什么它还能被修改?

在 JavaScript 中,数组和对象很特殊。如果将它们作为某些特定的函数例如 Array.sort 的参数,原本的数组和对象会发生改变。

此时,只读属性就可以派上用场了 🚀

把上面的排序函数修改一下:

function sortNumbers(array: Readonly<Array<number>>) {
  return array.sort((a, b) => a - b)
}

写完没多久,TypeScript 就会报错:Property ‘sort’ does not exist on type ‘readonly number[]’,这并不是我们想要的效果!说明我们改变要传入函数的参数(数组)的类型就会带来别的影响。好了,我们就不能用现成的函数帮助我们排序数组吗?当然可以,我们只需要排序这个数组的拷贝,而不是直接排序这个数组。在 JavaScript 中有很多复制数组的方法,如:ES6中新增的特性扩展运算符[...array]、数组对象方法array.concat()Array.from(array)array.slice()。我们使用拓展运算符来拷贝数组,然后修改上面的代码如下:

function sortNumbers(array: Readonly<Array<number>>) {
  return [...array].sort((a, b) => a - b)
}

重新运行一遍代码,可以看到我们想要的结果出现了。以上的方法对于对象也一样适用。

如果你想要了解 JavaScript 的不可变性可以阅读这篇文章

技巧二:Any 和 Unknown

如果你搭配 TypeScript 和 Eslint 使用时,你肯定会见过这条警告 unexpected any。我就会想过为什么使用 any 会被认为「不好」,不然你要怎么修饰一个可以赋任何数据类型的变量?下面是代码示例:

const someArray: Array<any> = []

// ... adding the values
someArray.push(1)
someArray.push('Hello')
someArray.push({ age: 42 })
someArray.push(null)

我们定义了一个可以存放所有数据类型变量的数组,给这个数组添加了数字、字符串和一个对象。再看看下面的代码,想想这样写会产生怎样的结果:

const someArray: Array<any> = []

// ... adding the values
someArray.forEach((entry) => {
  console.log(entry.age)
})

这段代码通过了 TypeScript 的检查并且没有报错地通过了编译,但是却无法运行。问题出现在这里,在要循环到 null 值或者 undefined 值时,尝试访问 .age属性,会抛出错误:Uncaught TypeError: Cannot read properties of null

可以看出,通过 TypeScript 的编译器来判断有没有问题是不够的,特别是在我们要认为这段代码没有问题的时候。但我们可以轻而易举地解决类似上面的问题,只要在定义数组时,使用 Array<unknown> 而不是 Array<any> ,修改后的代码如下:

const someArray: Array<unknown> = []

// ... adding the values

someArray.forEach((entry) => {
  console.log(entry.age)
})

修改后的代码就不能通过编译了,运行到要访问 entry.age 时 TypeScript 就会报错。

// ... other code

someArray.forEach((entry) => {
  // Object is of type 'unknown'
  console.log(entry.age)
})

在对 unknown 类型的值执行大多数操作之前,我们必须检查它的类型(或者值)。下面是代码示例::

// ... other code

type Human = { name: string; age: number }

someArray.forEach((entry) => {
  // if it's an object, we know it's a Human
  if (typeof entry === 'object') {
    console.log((entry as Human).age)
  }
})

上面这种情况下,我们检查该值是否是一个对象,然后再访问.age属性。对于这种非常抽象的说法,下面是一个小总结:

使用 any 类型基本上默认了 TypeScript 不会对这部分代码检查。所以说,最好避免使用 any,而使用 unknown,因为它强制你在使用前检查值的类型,否则将无法编译。

注意:不要使用 typeof x === ‘object’ 来检查是否为对象,这样做对于数组也会返回 true

技巧三:使用Records定义对象

当我刚开始使用 TypeScript 时,我总是会去谷歌搜索如何定义一个对象,因为我总是记不住方法,然后写成这样:

interface Person {
  [key: string]: unknown
}

const Human: Person = {
  name: 'Steve',
  age: 42,
}

这在 TypeScript 确实是一种定义对象的方法,不过是不是太难记住了,而且还有一定的局限性。

如果想要对这个对象限定在几个 key,我会先创建一个字符集,下面是代码示例:

type AllowedKeys = 'name' | 'age'

interface Person {
  [key: AllowedKeys]: unknown
}

const Human: Person = {
  name: 'Steve',
  age: 42,
}

但是在 TypeScript 中却会报出以下的错误:

An index signature parameter type cannot be a literal type or generic type. Consider using a mapped object type instead.

索引的参数类型不能是一个字面类型或者泛型。建议使用一个映射的对象类型。

啊这?TypeScript 报的这个错让你直接想回去用 JavaScript 的心都有了。不过让代码更有可读性的解决方案就出现了:

type AllowedKeys = 'name' | 'age'

// use a type here instead of interface
type Person = Record<AllowedKeys, unknown>

const Human: Person = {
  name: 'Steve',
  age: 42,
}

我们只要修改一下,将 interface 改为 type,这样就可以定义一个新的 type 然后使用关键字 Record——需要两个泛型参数,第一个是对象键以及第二个是值的类型(unknown 取决于值)。是不是很简单?另外,如果你想要在 AllowedKeys 添加新的值,在 Human 对象中将会抛出错误,这是因为 Human 对象中缺少这些属性。