typescript使用的思考总结与浅析

June 24, 2024

typescript 的简介

typescript 的使用简单来讲就是给原本没有类型定义的 js 加上后置于变量的类型定义,类型定义对于一个系统开发的重要性可想而知。类型可以作为检验函数输入输出的一个标准,为每一个参数加上类型定义可以让开发者更加清晰地去使用一个函数,这是 typescript 在开发中起到作用的其中一个方面。

但是起到作用不代表一定有必要使用,ts 的使用具有一定门槛,加上类型定义通常需要外加一大段类型定义代码,如果变量的嵌套层级非常复杂,则需要额外的自定义类型。那么 typescript 是否值得开发者付出这些额外的工作量呢,下面我将以我的角度对 typescript 进行我个人使用体验的一些总结以及分享一下 typescript 的使用。

真的有必要使用 typescript 吗?

了解了一下其他开发者对于使用 typescript 必要性的态度,发现有从很多不同角度出发的理解,我自身的体会比较深的就是学习成本,工具库兼容,编码效率这三个方面。

学习成本

笔者使用过诸如 C++这样的类型定义严格的编程语言,其实单从给变量加上一个类型这件事来说,并没有什么学习成本,但是与先前 JS 的开发习惯不一样的地方还需要慢慢适应,而且在很多较简单的场景也会不可避免地思考类型代码是否真的会起到提高代码质量的作用。进一步学习 typescript 后,了解到了这个类型系统还有类型体操这个说法,可以通过编写类型代码来实现一些使用泛型参数生成类型的事,这些确实也是需要学习并且通过具体使用场景获得进一步理解的。

工具库兼容

typescript 是 JavaScript 的超集,自然也可以使用 JavaScript 的工具库,但是 JavaScript 工具库不一定会提供 typescript 所需要的类型信息,在一般的编译设定下,引入使用这些工具库的代码会被编译器检测为问题并在问题窗口中报告。严格的编译设定下,这些工具库将会编译失败。同时由于工具库版本和类型信息版本会存在不同步的情况,有时可能会被错误类型信息误导,在使用一些没有使用经验的工具库时,也很难自己去进行类型修正,这样确实会影响到开发体验。

编码效率

为原本的 JavaScript 代码构建一个完整的类型系统可以为后续维护代码提供比较多的便利,但是这建立在一些前提上,例如是否能正确使用 ts,在非严格的编译设定下,使用 any 或者@ts-ignore 可以忽略掉一些很难处理的类型检查,这减少了 ts 的使用难度,但是也增加了维护代码时定义查询的不确定性。前端开发时经常会需要使用到一些第三方工具库,这些工具库的代码通常都有着非常复杂的类型嵌套,加上对这些工具库不熟悉的可能性,出现类型检查的错误时,是需要花上很多时间才有可能去解决而非忽略。所以对编码效率的影响是很难单用好或者坏去作评价的。

以上是我个人对 typescript 的使用总结,接下来笔者将结合一些 typescript 的知识来总结一下 typescript 的使用。

typescript 的使用知识总结

typescript 的类型系统

typescript 的类型系统是图灵完备的,图灵完备是针对数据操作规则的一个概念,定义上是这套规则可以实现图灵机上的所有功能。可以通过实现偏递归来证明。这些概念感兴趣的可以自己去检索资料,这里对 typescript 的类型系统作这样一个描述只是为了表达一下这个系统的完整和强大,接下来就着 ts 的实际使用来体会一下这个类型系统吧。

typescript 的基础用法

类型的基本使用

如上所说,ts 的基本使用是给 js 加上后置类型定义。

let num: number = 1;
let str: string = "123";
let flag: boolean = true;
//let num:number = "123"则会报错:不能将类型“string”分配给类型“number”。

let arr1: number[] = [1, 2, 3];
let arr2: string[] = ["a", "b"];
//let arr2:string[] = ["a",1]同理会报错
类型变量

针对需要进一步限制的变量,可以使用 ts 的类型变量

type str = "AAA";
type str2 = `${str}BBB`; //类型变量中也可以使用模板字符串,用法和js一样
let val: str2 = "AAABBB";
对象的类型

对于对象的类型,可以使用类型变量或者接口

//使用type
type obj1Type = {
  a: number;
  b: string;
};

let obj1: obj1Type = {
  a: 1,
  b: "a",
};
//使用接口interface
interface obj1Interface {
  a: number;
  b: string;
}

let obj1: obj1Interface = {
  a: 1,
  b: "a",
};

这些类型限制方法也可以用于函数

//直接加上类型定义
function func1(a: number): number {
  return a;
}

//或使用类型变量
type func1Type = (a: number) => number;

const func1: func1Type = function (a) {
  return a;
};

//或使用接口
interface func1Interface {
  (a: number): number;
}

const func1: func1Interface = function (a) {
  return a;
};
接口和类型变量的区别

interface 和 type 用法上比较相似,但是确实是存在区别的

//type可以定义别名、联合类型、交叉、元组type myNumber = number
type myNumber = number;
type myType = number | string;
type myTypeCross = { a: number } & { b: string };
type myTuple = [number, string];

//interface则可以声明合并,不同于type的交叉
interface myInterface {
  (myParams: number): number;
}

interface myInterface {
  (myParams: string): string;
  (myParams: boolean): boolean;
}

interface myInterface {
  (myParams: object): object;
}
//后声明的接口拥有更高优先级

typescript 进阶-类型体操

运用类型编程系统进行一些复杂类型定义或推导,这是我对类型体操的理解。typescript 的类型运算常用的有九种,在介绍类型运算之前,还有一个比较重要的概念,就是泛型.

泛型

我对泛型的理解就是类似于类型编程系统中函数的参数,对泛型赋值之后可以借助类型编程系统得到期望的输出:

function makeArr<T>(a: T, b: number): T[] {
  const arr: T[] = [];
  for (let i = 0; i < b; i++) {
    arr.push(a);
  }
  return arr;
}

makeArr<string>("", 5); //function makeArr<string>(a: string, b: number): string[]
类型运算方式

9 钟类型运算分别是:

  1. ? : (条件)

  2. extend (约束)

  3. infer (推导)

  4. | (联合)

  5. & (交叉)

  6. keyof Type(索引查询)

  7. Type[key] (索引访问)

  8. in (索引遍历)

  9. as (索引重映射)

使用类型运算的例子

以下例子实现了从泛型数组参数取得最后的类型值,使用了类型运算 1、2、3

type Last<T extends unknown[]> = T extends [...infer G, infer F] ? F : never;
type testLast = Last<[1, 1, 3]>; //type testLast = 3

extend 搭配? :表达式使用实现完整的条件判断并返回值,infer 则用于推导数组元素,这样取出指定位置的元素返回出去就实现了这个类型处理功能。

类型运算 3、4 的用法上文提到过就不再描述了

以下例子实现了对象类型的键和值对调,使用了类型运算 6、7、8、9

type ExchangeType<T> = {
  [Key in keyof T as `${T[Key] & number}`]: [Key];
};
type test9 = ExchangeType<{ a: 1; b: 2 }>; //type test9 = {1: ["a"];2: ["b"];}

keyof 运算可以将类型中的键查出来,而索引访问则可以通过键去查询对应的值,in 则是根据键去遍历对象类型,和 as 组合使用起到重映射类型中键和值的效果。

生成指定层数嵌套的 Record 对象

以上两个例子的需求应该是几乎不会在业务中使用的,但是其中的思路可以去实现一些更贴近业务的需求,例如生成指定嵌套层数的 record:

type myBuildArr<
  length extends number,
  Ele = unknown,
  Arr extends unknown[] = []
> = Arr["length"] extends length ? Arr : myBuildArr<length, Ele, [...Arr, Ele]>;

type buildRecord<T, A extends unknown[]> = A extends [infer F, ...infer R]
  ? buildRecord<Record<string, T>, R>
  : T;

type RC<T extends number> = buildRecord<string, myBuildArr<T>>;

type result = RC<3>;
//type result = {[x: string]: Record<string, Record<string, string>>;}

const f: result = {
  a: {
    a: {
      a,
    },
  },
};

这个实现分成两部分,一部分是通过向泛型输入的数字构建一个指定长度的数组,再通过这个数组进行循环计数进而生成嵌套类型结构,具体就是每次添加一层嵌套就删除数组中的第一个元素,达到指定次数循环的效果。

该例子中生成数组的操作并不清晰,以下是使用 typescript 创建指定长度数组的操作:

type createArr<T extends number> = fillArr<[], T>;

type fillArr<U extends unknown[], T> = U["length"] extends T
  ? U
  : fillArr<[unknown, ...U], T>;

type test10 = createArr<10>; //type test10 = [unknown, unknown, unknown]

这是 typescript 类型体操中的一个常规操作,除此之外还有匹配提取,重构变换的操作。可见 typescript 类型系统的强大。

总结

笔者使用 typescript 应该断断续续有一年了,虽然很少在使用工具库以外的情况接触过太多复杂的类型定义,但是也确实能感受到 ts 强大的类型系统给开发带来的便利。为 JavaScript 编写加上类型后,不仅可以在编码时有一道静态类型检查的保护,还能培养开发者的类型思维,进而提高代码质量。然而就像硬币有两个面一样,ts 也拥有弊端,例如对开发效率的影响,很多时候面对第三方库复杂类型的报错会花上很多时间解决,甚至有最终只能使用 any 或者 ts-ignore 去直接忽视的情况。这让我不禁思考类型系统是否有必要如何复杂和严格。再者 ts 代码也需要编译,会比 js 额外占用一段编译时间,当今的前端框架的编译速度很多时候会成为评价一个框架的指标,所以也会有为了效率而弃用 ts 的情况。总而言之,开发者应该视具体情况来决定是否使用 typescript,让这个系统在合理的地方发挥最大的作用。