TypesScript 的使用感想

1,508 阅读10分钟

TypesScript

本文不是 ts 教程,不涉及 ts 的安装、配置说明、各种基础类型和高级的知识,写这篇文章的目的是想记录自己在学习和使用 typescript 过程中认知的变化,希望能帮助到和我曾经一样在使用 typescript 初期迷茫的同学!

初识

19 年刚毕业工作那会,会点 js 只看过 react 文档但并没有在实际项目中使用过的我怀着紧张局促不安还有点小期待的心情开始了入职以来的第一个项目,项目的技术栈选用的是 react + redskull + babel + ant design。

  • 动态类型一时爽,代码重构火葬场,为了避免代码中的低级错误和非最佳实践的写法,我用 eslint 给代码加上了静态类型检查;
  • 一千个读者就有一千个哈姆雷特,一千个程序员就有一千种代码风格,为了保持代码风格统一,我用 prettier 给代码加上了风格检查;
  • 为了验证程序的正确性,我给代码添加了测试用例,用 ava 来跑测试;

这三板斧加上以后就很 amazing 啊,效果拔群,终于没有在写魔法的感觉了,一切都变得尽在掌握。

但我又面临了新的问题:

  1. 缺少智能提示,比如 import、原型方法和属性、组件 props
  2. 不经意的拼写错误,比如 Math.random() 方法名写成 randoms
  3. Cannot read property 'xxx' of null/undefined 等类型报错,等等...

2019 年前端圈文章中出现的 ts 要火 就像炉石传说视频弹幕中出现的 风暴要火 一样,让人觉得是茶余饭后诙谐调侃的同时又刷足了存在感,正好组内那段时间在分享 typescript,借此机会我也想学习 ts 并在项目中落地使用。和绝大多数同学一样,我也是通过 ts 官方文档来学习 ts,看了 5 分钟上手和基础类型后,我陷入了沉思和不解,感觉 ts 就是 esnext+type,为什么用过的同学都觉得它很香!

我当时的想法是:

  1. 因为在写代码的时候添加了类型,所以 ts 能在使用的时候能给出智能提示,如果想有提示,就得写类型,如果想在代码任何地方都有智能提示,就得一遍一遍重头到尾地写很多类型,额外多写的类型会增加编码量,花费更多时间,让时间本不充裕的开发环节雪上加霜
  2. 不知道以一种什么样的方式管理 ts 类型,担心随着项目不断迭代,随心所欲的类型编写和存放会让项目变得更加难以阅读和维护,搞出来很多“类型垃圾”
  3. redskull 项目用 ts 改造会不会有奇怪的坑

评估付出和回报后不打算将这个项目用 ts 改造。

使用

2020 年有幸接到个小项目,没有历史技术栈的包袱,也没有大项目类型管理的恐惧,让我下定决心在项目中落地 ts。

现在回想,当时也就定义了几个 interface,简单使用了基本类型、可选属性和泛型;dva model 里面的类型参照的官方文档的示例;遇到类型错误的问题 Google 一搜,搞不定的类型用 any 填充;缺少 TS 声明文件的三方库用 declare module 声明。可能因为是小项目,没有发挥 ts 的优势,项目撸一遍下来索然无味。

const generateRandomNumber = (a: number, b: number) =>
  // Property 'randoms' does not exist on type 'Math'. Did you mean 'random'?ts(2551)
  Math.floor(Math.randoms() * (b - a + 1) + a);

但使用 ts 后编码体验确实好了不少,智能提示让我 devdocs 文档打开的频率减少了很多,拼写检查也帮我规避了很多潜在风险,更重要的是及时提示了平时可能忽视的值为 null 和 undefined 的情况,让我在开发的时候就把他们干掉。除此之外,就没有让我眼前一亮的地方了,而且还是会担心复杂系统的类型太过冗余不好管理,比如 interface 的属性在 A 场景下都是必填,在 B 场景下选填,在 C 场景下只有部分属性,这些变化都将导致再重写一遍类型。

import React, { useEffect } from 'react';

const Test: React.FC = () => {
  useEffect(() => {
    // Object is possibly 'null'
    document.getElementById('test').scrollIntoView();
  }, []);
  return <h1 id="test">hello world</h1>;
};

继续学习

在后面的项目中,我继续沿用了 typescript 作为开发语言,并持续不断的学习并保持好奇心。我开始学习 tsconfig.json 中每个配置项的含义,开始好奇 ts 是如何在我们引入用 ts 写的第三方模块或 @types/xxx 文件的时候找到 d.ts 类型文件并给出提示。开始学习 ts 高级类型、工具类型和一些关键字。高级类型和工具类型对我的影响很大,完全改变了我对 ts 的看法,让我觉得 TS 的强大之处在于对类型的编程,通过编写操作类型的代码,从一个类型抽象出另外一个类型,一通抽象的操作,写出非常复杂的类型定义,实现出 amazing 的类型推断效果。

比如:索引类型 index-type:

// 数组
const xs = ['1', '2', 3];
let item = xs[0]; // "1"
type it = typeof xs[number]; // string | number

// 对象
const obj = {
  name: 'wfk',
  age: 18,
};
let name = obj.name; // "wfk"
let age = obj.age; // 18
type nt = typeof obj.name; // string
type at = typeof obj['age']; // number

比如,映射类型 map-type:语法形如:[K in Keys],新类型去遍历旧类型中的所有属性,keys 一般值为 string、number 和联合类型,下例中使用了 keyof 关键字来获取 interface 中由属性名组成的联合类型:keyof typeof obj

const obj = {
  name: 'wfk',
  age: 18,
};
type union = 'address' | 'phone';
type u = {
  [key in union]: string;
};
type o = {
  [key in keyof typeof obj]: string;
};
// type u = {
//   address: string;
//   phone: string;
// };
// type o = {
//   name: string;
//   age: string;
// };

除了高级类型外,lib.es5.d.ts 中还内置了很多类型工具,极大地方便我们对类型的操作,如果理解了含义,类型写起来会非常得心应手。比如 Partial 可以让泛型 T 中所有的属性可选,Required 让所有属性必选,这些内置的工具类型或多或少地都用到了高级类型中的概念。

type Partial<T> = {
  [P in keyof T]?: T[P];
};
type Required<T> = {
  [P in keyof T]-?: T[P];
};

离不开的类型元编程

理解 ts 高级类型和工具类型后,慢慢地离不开 ts 这种类型元编程的能力,回头看之前 js 的代码会很不习惯,用一些非 ts 写的或者不提供类型的三方库会很难受,在写类型的时候第一反应是这个类型能不能由现有的值或类型产生,而不是傻瓜式的硬编码一遍,而且会用 ts 的类型元编程能力去实现一些非常有意思的类型推导。

数组推导联合类型

定义如下数组,通过现有的数组定义推导出联合类型: '新房' | '二手' | '租赁'

const businessList = ['新房', '二手', '租赁'];

利用索引类型和值类型,我们可以写出如下如下类型推导:

const businessList = ['新房', '二手', '租赁'] as const; // 注意,as const 很关键,不然获取的就是类型 string 了
type businessUnion = typeof businessList[number]; // '新房' | '二手' | '租赁'

但是,如果 businessList 结构复杂了一点,需要根据下面的结构推导出 '新房' | '二手' | '租赁''new' | 'second' | 'rent' 这样的联合类型该怎么做?

const businessList = [
  { label: '新房', value: 'new' },
  { label: '二手', value: 'second' },
  { label: '租赁', value: 'rent' },
];

利用索引类型、值类型和工具类型 Pick,我们可以写出如下类型推导:

const businessList = [
  { label: '新房', value: 'new' },
  { label: '二手', value: 'second' },
  { label: '租赁', value: 'rent' },
] as const;
type L = Pick<typeof businessList[number], 'label'>;
type V = Pick<typeof businessList[number], 'value'>;
type LUnion = L[keyof L]; // '新房' | '二手' | '租赁'
type VUnion = V[keyof V]; // 'new' | 'second' | 'rent'

Object.assign 的简单实现

Object.assign() 方法对对象进行扩展的时候,不同对象的相同属性名会出现 TS 类型丢失的问题。

看下面一个例子,定义两个对象 o1o2,两个 interface AB,然后使用 Object.assign() 去拷贝对象属性,得到最终的 o3

const o1 = {
  a: 1,
  b: 1,
  c: 1,
  e: {
    name: 'wfk',
    age: 18,
  },
};

const o2 = {
  a: 'b',
  b: undefined,
  d: [1],
  e: {
    age: '18',
  },
};

const o3 = Object.assign({}, o1, o2);
// o3 结果
// {
//   a: 'b',
//   b: undefined,
//   c: 1,
//   d: [1],
//   e: {
//     age: '18',
//   },
// }

但是我们会发现,o1 和 o2 中都有的类型不同的基本数据类型,在 o3 中类型是 never,o1 和 o2 中都有的类型不同的引用数据类型,在 o3 中是两者的交叉类型(&),只有在 o1 或 o2 中出现的类型才能得到正确的类型,甚至在对 o1.a 进行赋值的时候会出现错误提醒:

o3.a = 1; // Type '1' is not assignable to type 'never'
o3.a = '1'; // Type '"1"' is not assignable to type 'never'.

打开 Google,搜索关键字 typescript Object.assign merge field type,第一条 Stack Overflow 有分析原因,感兴趣的同学可以看一看。大概意思就是:Object.assign() 确实在 merge 以后,不同对象的相同属性名的类型会出现丢失,但是在 TS2.8 以后,可以使用延展操作符 Spread<L,R> 进行处理,但也只能得到近似情况,因为:

  • Object.assign() 只会拷贝可枚举的 ownproperties(不会拷贝原型链),类型系统没有办法对其进行过滤
  • 延展操作符和 Object.assign() 对于 merge({ a: 42}, {a: undefined}) 值是 undefined 的情况,会得到错误类型 {a: number},而延展操作符和 assign 后的结果是 {a: undefined}
  • 编译时和运行时,预期结果不一致

双对象的 Object.assign 进行实现的话比较简单,我们可以利用交叉类型、条件类型、映射类型和索引类型写出如下类型推导:

type AssignInterface<A, B> = {
  [P in keyof (A & B)]: P extends keyof B
    ? B[P]
    : P extends keyof A
    ? A[P]
    : never;
};

对于合并 { a: 42}, {a: undefined} 这样的情况,相比于延展操作符,能得到正确的类型 {a: undefined}。使用方式也很简单,只需对上面的 o3 进行改造即可得到正确的类型

const o3: AssignInterface<typeof o1, typeof o2> = Object.assign({}, o1, o2);

slate 内置类型的扩展

最近在用 slate 写富文本编辑器,有场景需要对 slate 内置的类型进行扩展。slate Text 的 interface 定义如下

interface Text {
  text: string;
  [key: string]: unknown;
}

希望将其扩展成如下形式:color 和 backgroundColor 是 string 类型,其他扩展的属性是 boolean 类型:

interface TextExt {
  text: string;
  // 扩展属性 start
  bold: boolean;
  italic: boolean;
  underline: boolean;
  strikethrough: boolean;
  code: boolean;
  color: string;
  backgroundColor: string;
  // 扩展属性 end
  [key: string]: unknown;
}

已知有如下数组类型定义:

const TextField = [
  'bold',
  'italic',
  'underline',
  'strikethrough',
  'code',
  'color',
  'backgroundColor',
] as const;

即,如何由 slate 提供的 Text 和定义的 TextField 数组,得到如上的 TextExt interface。我们可以利用索引类型、交叉类型以及工具类型中的 Record 和 Omit 写出如下类型推导:

type TextFieldUnion = typeof TextField[number];
type TextExt = Record<'color' | 'backgroundColor', string> &
  Omit<Record<TextFieldUnion, boolean>, 'color' | 'backgroundColor'> &
  Text;

总结

ts 除了以上智能提示、拼写检查、类型错误提示的优势外,还有其他很多优点:用 ts 写的组件/库,类型就是最好的文档,鼠标悬停就能看到类型解释和说明,通过类型就能知道整体代码设计;重构 ts 代码也不会像重构 js 代码一样瞻前顾后,忧心忡忡;多人维护、代码量多、代码结构复杂的大型项目,类型系统能在编译期间帮大忙,合理使用 ts 能提高工程质量和工作效率。但是,因为 js 比较灵活,所以类型元编程可以写出非常复杂的类型,这些类型虽然写的时候可能很爽,但是玩弄的太花哨以后就不好读了,就像正则表达式,写起来很爽,读起来很痛苦。总而言之,零零散散写了这么多,还是希望大家能在项目中试试 ts,说不定你也会和我一样爱上它。

参考链接