写这篇文章的初衷,是因为又有一个粉丝朋友被 TypeScript 的类型体操逼疯了。他跟我吐槽了一通,然后问我是不是他使用 TS 的姿势不对,为什么感觉到的全是痛苦。
当然,我自己最近也对 TypeScript 怨念颇深,因为我把自己项目中的 React 升级到了 "react": "^18.2.0" ,对应的类型 "@types/react": "^18.2.45", 也升级了。然后我的项目就像中毒了一样,报了一堆错。一些三方工具库的类型直接就不兼容了,那一瞬间就超级想要放弃 TypeScript.
所以就想趁着这个烦躁情绪还在,来跟大家好好吐槽一下 TypeScript,这个让人又爱又恨的技术方案。不然我怕过段时间我就忘记了阵痛,又开始只记得 TS 的好了。
一、初心
好多大佬都在吐槽 JavaScript 是一种弱类型的语言,这也是毛病那也是毛病。所以 TypeScript 出现了。他是 JavaScript 的超集,具有强大的类型系统。TS 的初心就在于,他想要把 JS 变成一门强类型语言。
也就是说,TS 的出现,最开始的目的就是为了限制 JS 弱类型的灵活性。可是,在发展的过程中,也不知道是 TS 本身的问题,还是某些使用者有问题,玩着玩着,大家就在绞尽脑汁想要追求类型的灵活性。
于是,类型体操诞生了。
就比如,有的人想要封装一个方法,去获取数据最后一个值的类型到底是什么。然后就一顿体操操作出来。如下
type Last<T extends any[]> = T extends [...any[], infer Latest] ? Latest : never
一个小小的三目运算符,叠加了好几个基础语法。
然后,我的问题就是,在强类型的逻辑里,一个数组,为什么要有不同类型的子项?
我们来梳理一下这个逻辑,假如我允许数组中存在不同类型的子项,会发生什么事情呢?我们来试试看:现在我定义一个简单的数组,子项类型可能会是 number 或者 string,于是我这样声明数组。
const arr:Array<number | string> = [1, 'string']
OK,这里还没有什么问题,然后如果我要使用这个数组呢?在使用的过程中,子项类型不同,会走向不同的逻辑,于是我们会在使用的时候对类型进行判断
const arr: Array<number | string> = [1, 'string'] arr.map(item => { if (typeof item === 'number') { return item + 1 } if (typeof item === 'string') { return item.toLocaleUpperCase() } return item })
从写法上,这也没有什么问题,但是我有一个问题就是,那我们使用 ts 对 arr 进行类型约束的意义在哪里?
没错,意义消失了。我们使用 TS 的初衷是为了限制 JS 类型的灵活性,但是在使用的过程中,又把 JS 类型的灵活性找回来了... 所以,回过头来思考一下我们刚才写了一个体操去获得数组最后一项的类型是什么,这个体操存在的基础就是,认可了数组子项类型的多变。
所以很多人在使用 TS 的过程中,感受不到 TS 的意义,更多的是感受到痛苦。因为我们在使用 ts 时,并没有想着去限制 JS 类型的多样性,而是在尽可能的想办法使用 ts 的语法去包容 JS 的弱类型。然后大家就在类型体操的路上越走越远。
属于典型的既要强类型,又要灵活性。不痛苦才怪。
二、类型体操,无非过度嵌套而已
TS 的类型体操,透露着一股强烈的过度嵌套味道。
就比如在 react 18 的类型声明中,对 Provider 的封装,内部是实现是这样的
interface ExoticComponent<P = {}> { /** * **NOTE**: Exotic components are not callable. */ (props: P): ReactElement | null; readonly $$typeof: symbol; } interface ProviderExoticComponent<P> extends ExoticComponent<P> { propTypes?: WeakValidationMap<P> | undefined; } interface ProviderProps<T> { value: T; children?: ReactNode | undefined; } type Provider<T> = ProviderExoticComponent<ProviderProps<T>>;
是的,我这里要吐槽的就是 React 的类型声明
就是几个简单的属性,愣是写了好几个 interface 出来继承,泛型也是一层套一层,狗看了都要摇头。这逻辑拆分得,比函数柯里化都还要过分。关键的问题是,还能让你看得迷糊... 觉得他高深莫测... 就是主打一个有病。给我们在使用的时候带来的痛苦就是,当类型推导在某个环节断层,你又要去兼容它不报错,有的时候我都不知道咋写...
再比如我们列举一个简单的例子。我知道有的人已经开始看不懂了
type Exclude<T, K> = T extends K ? never : T type Omit<T, K> = { [P in keyof T as P extends Exclude<keyof T, K> ? P : never]: T[P] }
看上去是不是很高级。
这真的是把语法嵌套用得炉火纯青。你在 js 里面这样写,不被喷就是好的了。但是你说怪不怪,放到 TS 里,就变成高大上了????
但凡是一个正常的程序员,都知道这是基础语法的过度嵌套,上面例子虽然实现了 Omit 的功能,但是可读性那是一点都没有。不过呢,有的人会告诉你,你得学会这样搞哦,不然就是不懂 TS!
???
什么时候语法过度嵌套使用还成学习目标了?
有的地方更过分,直接整个三目运算的层层嵌套
type DeepReadonly<T> = T extends ((...args: any[]) => any) | Primitive ? T : T extends _DeepReadonlyArray<infer U> ? _DeepReadonlyArray<U> : T extends _DeepReadonlyObject<infer V> ? _DeepReadonlyObject<V> : T;
你 JS 也这样写吗?
整个所谓的 TS 类型体操,就是一个大型语法过度使用的灾难现场,这就是 TS 类型体操的本质。所以我们常常可以比较容易写出来一个体操,但是你要去读懂别人写的体操,那可就真不容易。最痛苦的是,有的时候,你还要写一个类型去兼容他的体操类型....
这,绝对不是学习和使用 TS 的正确方向。这样的思路,也无法利用 TS 给我们的工作带来任何便利和效率上的提升,反而是极大的降低了工作效率。
三、如何正确使用 TypeScript
好在我洞察了 TS 各种行业乱象,滤清了各种嘈杂的声音,回归到 TS 是一门强类型语言的本质,充分发挥这一特点,从提高开发效率的角度,找到了使用 TypeScript 的正确姿势。
这里最核心的关键,就是要理解到 TS 具备强大的类型推导能力。,做到一处声明,多处使用,其他地方全靠推导。这需要一定的架构思维来支撑我们去去构建一个完备的类型体系。
以我之前在 React 知命境中,自定义 hook 的一个案例为例,在使用层面,我的写法是这样的
const { loading, setParam, list = [], error } = useFetch(searchApi)
毫无 TS 痕迹。
他厉害的地方就在这里,我们会发现,虽然没有任何 TS 语法的痕迹在,但是类型已经被明确好了。包括函数的入参,返回值,所有的细节都有。
那么问题的关键就是,如何做到的呢?非常简单,利用 TS 强大的类型推导,我们只需要关注数据类型的入口即可。
首先明确入参 searchApi 的类型,中间的具体逻辑可以不看,只关注类型的部分。
export function searchApi(param?: string) { return new Promise<string[]>((resolve, reject) => { const p = (param || '').split('') const arr: string[] = [] for(var i = 0; i < 10; i++) { const pindex = i % p.length arr.push(`${p[pindex] || '^ ^'} - ${Math.random()}`) } setTimeout(() => { if (Math.random() * 10 > 1) { resolve(arr) } else { reject('请求异常,请重新尝试!') } }, 600) }) }
然后再封装自定义 hook 时,利用 ts 的类型兼容性和类型推导的特性,把这里的细节逻辑封装在 useFetch 中
type API<T, P> = (param?: P) => Promise<T> export default function useFetch<T, P>(api: API<T, P>) { const param = useRef<P>() const [list, setList] = useState<T>() const [error, setError] = useState('') const [loading, setLoading] = useState(true) function getList() { api(param.current).then(res => { setList(res) setLoading(false) setError('') }).catch(err => { setLoading(false) setError(err) }) } useLayoutEffect(() => { loading && getList() }, [loading]) return { param, setParam: useCallback((p: P) => param.current = p, []), list, error, loading, setLoading } }
在实践中,我们会大量运用类型推导来简化 TS 的编写,只需要在类型入口和出口去明确类型的声明。以及偶尔在类型推导脱节的时候去重新明确类型声明。
在列举一个例子,很多年前我在 github 上基于 react hooks 封装了一个小型的状态管理工具 moz,我也做到了使用时无 TS 痕迹,能够自动推导出定义在 store 中的具体数据类型
地址, https://github.com/yangbo5207/moz,欢迎大家 star 与使用,这是一个非常灵活,超级轻量,使用简单,非常适合小范围状态共享的状态管理库。
四、如何学习 TypeScript
我们只需要明白一个道理,就能具备学好 TS 的基础,那就是:类型体操是基础语法的嵌套。因此,我们只需要去学习 TS 的基础语法就好了。这样结合官方文档,我们就能学好 TS。
除此之外,在我的小册《JavaScript 核心进阶》中,我专门把 TS 学习最重要最核心的部分抽离出来分为几个部分,明确了一个通熟易懂的学习思路,给大家提供了一个非常有用的学习指引
- 学习 TypeScript 的必要性
- 观察 TypeScript 的实践运用
- 泛型
- 类型推导是核心
- 常用高级类型
- 准确理解类型兼容