TypeScript的两个“多此一举”的断言小技巧 顺带探讨一下Object.keys的返回值类型问题
摘要
TS给前端开发者带来方便的同时,也使得我们在开发过程中,有时不得不为了类型安全而做一些“多此一举”的事情,本文将盘点两个多此一举的封装断言的“小技巧”,权当学习,实际开发请谨慎使用。
在第二个例子中,我还会稍微探讨一下Object.keys的返回值类型问题。
无处不在的T | undefined
在JS中,如果我们想获取一个HTML元素并进行操作,通常是这么做的:
const el = document.querySelector('.el')
el.textContent = 'hello'
但是,在TS中,querySelector
的返回值类型是Element | null
,如果我们直接操作el,TS就会报错。
注:getElementById的返回值类型为HTMLElement | null
原因倒也不难理解,因为.el
元素不一定存在。
为此,我们需要对它进行断言,否则每次都得事先判断它是否为null:
// 值断言
const el = document.querySelector('.el') as HTMLElement
el.textContent = 'hello'
// 或者非空断言
const el = document.querySelector('.el')
el!.textContent = 'text'
// 否则,每次都得做一次判断,这会增加运行时开销,但也能最大限度保证运行时安全。
const el = document.querySelector('.el')
if (el) {
el.textContent = 'hello'
}
// 如果只是调用函数,还可以使用可选链
const el = document.querySelector('.el')
el?.addEventListener('click', () => {
console.log('click')
})
类似的例子在TS中无处不在,例如在Vue3 + TS项目中,获取的子组件实例如何避免每次都要判断它是否undefined,也是一个典型的例子。
虽然断言不一定是个优雅的做法,但它确实是一个较好的解决方案,它不会增加额外的运行时开销。
既然都是多此一举,那我们何不更进一步,将断言封装成函数呢:
// fren的意思是function returns exclude null
function fren<T extends (...args: any[]) => any>(fn: T, ...args: Parameters<T>) {
return fn(...args) as ReturnType<T> & {}
}
const el = fren(document.getElementById, '.el')
el.textContent = 'hello'
ReturnType<T> & {}
,是NonNullable<ReturnType<T>>
的简略写法,如果对于NonNullable
或{}
抱有疑问,欢迎参考我之前的文章:TS类型体操(二) TS内置工具类1#NonNullable。
如此一来,el的类型就只剩下Element
了。
这有点类似于科里化,将函数作为第一个参数,函数需要的参数则列在后面。
这种封装算得上是真正的多此一举,纯JS中我们是不可能这么干的,而且它的可读性也不太好,所以,虽然算是一种解决方案,但建议大家把它当作是一个学习TS的例子,实际开发谨慎使用。
隐式any
在JS中,遍历对象是个很常用的操作,其中一种很受欢迎的做法就是使用Object.keys
来获取所有的键,然后通过键来遍历,实践表明,这么做通常会比for in的性能更好:
const obj = {
a: 1234,
b: 'abcd',
}
const keys = Object.keys(obj)
keys.forEach(key => {
const value = obj[key]
})
但如果在TS中,默认情况下,这么做会因隐式any
而报错。
因为Object.keys
的返回值类型是string[]
,所以key
就是string
,而obj的键类型应该是'a' | 'b'
,因此TS会推断obj[key]
类型是any
。
第一种十分不推荐的解决办法,是在tsconfig.json
中将noImplicitAny
字段设为false
,允许隐式any
。
{
"compilerOptions": {
"noImplicitAny": false
}
}
第二种解决办法,就是给obj声明为键为string的类型:
const obj: Record<string, number | string> = {
a: 1234,
b: 'abcd',
}
const keys = Object.keys(obj)
keys.forEach(key => {
const value = obj[key]
})
第三种解决办法,那就是使用断言:
const obj = {
a: 1234,
b: 'abcd',
}
const keys = Object.keys(obj) as (keyof typeof obj)[]
keys.forEach(key => {
const value = obj[key]
})
同样,我们也可以多此一举的将其封装为函数:
function objectKeys<T extends object>(obj: T) {
return Object.keys(obj) as (keyof T)[];
}
Object.keys的返回值问题
看到这里,爱思考的你也许会冒出一个疑问,为什么TS官方不让Object.keys
直接返回(keyof T)[]
,而要返回string[]
呢?
对此,官方开发者有过详细解释:Microsoft/TypeScript#12253,这里我举例子稍微解释一下为什么:
type TaskLocation = "work" | "home"
type TaskCounts = Record<TaskLocation, number>
function getLocation(counts: TaskCounts) {
return Object.keys(counts) as (keyof TaskCounts)[] // 使用断言,用于假设Object.keys返回值为(keyof T)[]
}
const taskCounts = { work: 10, home: 5, café: 3 }
const taskLocation = getLocation(taskCounts) // 类型为TaskLocation[]
请思考上面这个例子,getLocation
函数用于获取TaskCounts
类型的对象的键名。
但实际传入的参数taskCounts
比TaskCounts
类型多了一个café
属性,前者是后者的子类型,这种做法TS是允许的。
最终,我们得到的taskLocation
类型是TaskLocation[]
,也就是("work" | "home")[]
,但是,我们期待的类型应该是("work" | "home" | "café")[]
更极端一点,如果counts
参数的类型是{}
,taskLocation
的类型将变成never[]
type TaskLocation = 'work' | 'home'
type TaskCounts = Record<TaskLocation, number>
function getLocation(counts: {}) {
return Object.keys(counts) as (keyof typeof counts)[]
}
const taskCounts = { work: 10, home: 5, café: 3 }
const taskLocation = getLocation(taskCounts)
正因为存在上述问题,官方才没有将(keyof T)[]
作为Object.keys
的返回值。
也因此,在实际开发中,我们也得谨慎使用。
转载自:https://juejin.cn/post/7248921465245777976