引言

高质量的类型可以提高项目的可维护性并避免一些潜在的漏洞。 via: type-challenges

前端开发的潮流之一就是使用 Typescript 进行开发,可以说 Typescript 让大型项目的前端开发体验上了一个台阶,不知读者们有无开发过大型的基于Javascript的前端系统的经验,一开始的时候JS带来的流畅的体验和效率让人很兴奋,但是随着维护时间的增加,以及业务逻辑随着时间推移不可避免的锈化,很多时候在工作时会对正在操作的对象产生怀疑:这个属性存在吗,是什么意思的来着?
从我的角度来看,在使用JS进行开发的时候,需要开发者自己去维护一套上下文模型,以应对返回的数据,和需要保存的状态等等。当项目扩大,参与人员增多的时候,多人开发无法快速互相沟通的问题,也会导致开发进度的受阻。所以如果可以的话,使用 TypeScript 吧!这篇文章不是布道,也不是关于TS的教程,网上资料很多,本文主要是想分享一下我在开发大型长期维护项目的过程中,对于类型计算和TypeScript 的一些理解,以及TypeScript 是如何服务于领域驱动设计和开发者的心智模型的。

类型化的JS的前世今生

JavaScript 是一门使用广泛并且很容易入门的语言,但是这种易用性导致了很大的不稳定性,相比于Java,Rust等我用过的静态类型语言来说,JavaScript 的超高自由度可以让我在一天里撸出一个前后端简单点单系统,但是也可以让我在后续的重构中痛不欲生——如果你在里面不能很规范的书写你的代码的话。
易书写,难梳理是我对 JavaScript 的开发的一个总结,为了解决这个问题,除 TypeScript 之外也有许多解决方案:

  1. 首先是 JSDoc,使用注释来给 JavaScript 方法等添加类型,结合编辑器可以实现一定的类型推断功能,详细大家都不陌生。
  2. Flow 可以说是一个很重要的 JavaScript 静态类型检查工具了。如果你读过 vue.js 的源码,应该对 Flow 有印象,有点类似于 Eslint,Flow 会在文件开头添加特殊注释打开检查功能,添加的类型注解会计算获得的类型,这和 TypeScript 也很类似。

关于Flow为什么没能竞争过 TypeScript ,可以去看看这篇文章,但总的来说就是使用起来不够方便,所以下面我们就看看,如何可以在项目中使用 TypeScript ,并且最大限度的利用TS的类型计算功能,辅助开发的流程。

如何在开发中最大限度利用 TypeScript

TypeScript 是 JavaScript 的超集,也就是说:

所谓超集是集合论的术语,A ⊇ B,则 A 集是 B 的超集,也就是说 B 的所有元素 A 里都有,但 A 里的元素 B 就未必有。 int i = 0; 合乎 C 语言语法,在 C++ 里也是合法的。

可以通过直接修改文件后缀名的方式,把 .js 代码直接转换为 .ts的代码。这种渐进式的引入我认为是可以快速传播的关键之一,配合 ts-node 可以很快的进行测试使用。
当然如果是新建的项目,主流的脚手架目前都支持拉取 ts 的模板,入门门槛已经很低了,网上的“第一天教程”也是满天飞。
那接下来应该怎么办呢,很多时候当我们开始真正写代码的时候,就会对TS有点手足无措了,也许我应该给我声明的变量设置一个类型,但是如果是接口返回的数据应该如何做?一些工具方法,例如parse结构的方法,应该如何最好的设置类型?对于使用的组件库/轮子,如何使用提供的d.ts文件?可能需要后续的学习了。这里不是很想赘述很多“第二天教程”,还是结合一些我最近写ng的体验来说说我是如何在开发中最大限度利用 TypeScript 的。

例子

考虑一个对象数组{id:number,name:string}[],如果知道了id想去查询对应的name,应该还是很容易的:

  1. function findObjByKey(objArr, key, value) {
  2. const res = {};
  3. objArr.forEach((x) => {
  4. if (x[key] === value) {
  5. Object.assign(res, x)
  6. }
  7. });
  8. return res;
  9. }
  10. const source = [
  11. {
  12. id: 1,
  13. name: 'zhangsan'
  14. },
  15. {
  16. id: 2,
  17. name: 'alfxjx'
  18. }
  19. ];
  20. const id = 1;
  21. const targetName = findObjByKey(source, 'id', id)['name'];
  22. // 'zhangsan'

但如果真的在编辑器里面手敲这段代码,就会发现并没有任何的推断,vscode可以计算出一定的类型,但这对我们的意义不是很重要:毕竟业务中可能返回的不是基本类型而是一个领域模型的话,简单的推导就无能为力了。
image.png
考虑这么一种情况:

  1. const source = [{
  2. id: 1,
  3. userInfo: {
  4. userId: 12345,
  5. userName: 'zhangsan',
  6. email: {
  7. value: 'zhangsan@abandon.work',
  8. domain: 'abandon.work'
  9. }
  10. }
  11. }];

那么计算得出的结果,后续的属性获取就无法计算了,对于领域模型来说这是不可接受的。
如果我们使用了TS,并且辅助以良好的类型计算:

  1. type User = {
  2. id: number;
  3. userInfo: {
  4. userId: number;
  5. userName: string;
  6. email: Email;
  7. }
  8. }
  9. type Email = {
  10. value: string;
  11. domain: string;
  12. }
  13. const source: User[] = [{
  14. id: 1,
  15. userInfo: {
  16. userId: 12345,
  17. userName: 'zhangsan',
  18. email: {
  19. value: 'zhangsan@abandon.work',
  20. domain: 'abandon.work'
  21. }
  22. }
  23. }];
  24. function findObjByKey<T>(objArr: T[], key: keyof T, value: T[keyof T]): T {
  25. const res = {} as T;
  26. objArr.forEach((x) => {
  27. if (x[key] === value) {
  28. Object.assign(res, x)
  29. }
  30. });
  31. return res;
  32. }
  33. // 尝试输入这一行
  34. const email = findObjByKey(source, 'id', 1)['userInfo'].email['value'];

使用的时候是这样的:
image.png
此时已经可以满足常规的业务开发的需求了,当然我们回头再看看上面的类型,可以发现对于此方法我们的主要目的就是

  1. 对于一个已知类型的数组(T[]
  2. 根据其中的一个键名(keyof T
  3. 传对应的键值(T[keyofT]
  4. 查出对象(T

通过一些高级类型和泛型,我们可以做到在类型声明时的计算,从而获得更好的类型支持。

另一个例子

参考上面的例子中的 Email 类型,对于Email.value来说,事实上不是一个简单的基本字符串类型,而是一个有一定的规则的字符串,那么如何能够判断呢?
我们知道在JS中有很多方式,一个比较常用的方法就是使用正则表达式来判断。考虑一个最简的情况,一个邮箱地址分成三部分:邮箱名称 + @ + 域名(组织名称+.后缀),或许用TS也可以很好的描述

  1. const _isValidEmail: (email: string) => boolean = (email) => {
  2. const isEmailRegex = new RegExp(/.+\@.+\..+/);
  3. return isEmailRegex.test(email);
  4. }

但是这样一来返回的是Boolean,那如果我想知道在开发阶段就知道输入的邮箱是否正确的话(虽然这个没啥意义。)可以借助TS来实现:

  1. type ParseEmail<T extends string> = T extends `${infer P}@${infer A}.${infer Rest}`
  2. ? true : false;
  3. const rightEmail = 'xujianxiang@abandon.work';
  4. const valid = _isValidEmail(rightEmail); // const valid: true
  5. const noValid = _isValidEmail(''); // const noValid: false

这里使用了一个 infer 关键字,和 extends 关键字配合可以发挥出类似于解构赋值的功能。简单来说就是我推断传入的类型T是一个基于string扩展的类型,对于这个类型,判断其字符串内部的格式是否为 aaa@bb.cc 的格式,返回一个Boolean。
另外的,考虑上一个例子里面的场景,用户提交用户信息的时候,需要在提交邮箱地址之外,另外传一个域名提供商信息:

  1. type ParseEmailString<T extends string> = T extends `${infer P}@${infer A}.${infer Rest}` ? T : unknown;
  2. type ParseEmailProvider<T extends string> = T extends `${infer P}@${infer A}.${infer Rest}` ? `${A}.${Rest}` : unknown;
  3. type EmailObj<Email extends string> = {
  4. email: ParseEmailString<Email>,
  5. provider: ParseEmailProvider<Email>
  6. }
  7. function getEmailObj<T extends string>(email: T): EmailObj<T> {
  8. return {
  9. email: email,
  10. provider: email.split('@')[1]
  11. } as any;
  12. }
  13. const emailValidation = getEmailObj(rightEmail)

image.png

Vuex Actions 类型推断

通过这个例子可以做什么呢?似乎在日常的开发中也不会硬编码一个邮箱,然后去判断它是不是正确的格式。比如使用vuex的时候,我们会使用dispatch发布改动的时候,会大量的使用字符串模板,可以用这样的方式去给不同的 Dispatch Type 标记类型:

  1. type VuexOptions<M, N> = {
  2. namespace?: N,
  3. mutations: M,
  4. }
  5. type Action<M, N> = N extends string ? `${N}/${keyof M & string}` : keyof M
  6. type Store<M, N> = {
  7. dispatch(action: Action<M, N>): void
  8. }
  9. declare function Vuex<M, N>(options: VuexOptions<M, N>): Store<M, N>
  10. const store = Vuex({
  11. // 这里提示编译器,N泛型即为字符串'cart',无需对他进行推断了
  12. namespace: "cart" as const,
  13. mutations: {
  14. // 简写,实际是 add: function(){}
  15. add() { },
  16. remove() { }
  17. }
  18. })
  19. store.dispatch("cart/add")
  20. store.dispatch("cart/remove")

,原文章的地址在这里:

解析 UrlParams

另外一个很有用的地方就是对于/purchase/[shopid]/[itemid]/args/[...args]这样的nodejs请求地址,可以通过上面的类型计算去推到请求里面的shopid,itemid以及args等:

这里预设了地址栏里面的 id 是 number 类型,而后续的 …args 是一个 string[]

  1. type IsParameter<Part> = Part extends `[${infer ParamName}]` ? ParamName : never;
  2. type FilteredParts<Path> = Path extends `${infer PartA}/${infer PartB}`
  3. ? IsParameter<PartA> | FilteredParts<PartB>
  4. : IsParameter<Path>;
  5. type ParamValue<Key> = Key extends `...${infer Anything}` ? string[] : number;
  6. type RemovePrefixDots<Key> = Key extends `...${infer Name}` ? Name : Key;
  7. type Params<Path> = {
  8. [Key in FilteredParts<Path> as RemovePrefixDots<Key>]: ParamValue<Key>;
  9. };
  10. type CallbackFn<Path> = (req: { params: Params<Path> }) => void;
  11. function get<Path extends string>(path: Path, callback: CallbackFn<Path>) {
  12. // TODO: implement
  13. }
  14. app.get('/purchase/[shopid]/[itemid]/args/[...args]', ()=>{
  15. const { params } = req;
  16. // params: {
  17. // shopid: number;
  18. // itemid: number;
  19. // args: string[];
  20. // }
  21. })

上面的这个例子来自最近的一期 JavaScript Weekly中的一篇:

领域驱动设计

从上面的两个例子可以看出类型计算的魅力,即从基础的类型出发,通过计算和推导即可得到后续的类型。TS将开发者开发时的上下文记录了下来,帮助我们能够更好的编写健壮的代码。
TS另外一个优势是它很有利于DDD (领域驱动设计),如果你喜欢,可以看看这个代码 ,这是一个基于TS的领域驱动设计实例,Readme里面有很详细的说明,这方面我也需要继续学习,后续会出这个方面的文章。

写在最后

最后非常推荐大家去读 TypeScript 类型体操通关秘籍 这本小册,里面说是类型体操,读完之后你的TS水平一定会有所提高。另外有几个github repo也值得关注:

  1. type-challenges 类型体操题库 有时间可以上去做做练习,ts的leetcode
  2. ts-toolbeltutility-typesSimplyTyped 等TS的工具类型库。