找不着北的 TypeScript

自从有了 TS 的辅助,JS 写起来如鱼得水。

长期以往之后,迷迷糊糊之中似乎有这么一个概念:在 IDE 里推导得到的类型结果,就是代码真实运行的那个类型结果。

但我如果说:number 的 1 + 1 的类型是 string

你可能觉得我在扯蛋或搞事。

先不要走开,给我个机会解释一下。

「类型结果」vs「实际结果」

案例一

先来一段简单的代码:

let foo = 123
type bar = number
复制代码

第一行是声明一个变量。

第二行是声明一个类型。

它们是两种语法,但它们之间有一个共同点。

通过 typeof 运算符,换个姿势来看看:

let foo = 123
type Foo = typeof foo // Foo 的类型是 number
复制代码

变量 foo 可以通过 typeof 运算符转换成和 bar 一样的 number 类型。

哇,真神奇呀。

咳咳(正经),这里其实要说明的是,在 TS 的生态里,每个变量有自己的类型,它们之间有着一一对应的关系。

但是重点来了,这个类型关系并不一定准确

function foo (value): string {
  return value
}

const bar = foo(1+1)
复制代码

上面这段代码,尽管运行结果是 number,但 TS 告诉我们类型结果是 string。(也顺便填了开头的坑)

image-20230128150737495

这里其实是因为在 foo 函数里的 value 参数类型是 anystringany 的子集,因此这个类型推导没有任何问题。

从这个例子里隐隐约约有种感觉,TS 好像不太安全?

案例二

再来做一个阅读理解,加深一下知识点:

以下代码运行后的结果是什么?

type Foo = {
  foo: {
    bar: string
  }
}
const test = {} as Foo
console.log(test.foo.bar) // 这行结果是什么?
复制代码

结果不难看出,会抛出 undefined 的错误。

但是交给 TS 去检查,它会告诉你:这代码写得太棒了,一点毛病都没有呢。

image-20230128152547163

如果是像以前的我那种大聪明,可能会说:那就不允许使用 anyas 就好了嘛,使用 TS 就很安全了。

但没有什么是绝对安全的。当我们越相信一个规律的时候,它潜在的破坏力就会越大

事实上,很多场景都无法脱离诸如 anyas 这些不安全的类型工具,我们得在混乱中学会分辨哪些是安全的。这就是 TS 「找不着北」的原因。

类型推导

相信很多同学已经知道什么是「类型推导」了,但还是为一些 TS 萌新简单铺垫一下。

由于部分 JS 语法有显而易见的逻辑关系,所以可以做到一些自动推导的能力。

function foo (value: string): string {
  return value
}
// vs
function foo (value: string) {
  return value
}
复制代码

根据逻辑关系, value 类型是 string,通过 return value,大聪明 TS 就能得知返回的类型是 string

因此,后面的 foo 函数虽然把返回值类型 string 省略掉,但两者在 TS 里的效果依然"相同"(在实践上还是有区别的,但这里就不深入展开了)。

但是存在一些情况,导致 TS 无法顺利推导出我们期望的结果

为什么这么强调自动推导呢,原因有二:

  1. 如果什么都要人工干预,那不是智能,是智障。
  2. 只要是人为的,就有可能出错。

由于 JS 特性 导致无法自动推导预期类型

因为 JS 语法过于 难以理解 灵活,有些时候 TS 无法推导出开发者的意图:

const foo = {
  bar: [] // foo.bar 的类型是 any[]
}
复制代码

由于第二行的 bar 声明时未指明是什么数组,TS 内心也很绝望,JS 先支持的这种 sb 便捷语法,那自己只能把它当成 any[]来处理了。而any的危害大家都有所耳闻,这里就放进去一只害群之马。

但作为开发者,这种声明代码,肯定不会为了让大聪明 TS 方便理解,给数组里塞一个目标类型的值进去。

这里处理方法有两种:

const foo: {
  bar: string[]
} = {
  bar: []
}
复制代码
const foo = {
  bar: [] as string[]
}
复制代码

这种场景,更推荐第二种,语法简练 (这就是为什么不要一杆子打死 as 的原因)。但千万不要滥用 as用了也别说是我教的

还有一个更常见的场景:

JS 里的 Promise 由于语法原因,then 里面的参数没法和上文的 resolve 形成关联。因此 then 流程里面的参数是 unknowncatch 流程里的参数是 any

new Promise((resolve, reject) => {
  return Math.random() > 0.5 ? resolve(123) : reject(456)
}).then(data => {
  data // data 类型是 unknown
}).catch(err => {
  err // err 类型是 any
})
复制代码

调教方式如下:

new Promise<number>((resolve, reject) => {
  return Math.random() > 0.5 ? resolve(123) : reject(456)
}).then(data => {
  data // data 类型是 number
}).catch((err: number) => {
  err // err 类型是 number
})
复制代码

由于 平台特性 导致无法自动推导预期类型

对于跨平台,其实不论是 JS 还是 Java,系统都不知道对面会传来个什么玩意,算是通病。

但是因为 JS 过于 渣男 优秀,在哪都能见到他。因此「跨平台」在这是个更为广义的概念,不仅在外部隔着网线(Fetch Api),在内部(DOM、BOM、WASM等)也不消停。因此更为常见。

在跨平台的场景,如果 JS 都不知道是啥,TS 更是无法推断是啥玩意。

Fetch Api 场景:

fetch('/api').then(res => {
  return res.json()
}).then(data => {}) // data 的类型是 any
复制代码

序列化场景:

let foo = { bar: 123 }
localStorage.setItem('foo', JSON.stringify(foo))
let _foo = JSON.parse(localStorage.getItem('foo')!) // _foo 的类型是 any
复制代码

有的同学可能会问:我在使用 DOM Api 的时候,TS 也给了提示呀,很好用,为啥还说无法自动推导呢?

答:那是因为微软的工程师帮你负重前行。

由于 TS 逻辑问题 导致无法自动推导预期类型

这个标题是什么意思呢?

可以理解算是 TS 的 issue。

有的是可以修复的,属于特定版本可复现,这里就不谈了。

还有的是"无法修复"的,属于逻辑上的缺陷:

猜一猜 bar 是什么类型?

const foo: (undefined | string)[] = []
const bar = foo.filter(item => !!item)
复制代码

答案揭晓:

image-20230128165215312

bar 并没有因为 filter 内逻辑的存在,而收敛了类型。

解决方法:

通过「类型断言」

const foo: (undefined | string)[] = []
const bar = foo.filter((item): item is string => !!item) // bar 的类型是 string[]
复制代码

将通过 filter 的值类型全部断成 string,问题勉强解决。

还有"无解"的案例:

const foo: ReadonlyArray<string> = []
if (Array.isArray(foo)) {
  foo // 在这个大括号里,foo 的类型是 any
}
复制代码

Array.isArrayReadonlyArray 配合使用时,判断是数组的时候会导致内部原本是数组的类型变为 any。

下面是 Array.isArray 的类型实现:

interface ArrayConstructor {
    // ...
    isArray(arg: any): arg is any[];
    // ...
}
复制代码

简单来说就是 ReadonlyArray 是个特殊的"数组",在条件类型里,无法正常取出它泛型内的类型。

可以用这个函数改写做兼容:

function isArray (arg: any): arg is ReadonlyArray<any> {
  return Array.isArray(arg)
}
复制代码

但总归不优雅。

另外有兴趣的可以看下这个讨论,官方都闲置7 年了...。

全局类型 vs 全局变量

TS 中的两个全局的概念也容易找不着北。

这里主要就是涉及声明文件相关的知识点了。

这是声明一个全局类型:

// index.d.ts
interface GlobalType {
  foo: string
}
复制代码

这是声明一个全局变量:

// index.d.ts
declare const globalFoo: string
复制代码

乍一看之下,好像明白了,又好像什么都不明白。

image-20230129144542413

声明全局类型

全局类型好理解,但问题在于「全局」和「局部」的区别。

有一个关键的细节:

下面是全局类型

// index1.d.ts
interface GlobalType1 {
  foo: string
}
interface GlobalType2 {
  foo: string
}
复制代码

下面是局部类型

// index2.d.ts
export interface GlobalType1 {
  foo: string
}
interface GlobalType2 {
  foo: string
}
复制代码

可以看到,就是第二行有无 export 的区别。

差别就在这里,只要当前文件存在任意一个 export,里面定义的所有类型都是局部类型。

比如 index2.d.ts 里面,GlobalType2 没有 export,但它也是局部类型。

声明全局变量

接下来是声明全局变量,它容易和 JS 里的「全局变量」混淆。

做一个不存在的假设:当前全局作用域在 window 上,它对应的类型是 IWindow

我们可以在代码中直接敲出对应关键字来访问挂载在 window 上的属性,在类型里也就是写在 IWindow 上的那些属性。

Array.isArray([]) // Array 就是全局变量,假设 IWindow 上定义了 Array
复制代码

声明全局变量,可以类比是在 IWindow 里扩展了一个属性,但是它并不存在于 window 实例上,要自己用额外的代码去实现。

interface IWindow {
  globalFoo: string // 添加了一个 globalFoo 的属性
}
复制代码

回到真实的 TS 中。

根据上文,实现全局变量分两步:

第一步,全局类型上占个坑位 (这里要满足全局类型的条件,也就是不能存在 export)

// index.d.ts
declare const globalFoo: string
复制代码

第二步,JS 中实现对应功能

// index.ts
window.globalFoo = '123'
复制代码

这样,就可以直接在代码里访问 globalFoo,即存在类型,也不会在运行代码的时候是 undefined

npm 包里面的话,这俩还会有差异,不过大部分情况下用不上,有兴趣的同学自己看文档呗~~(绝不是因为偷懒)~~。

与 TS 和谐的生活

TS 虽然有这么多毛病,但不妨碍人类用它做出一些"伟大"的产品。

image-20230128170829852

不知道在哪个时间节点,我们发现 IDE 变得智能很多。就算用的只是 JS,也有很多智能提示。

比如 ES6 的语法、DOM 的语法,甚至是 React、Vue、lodash 等第三方包 ,敲一下键盘,就能得到后面需要的东西,写错了也会提示自己。

image-20230129162349203

现在我们知道了,它的背后是 TS 的声明文件的功劳,也知道了 TS 其实不那么好用,但是却不影响用它的人能无缝享受到它带来的生产力的提升。

在这背后是「封装」的理念。

就算在一些无法通过推导得到类型的场景,我们可以简化这个过程,只用类型描述输入和输出,再和对应 JS 代码关联上,忽略亿点点细节。

就像我们无缝使用 DOM Api 一样,甚至没有察觉背后有声明文件的存在。

TS 的本质是一个辅助工具

以下是 TS 官网上关于 ThisType 类型工具的示例,通过它可以实现类似 Vue 选项式语法上下文作用域的关联:

// 这只是一个例子,为了感受封装性,并不需要看懂(个人认为这是 TS 里最复杂的运用方式之一)。
type ObjectDescriptor<D, M> = {
  data?: D;
  methods?: M & ThisType<D & M>; // Type of 'this' in methods is D & M
};
 
function makeObject<D, M>(desc: ObjectDescriptor<D, M>): D & M {
  let data: object = desc.data || {};
  let methods: object = desc.methods || {};
  return { ...data, ...methods } as D & M;
}
 
let obj = makeObject({
  data: { x: 0, y: 0 },
  methods: {
    moveBy(dx: number, dy: number) {
      this.x += dx; // Strongly typed this
      this.y += dy; // Strongly typed this
    },
  },
});
 
obj.x = 10;
obj.y = 20;
obj.moveBy(5, 5);
复制代码

在第 17 行的 moveBy 函数体内,可以通过 this 访问在第 14 行定义的 data 对象类型,得到看似毫无关联的 xy 的类型。

是不是和 Vue 的语法很像?

但不需要实现框架细节,就能够得到预期类型的关系。

这也使得 TS 更像一个"外挂",也是我自己一直强调的:TS 本质是一个工具,有一些缺陷,但不妨碍它帮助我们写出更好维护代码

因此,我们借助类型这个工具,可以实现很多「特别」的效果,比如:

  • 在使用 Event 时,自动显示可用的事件,以及 callback 的类型:更好维护的发布订阅模式的应用

  • 调用接口之后拿到的数据,将不靠谱的数据类型定义为「可选」,让 TS 帮我们判断什么时候要补充默认值:

    interface ApiGetData {
      status: number
      data: {
        list?: string[] // 让系统告诉我们使用时要兼容 undefined 的情况
      }
    }
    复制代码

image-20230128174509703

  • 通过包装一个 helper 函数承载类型计算,然后用 ts-ignore 让它强制通过,实现任意想要达到的类型组合效果:

    假设有一个函数,传入一个url,自动帮我补全所有其他默认参数

    interface MixinType {
      methods: 'GET' | 'POST'
      timeout: number
    }
    
    const apiHelper = function <T extends { url?: string }>(
      arg1: T
    ):
      T extends { url: string } ? 
        () => T & MixinType // 在这行写任意想要的结果类型
        : () => any
    {
      // @ts-ignore
      return arg1
    }
    
    const getFetchArgs = apiHelper({
      url: '/get/sth',
    })
    复制代码

如下所示,通过apiHelper生成的函数,返回值就是我们预先定义的 MixinType 里的内容。

image-20230128175225167

低可读性

由于 TS 类型不像 JS 代码那样可调试,泛型传递链又臭又长,难以追溯,因此对于一些复杂实现的类型代码,可读性差几乎是常态。

image-20230129143211249

// ps: 不需要看懂这段代码
type EventNameTransFunction<
    EventFunctionTypes extends Record<string, IAnyFunction> = Record<string, IAnyFunction>,
    EventKeyTypes extends Record<string, string> = Record<string, string>,
> = {
    [T in keyof (EventKeyTypes&EventFunctionTypes)]: (EventKeyTypes&EventFunctionTypes)[T] extends IAnyFunction ?
        (EventKeyTypes&EventFunctionTypes)[T] :
        IAnyFunction<void>
}
复制代码

上面这段类型计算的逻辑实现了某个自定义的类型需求,但是一眼就懵,并且很难调试。

在 Vue、React-Redux 等框架的类型定义文件里,充斥了大量这种代码。

越深度使用 TS,写越多定制化的功能,越容易出现这种代码。也许自己还能勉强看懂,丢给别人,那内心是真的会问候对方的。

解决方法,就是封装。把一些复杂的东西隐藏起来,暴露良好的外部接口。比如,React 声明文件也一样复杂,但是我们并不需要关心怎么实现这些类型能力的,偶尔看看类型接口传参就行。

和找不着北的 TS 和谐的生活在一起。

一些实践技巧

压轴部分,分享一些上文没提到的其他实践经验,可以避免更多「找不着北」的问题。

只允许初始化场景的「类型断言」

「类型断言」上文出现过,它的威力非常强大,可以将某个类型强制关联到某个变量头上,比如:

const foo = [] as string[] // 这里用了 as 操作符
const bar = <{ foo: string }>{} // "<T>{}" 等同 "{} as T"
function isBoolean(args: any): args is boolean { return typeof args === 'boolean' } // 这里用了 is 操作符
复制代码

上文有讲到,类型错误带来的危害。「类型断言」是一种改变类型的方法,因此在使用的时候一定要明白自己在做什么

很多同学在用类型断言只是为了粗暴解决 ts error,这种情况一定是禁止使用类型断言的,造成的烂摊子如果让别人收拾,真的是会有想打人的冲动。

function getValue() {
  const value: string = ''
  return Math.random() > 0.5 ? false : value
}
// bar 有 boolean 和 string 两种可能,但为了解决 ts error,断言成 string 类型
const bar = getValue() as string
bar.trim() // 运行代码,有报 TypeError 的风险
复制代码

上面的代码只是一个例子,实际情况常见于一些复杂结构的类型当中。但原因是一样的。

我个人认为,能安全且推荐使用类型断言的只有一个场景:初始化。

const foo: {
  bar: string[]
} = {
  bar: []
}
复制代码

上面这段代码,常见于 react 或者 store 当中。新增一个变量还得对应新增类型,得维护两个地方。

用「类型断言」可以很好的解决这个问题:

const foo = {
  bar: [] as string[]
}
复制代码

在一些特殊的场景,因为 TS 的语法问题,只能用「类型断言」去解决,但前提是知道自己在做什么,以及多想一想,多 google 查一查,透过现象看本质,是否有其他更安全的做法。

禁止未赋值的初始化变量

未初始化的赋值,在 TS 的一些场景里直接使用不会报错。

let foo: { bar: string }

function test () {
  foo.bar // 这里 TS 不会报错,但实际上 foo 是 undefined
}
test() // 运行报错
复制代码

这里主要靠 ESLint 去帮我们检查。

"init-declarations": "error"
复制代码

在较为复杂的初始化,无法一行解决,就用函数去给变量初始化。

let foo: { bar: string } = init()

function init() {
  // ...
  return {
    bar: ''
  }
}
复制代码

用 解构赋值 代替 Object.assign

一个 TS 变量的类型在确定之后,后面就无法再变更了。

const bar = {
  foo: 123
}
Object.assign(bar, {
  vvv: 456
})
bar.vvv // 这里会有 ts error
复制代码

因此上面这段代码,虽然用 Object.assign 相比 bar.vvv = 456 而言,拓展属性不会报 TS 错误,但是 bar 的类型并没有拓展,导致下文访问 bar.vvv 提示 ts error。

image-20230128181139313

实际使用当中,可能会导致更为严重的情况:

const bar = {
  foo: 123
}
Object.assign(bar, {
  foo: '123' // 类型不匹配,但 ts 不报错
})
// ... 省略亿点点业务代码
bar.foo.toFixed(2) // 上文已经更改为 string 类型,但这里 ts 不报错
复制代码

这个问题常见于使用 TS 的新手当中。想要将 bar.foo 赋予其他类型的值,但是直接赋值会报错,不知道怎么解决,但发现用 Object.assign 不会触发,就以为是安全的用法。

但是在下文当中,都只会以为 bar.foo是 number 类型,在真实运行的时候就有概率出现 js error 等更为严重的问题。

image-20230128181621379

推荐在代码里禁用 Object.assign。如果要拓展对象,就新建一个,不要去更改原来的。

代码如下:

const bar = {
  foo: 123
}
// 新建一个对象去拓展原来的对象
const _bar = {
  ...bar, 
  foo: '123'
}
_bar.foo.toFixed(2) // ts 报错这里代码有问题,符合预期
复制代码

在达到拓展类型的目的的同时,确保了类型安全。

亦或者,封装一个安全的 assign :

function assign<T>(a: T, b: Partial<T>): T {
  return Object.assign({}, a, b)
}
复制代码

但还是推荐一巴掌拍死 Object.assign。比起应该用什么,不应该用什么更容易记得。毕竟又不是找不到替代方案。

寻找支持的泛型接口

前面有介绍过,有很多原因导致类型无法自动推导,成为了 any。

其实开发者也有意识到这个问题,会提设计泛型接口,补充相应的能力。

比如 Promise,它接受一个泛型,作为 then 的参数类型。

new Promise<number>((resolve, reject) => {
  return Math.random() > 0.5 ? resolve(123) : reject(456)
}).then(data => {
  data // data 的类型是 number
})
复制代码

比如 React 类组件,它第一个泛型参数是指定 this.props 的类型,第二个泛型参数是指定 this.state 的类型。

class Test extends React.Component<{
  test: string // 第一个泛型参数,决定 this.props 是什么类型
}, {
  foo: number // 第二个泛型参数,决定 this.state 是什么类型
}> {
  bar = () => {
    this.props.test // string 类型
    this.state.foo // number 类型
  }
}
复制代码

需要注意的是,不论是 Promise,还是 React,这些泛型参数都是开发者人为设计的,不是内置的,都能找到源代码,自己也可以实现。

因此,也存在设计的不是那么好的,比如 JSON:

JSON.parse<number>('123') // 不支持泛型,触发 ts error
复制代码

以它为例,我们可以自己套个马甲,封装一个对开发者更友好的 JSONParse 函数:

提供一个泛型参数 T,支持定义返回值的类型。

function JSONParse<T> (str: any) {
  return JSON.parse('123') as T
}
JSONParse<number>('123') // number
复制代码

或者直接拓展 TS 的类型库:

interface JSON {
  parse<T = any>(text: string, reviver?: (this: any, key: string, value: any) => any): T;
}
JSON.parse<number>('123') // number
复制代码

或者暴力一些(不推荐):

JSON.parse<number>('123') as number
复制代码

需要声明的是,这里使用「类型断言」,是因为我明白自己在做什么,并且为可能的结果负责。

后记

还是那句话,TS 本质是一个工具,有一些缺陷,但不妨碍它帮助我们写出更好维护代码。

如果以后有更好的工具,我会毫不犹豫的拥抱新的工具,来帮助我写出更少缺陷,更好维护的代码。

猜你喜欢

转载自juejin.im/post/7194237533076029497