likes
comments
collection
share

关于TypeScript,你应该知道些什么?(第一篇)

作者站长头像
站长
· 阅读数 16

前言

最近在看《Effective TypeScript》,深感自己对TypeScript的使用和思考还是太过于浅显了,就借此机会写一下自己的一些读书感悟,希望对正在使用TypeScript的朋友们有点帮助

正文

1. 不要被类型检查欺骗

function asNumber(val: string | number): number {
	return val as number
}
// 这时即使类型检查通过了,但经过编译后的js代码仍然可能返回类型为string的值

// 正确做法如下👇
function asNumber(val: string | number): number {
	return typeof val === 'string' ? Number(val) : val
}

2. TypeScript并不会影响编译时的性能,tsc在将TypeScript编译为JavaScript时会将类型清除,因此不存在影响性能的问题

但当遇到需要通过polyfill兼容时,tsc会因需要对代码进行转换而造成一定的性能影响,但不会成为主要原因

3. 当需要对一个对象进行遍历时,可能会出现key的类型为string从而造成类型错误的问题

interface Obj {
	a: string
	b: string
}
function recreate(obj: Obj) {
	for (const key in obj) {
		const val = obj[key]
		//    ~~~ 元素隐式具有"any"类型
	}
}

可以用下面的方法解决👇

function recreate(obj: Obj) {
	let key: keyof Obj
	for (key in obj) {
		const val = obj[key]
	}
}

4. 尽可能少用any类型

当你设计了下面这段代码的时候,运行时并不会发现任何问题👇

interface ComponentProps {
	onSelectitem: (item: any) => void
}

function renderSelector(props: ComponentProps) { ... }

let selectedId: number = 0

function handleSelectItem(item: any) {
	selectedId = item.id
}

renderSelector({ onSelectitem: handleSelectItem })

然而当你修改了ComponentProps时,仍然会通过类型检查

interface ComponentProps {
	onSelectItem: (item: number) => void
}

当ts在编译时,上面的代码并不会提示任何错误,但是当handleSelectItem对象传入的参数为number类型时,就会造成错误导致代码无法运行

5. 善用tsserver

使用过TypeScript的朋友们应该很熟悉tsc,作为编译器,它帮助我们将ts代码转换为js代码,但在开发过程中帮助我们检查类型、补全代码的则是tsserver,在tsserver的帮助下,我们可以轻松通过编译器来了解类型的构造以及补全代码

// 当我们在使用fetch时,tsserver可以帮助我们跳转到定义fetch类型的源码中,帮助我们了解如何使用fetch
const request = fetch('http://example.com')
//              👆windows下ctrl+鼠标左键,mac下command+鼠标左键

6. 善用集合

如果你希望将一个类型为另一个类型扩充属性,那你可以这样做👇

type Person = { id: string }
type Student = { name: string } & Person // Student类型就同时拥有id和name属性

// 同样的,你也可以使用接口(interface)实现
interface Person { id: string }
interface Student extends Person { name: string } // 也能实现相同的效果

有时候你可能希望函数的参数为对象中的某些属性,那就可以这样做👇

interface Person {
	id: string
	name: string
	age: number
}

type KeyOfPerson = keyof Person // KeyOfPerson类型继承了Person类型的所有属性名
//   👆 类型为'id' | 'name' | 'age'

function inputVal<K extends keyof T, T>(val: T[], key: K) {
	...
}

const people: Person[] = [{ id: '1', name: 'Joe', age: 12 }, { id: '2', name: 'Anna', age: 8 }]

inputVal(Person, 'id') // correct!
inputVal(Person, 'name') // correct!
inputVal(Person, 'phone') // fail! 类型'phone'不能赋值给类型为'id' | 'name' | 'age'的参数

同样的,如果你希望某些参数只有在特定的参数值才允许使用时,也可以使用集合实现

type Person = {
	name: string
} & ({ gen: 'male', strength: number } | { gen: 'female', charm: number })

const male: Person = {
	name: 'Joe',
	gen: 'male',
	strength: 10
} // correct!

const female: Person = {
	name: 'Alice',
	gen: 'female',
	strength: 10
}  // 对象字面量只能指定已知属性,并且“strength”不在类型“{ name: string; } & { gen: "female"; charm: number; }”中

7. 有选择地使用类型声明类型断言

首先来了解一下什么是类型声明类型断言👇

interface Person { name: string }

const man: Person = { name: 'Johnson' } // 这是类型声明
const man = { name: 'Johnson' } // 这是类型断言

上面两种方式的结果都是相同的,man都为Person类型,但是不同的是: 1⃣️ 类型声明在对象声明时就确定了对象的值类型必须为Person 2⃣️ 类型断言则是将赋值给man对象的值声明为Person 请看下面的例子🌰

const man: Person = { name: 'Johnson', age: 20 } // fail! Person类型中并不包含age
const man = { name: 'Johnson', age: 20 } // correct!

这也就意味着,当你不了解值的具体类型时,不要轻易使用类型断言 但在某些情况下,我们知道值的类型是非常明确的,只是TypeScript为了保险起见多加了null类型,那么这个时候就可以使用类型断言来告诉TypeScript👇

const dom = document.querySelector('body') as HTMLBodyElement

// 你还可以这样做,同样能达到断言的效果
const dom = document.querySelector('body')!

同样是上面的Person类型,如果我们需要在一个函数中将返回值设置为Person[]类型应该怎么做呢?

const people = ['Alice', 'Joe', 'Jcob'].map(name => { name })
// 此时类型为{ name: string }[]

// 类型断言
const people = ['Alice', 'Joe', 'Jcob'].map(name => { name } as Person)

// 类型声明
const people = ['Alice', 'Joe', 'Jcob'].map((name): Person => { name })

上面的两种方式最终都能使people对象正确声明为Person[]类型,但是如果当后续修改了Person类型时,就会发现**类型断言并不会提示你返回值的类型不正确!**

interface Person { name: string, isAdmin: boolean }

const people = ['Alice', 'Joe', 'Jcob'].map(name => { name } as Person)
// correct!

const people = ['Alice', 'Joe', 'Jcob'].map((name): Person => { name })
// fail! { name: string }类型缺少了Person类型中的age属性

因此,在条件允许的情况下,请尽可能使用类型声明

8. 额外属性检查的局限性

当你对一个已经声明了类型的变量进行赋值时,TypeScript会确保值的范围只在类型包含的属性中

interface Person { id: string; age: number }

const man: Person = { id: '0', age: 10 }

const woman: Person = { id: '1', name: 'Elsa', age: 8 }
// fail! Person类型中并不包含name属性

但是当你将一个已经声明的对象赋值给变量时,则不会出现这个错误

const info = { id: '1', name: 'Elsa', age: 8 }

const woman: Person = info // correct!

看到这里可能你会说这是不是发现了一个TypeScript的BUG? 其实并不是,这只是由于info变量的类型中包含了Person变量的子集,因此通过了TypeScript的类型检测

**也就是说,TypeScript的类型声明校验对于非字面量声明是不严格的 **

但是这种变化是隐性的,如果不仔细检查可能会忽略,如果你希望Person类型就是允许有额外的属性的话,那你可以这样做,会更安全些👇

interface Person {
	id: string
	age: number
	[key: string]: string
}

const woman: Person = { id: '1', name: 'Elsa', age: 8 } // correct!

9. 尽可能为整个函数定义类型

TypeScript中,不仅可以对变量定义类型,也可以对函数声明类型

type CalcFnc = (a: number, b: number) => number

const plusNumber: CalcFnc = (a, b) => a + b
// 在已经定义了函数的类型后,就不需要在函数声明时重新定义

你可能会说,那我直接在函数声明时对函数的参数和返回值定义类型不行吗?完全没有问题,而且这种方式会更加灵活,但如果你在对某些工具库进行二次封装时,这可能不是一种很好的方法。

例如,在React中提供了MouseEventHandler类型,如果你的修改并不涉及参数的话,那么最好的做法就是将函数定义为MouseEventHandler类型,而不是将参数定义为MouseEvent类型,然后再定义返回值类型

再举个例子🌰,现在需要将fetch函数进行二次封装,需要对请求中可能出现的错误进行单独处理,你可能第一时间会想到这样写👇

async function fetchData (input: RequestInfo, init?: RequestInit) {
	const response = await fetch(input, init)
	if (!response.ok) {
		throw new Error(`Request Error! ${response.status}`)
	}
	return response
}

但如果并不需要对参数进行拓展的话,下面的写法是更好的选择

const fetchData: typeof fetch = (input, init) => {
	const response = await fetch(input, init)
	if (!response.ok) {
		throw new Error(`Request Error! ${response.status}`)
	}
	return response
}

你可能会说,这看起来没有区别啊?但事实上并不是这样,当你将函数中的错误处理从throw修改为return时,TypeScript就会捕获到第二个函数的错误,而第一个函数就不会被捕获错误👇

async function fetchData (input: RequestInfo, init?: RequestInit) {
	const response = await fetch(input, init)
	if (!response.ok) {
		return new Error(`Request Error! ${response.status}`)
		// 这里并不会被TypeScript检测到任何错误,但是请求发生错误时就不会出现任何错误提示
	}
	return response
}

const fetchData: typeof fetch = (input, init) => {
	const response = await fetch(input, init)
	if (!response.ok) {
		return new Error(`Request Error! ${response.status}`)
		// 不能将类型Promise<Reponse | HTTPError>分配给类型Promise<Response>
		// 不能将类型Response | HTTPError类型分配给类型Response
	}
	return response
}

所以,下次你在声明一个新的函数时,应该先思考一下是否有合适的类型可以用于函数声明,而不是直接给函数的参数和返回值定义类型

10. TypeInterface的异同

如果你使用过TypeScript,那你对这两种类型声明方式肯定不会陌生,在一般情况下,这两种声明方式的作用是一样的

type TNum = { num: number }
interface INum = { num: number }

type TCalNum = (a: number, b: number) => number
interface ICalNum { (a: number, b: number): number }

type TPair<T> = { name: T }
interface IPair<I> { name: I }

上面使用两种方式声明的类型都完全一致,并且interfacetype也可以互相扩展

interface IProps extends TPair { age: number }
type TProps = IPair & { age: number }

完全没有问题!正如开头所说,一般情况下这两种方式没有任何区别,那他们之间的区别在哪呢?

首先是联合类型type可以声明联合类型,而interface则无能为力

type TCompose = 'A' | 'B'

type TMix = (IPair | TPair) & { add: string }
// 这里为Ipair类型和Tpair类型都添加了一个add属性

interface也不是吃素的,type有他的本领,interface也有他独特的类型拓展方法

interface IExtends {
	isAdmin: boolean
	password: string
}
interface IExtends {
	username: string
}

const user: IExtends = {
	username: 'user',
	password: '123',
	isAdmin: false
} // correct!

上面我们连续声明了两个IExtends类型,但他们并不会进行覆盖操作,而是进行合并操作,而type则相反,连续的声明会覆盖先声明的type

因此,如果你希望编写出来的类型允许被拓展的话,那就使用interface;如果你不希望类型被拓展type是更好的选择。同时,如果仓库的整体风格是使用interface,那为了保持一致性你也应该尽可能地使用interface

11. 学会使用Readonly

在开始之间,我们先看下面这个例子🌰,并请尝试推断出他的结果是什么

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

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

现在公布结果,上面的代码将会输出0 1 2 3 4

可能有的朋友已经发现了华点,在arraySum函数中我们使用pop函数修改了参数arr的值,由于js的对象引用导致nums直接被修改了,因此每次arraySum被调用时传递的nums其实都只有一个值

因此对于那些不应该在函数内部修改参数的函数来说,对参数使用readonly是一个正确的选择,可以帮助你避免一些不经意间的值修改操作

上面的例子在使用readonly后就能够很好的解决这个问题

function arraySum(arr: readonly number[]) {
	let sum = 0, num
	while((num = arr.pop()) !== undefined) {
	//               ~~~ 类型“readonly number[]”上不存在属性“pop”
		sum += num
	}
	return sum
}

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

同样的readonly也可以作用在赋值上

const sentence: string[][] = []
const word: readonly string[] = ['i', 'love', 'u']

sentence.push(word)
//       ~~~~ 类型“readonly string[]”的参数不能赋给类型“string[]”的参数。

如果你希望对象本身是可以修改的,仅是内部的属性不允许修改的话,也可以使用let声明

let word: readonly string[] = ['i', 'love', 'u']

word = ['i', 'hate', 'u'] // correct!
word[0] = 'me'
//~~~~~ 类型“readonly string[]”中的索引签名仅允许读取。 

但需要注意的是,readonly是浅层的,虽然你不能修改readonly对象本身,但你可以修改它里面的属性,看下面的例子🌰

const dates: readonly Date[] = [new Date()]
dates.push(new Date())
//    ~~~~ 类型“readonly Date[]“不存在“push”
dates[0].setFullYear(2024) // correct!

对于readonly的兄弟Readonly也是同样的

interface Outer {
	inner: {
		x: number
	}
}

const o: Readonly<Outer> = { inner: { x: 0 } }
o.inner = { inner: { x: 1 } }
//~~~~~ 不能分配给”inner“,因为它是一个只读属性
o.inner.x = 1 // correct!

对于上面的Readonly<Outer>来说,创建出来的类型是这样的👇

type T = Readonly<Outer>
// Type T = {
//  readonly inner {
//   x: number
//  }
// }

最后总结一下:

  1. 如果你的函数不需要修改参数,你应该给参数加上一个readonly标志以防意料之外的值操作
  2. readonlyReadonly都只作用于浅层对象

结语

目前我还没有读完这本书,所以能写的东西也比较有限,希望能对你有点帮助

如果文中有任何错误或是需要修改的地方,烦请指出,感激不尽

还有我的个人博客,欢迎各位来参观