找不着北的 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
。(也顺便填了开头的坑)
这里其实是因为在 foo
函数里的 value
参数类型是 any
,string
是 any
的子集,因此这个类型推导没有任何问题。
从这个例子里隐隐约约有种感觉,TS 好像不太安全?
案例二
再来做一个阅读理解,加深一下知识点:
以下代码运行后的结果是什么?
type Foo = {
foo: {
bar: string
}
}
const test = {} as Foo
console.log(test.foo.bar) // 这行结果是什么?
结果不难看出,会抛出 undefined 的错误。
但是交给 TS 去检查,它会告诉你:这代码写得太棒了,一点毛病都没有呢。
如果是像以前的我那种大聪明,可能会说:那就不允许使用 any
和 as
就好了嘛,使用 TS 就很安全了。
但没有什么是绝对安全的。当我们越相信一个规律的时候,它潜在的破坏力就会越大。
事实上,很多场景都无法脱离诸如 any
和 as
这些不安全的类型工具,我们得在混乱中学会分辨哪些是安全的。这就是 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 无法顺利推导出我们期望的结果。
为什么这么强调自动推导呢,原因有二:
- 如果什么都要人工干预,那不是智能,是智障。
- 只要是人为的,就有可能出错。
由于 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
流程里面的参数是 unknown
,catch
流程里的参数是 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)
答案揭晓:
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.isArray
和 ReadonlyArray
配合使用时,判断是数组的时候会导致内部原本是数组的类型变为 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
乍一看之下,好像明白了,又好像什么都不明白。
声明全局类型
全局类型好理解,但问题在于「全局」和「局部」的区别。
有一个关键的细节:
下面是全局类型
// 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 虽然有这么多毛病,但不妨碍人类用它做出一些"伟大"的产品。
不知道在哪个时间节点,我们发现 IDE 变得智能很多。就算用的只是 JS,也有很多智能提示。
比如 ES6 的语法、DOM 的语法,甚至是 React、Vue、lodash 等第三方包 ,敲一下键盘,就能得到后面需要的东西,写错了也会提示自己。
现在我们知道了,它的背后是 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
对象类型,得到看似毫无关联的 x
和 y
的类型。
是不是和 Vue 的语法很像?
但不需要实现框架细节,就能够得到预期类型的关系。
这也使得 TS 更像一个"外挂",也是我自己一直强调的:TS 本质是一个工具,有一些缺陷,但不妨碍它帮助我们写出更好维护代码。
因此,我们借助类型这个工具,可以实现很多「特别」的效果,比如:
-
在使用 Event 时,自动显示可用的事件,以及 callback 的类型:更好维护的发布订阅模式的应用
-
调用接口之后拿到的数据,将不靠谱的数据类型定义为「可选」,让 TS 帮我们判断什么时候要补充默认值:
interface ApiGetData { status: number data: { list?: string[] // 让系统告诉我们使用时要兼容 undefined 的情况 } }
-
通过包装一个 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
里的内容。
低可读性
由于 TS 类型不像 JS 代码那样可调试,泛型传递链又臭又长,难以追溯,因此对于一些复杂实现的类型代码,可读性差几乎是常态。
// 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。
实际使用当中,可能会导致更为严重的情况:
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 等更为严重的问题。
推荐在代码里禁用 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 本质是一个工具,有一些缺陷,但不妨碍它帮助我们写出更好维护代码。
如果以后有更好的工具,我会毫不犹豫的拥抱新的工具,来帮助我写出更少缺陷,更好维护的代码。
转载自:https://juejin.cn/post/7194237533076029497