likes
comments
collection
share

TypeScript的两个“多此一举”的断言小技巧 顺带探讨一下Object.keys的返回值类型问题

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

摘要

TS给前端开发者带来方便的同时,也使得我们在开发过程中,有时不得不为了类型安全而做一些“多此一举”的事情,本文将盘点两个多此一举的封装断言的“小技巧”,权当学习,实际开发请谨慎使用。

在第二个例子中,我还会稍微探讨一下Object.keys的返回值类型问题。

无处不在的T | undefined

在JS中,如果我们想获取一个HTML元素并进行操作,通常是这么做的:

const el = document.querySelector('.el')
el.textContent = 'hello'

但是,在TS中,querySelector的返回值类型是Element | null,如果我们直接操作el,TS就会报错。

注:getElementById的返回值类型为HTMLElement | null

TypeScript的两个“多此一举”的断言小技巧 顺带探讨一下Object.keys的返回值类型问题

原因倒也不难理解,因为.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了。

TypeScript的两个“多此一举”的断言小技巧 顺带探讨一下Object.keys的返回值类型问题

这有点类似于科里化,将函数作为第一个参数,函数需要的参数则列在后面。

TypeScript的两个“多此一举”的断言小技巧 顺带探讨一下Object.keys的返回值类型问题

这种封装算得上是真正的多此一举,纯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

TypeScript的两个“多此一举”的断言小技巧 顺带探讨一下Object.keys的返回值类型问题

第一种十分不推荐的解决办法,是在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)[];
}

TypeScript的两个“多此一举”的断言小技巧 顺带探讨一下Object.keys的返回值类型问题

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类型的对象的键名。

但实际传入的参数taskCountsTaskCounts类型多了一个café属性,前者是后者的子类型,这种做法TS是允许的。

最终,我们得到的taskLocation类型是TaskLocation[],也就是("work" | "home")[],但是,我们期待的类型应该是("work" | "home" | "café")[]

TypeScript的两个“多此一举”的断言小技巧 顺带探讨一下Object.keys的返回值类型问题

更极端一点,如果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)

TypeScript的两个“多此一举”的断言小技巧 顺带探讨一下Object.keys的返回值类型问题

正因为存在上述问题,官方才没有将(keyof T)[]作为Object.keys的返回值。

也因此,在实际开发中,我们也得谨慎使用。

转载自:https://juejin.cn/post/7248921465245777976
评论
请登录