likes
comments
collection
share

多年老鸟教你TypeScript要做的性能优化、关键字进阶与实践、常用几个技巧

作者站长头像
站长
· 阅读数 65

多年老鸟教你TypeScript要做的性能优化、关键字进阶与实践、常用几个技巧

前言

大家好,我是易师傅 ,距离上一次的发文还是上次了;最近沉迷游戏,所以懈怠放纵了自我。

好了,不狡辩了,没错,就是变懒了 ~

话不多说,基于自己使用 TypeScript 有了几年的时间,故简单做一个总结,如果你想进阶高级 TSer 以及想如何更好的写好 TS 代码,我觉得这篇文章对你肯定是受益匪浅,终身受用 ~

默认大家了解 TypeScript 基础相关知识且使用了一段时间,所以不会做一些基本介绍,如果需了解的可自行搜索相关文章;

一、TS 的性能优化

为什么 TS 要做性能优化?

同样也是类似的问题在前端框架 Svelte 上再次上演,虽然 Svelte 的作者没有直接想 Deno 那样说的透彻,但是大概意思也是对编译效率等相关问题的解决。

下图为 Svelte 作者个人的回答,使用谷歌翻译:

多年老鸟教你TypeScript要做的性能优化、关键字进阶与实践、常用几个技巧

但是我们真是也要追随大佬们的脚步而选择放弃吗?

这里笔者个人理解一个词概括就是:TypeScript 能用就用,具体为啥下面也会讲解;

如何做?

1. 优先使用 interface 而非 type

我们都知道,typeinterface 的作用非常相似

interface Foo { prop: string }

type Bar = { prop: string };

简单一看,其实它俩差异并不大,没错,的确相差不大;

假设我们定义了多个不同的类型,interface 一般可使用 extends 来继承;而 type 需要使用交叉类型 & 来实现集成;那么现在就会展现出来他们的差异来了;

差异一:因为 interface 定义的是一个单一平面对象类型,可以检测属性是否冲突;

多年老鸟教你TypeScript要做的性能优化、关键字进阶与实践、常用几个技巧

差异二:交叉类型只是递归的合并属性,有些情况下会产生 never

多年老鸟教你TypeScript要做的性能优化、关键字进阶与实践、常用几个技巧

所以当您可能需要将类型并集或交集时,请使用 interface

2.非特殊情况必须要使用类型注解

一句话解释就是:添加类型注解,尤其是返回类型,可以节省编译器的大量工作;

虽然 TypeScript 的 类型推导 是非常方便的,但是如果在一个大型项目中就会变得较慢,影响开发时间;

这是因为 命名类型(类型注解) 会比 匿名类型(类型推导) 让你的编译器减少了大量的读写声明文件的时间;

多年老鸟教你TypeScript要做的性能优化、关键字进阶与实践、常用几个技巧

3.优先使用基础类型而不是联合类型

使用联合类型的代码:

多年老鸟教你TypeScript要做的性能优化、关键字进阶与实践、常用几个技巧

上述例子中,当我们调用 printSchedule 函数时,每次将参数传递给 printSchedule 函数时,需要比较联合类型里的每个元素;

如果是一两个元素的联合类型那还好,如果是多个呢,这样就引起编译速度的问题;

所以我们需要改进成基础类型代码:

多年老鸟教你TypeScript要做的性能优化、关键字进阶与实践、常用几个技巧

在实际项目开发中,类似的情况几乎都会遇到,例如:

// 定义一个 div 并且声明类型
const div: HTMLElement = document.createElement('div')

其实这样写是没问题的,但是我们看 HTMLElement 类型,他其中就是 HTMLDivElement | HTMLImageElement 等的一个无穷多的一个联合类型;

所以我们只需要改进下代码:

// 定义一个 div 并且声明类型
const div: HTMLDivElement = document.createElement('div')

4. 复杂类型抽离成简单类型

当我们进行复杂类型编程时,会发现很多同学,喜欢把多个运行时写在同一个类型中;

所以为了优化,我们需要抽离,其实这就类似编程的 单一职责模式

但是 TS 的类型抽离不仅仅只是代码变得单一职责了,更多的也是减少了编译器的负担

多年老鸟教你TypeScript要做的性能优化、关键字进阶与实践、常用几个技巧

上述例子中,当我们每次调用 foo 函数时,TypeScript 都会重新运行条件类型,这样就会增加编译器的负担,所以我们需要优化;

我们可以提取其中的类型为一个新的类型别名,这样这个类型别名就会被编译器缓存了,减少编译器的负担

多年老鸟教你TypeScript要做的性能优化、关键字进阶与实践、常用几个技巧

5. 控制单个项目大小

为什么笔者在上面说:在实际项目中 TypeScript 是能用则用,固然在单个大型项目中,TypeScript 往往会增加项目的负担,但是我们换一种思路去想一下这个问题;

如果单个项目没有达到所谓 Deno 等库的体积量呢?那么这时候 TypeScript 是不是利大于弊呢?

所以我们需要:

  • 单个项目尽可能的避免大型开发;
  • 大型项目尽可能的分成多个项目,参考微服务架构;
  • 增加模块尽可能的少,避免项目的负担;

二、关键字进阶与实践

1. keyof 的秘密

keyof 是一个单目运算符,接受一个对象类型作为参数,返回该对象的所有键名组成的联合类型。

interface obj {
    a: string
    b: number
}

keyof obj // "a" | "b"

上面这是把一个正常的对象类型作为了 keyof 的参数,如果我们用 any 作为 keyof 的参数呢?会怎样?

keyof any // string | number | symbol

这是因为 JavaScript 对象的键名只有三种类型,所以对于任意对象的键名的联合类型就是 string | number | symbol

TypeScript 的内置对象 Record 就是 keyof any 的一个很好的实现例子;

// 内置的对象
type Record<K extends string | number | symbol, T> = { [P in K]: T; }

// 其实就是:
type Record<K extends keyof any, T> = { [P in K]: T; }

需要注意的是有一种特殊情况:

如果在 tsconfig.ts 配置文件中开启了 keyofStringsOnly: true ,那么:

keyof any // string

这也是为什么 Record 写的是 K extends string | number | symbol

问答环节:

  • 既然 keyof any = string | number | symbol ,那么 keyof unknwon 输出什么呢?
  • 凭你的第一印象说出你的答案,直接打在评论区,说错也不要紧,因为这对实际项目没什么用;
  • 具体原因会在下面讲到;

2. 方括号 [] 的秘密

方括号运算符([]) 用于取出对象的键值类型,比如T[K]会返回对象T的属性K的类型。

type Person = {
  age: number;
  name: string;
  alive: boolean;
};

// Age 的类型是 number
type Age = Person['age'];

其实除了上述的用法,还有多种不常见的使用方法

用法一:方括号运算符的参数也可以是属性名的索引类型。

type Obj = {
  [key:string]: number,
};

// string 就是上面 key 的索引类型
type T = Obj[string]; // number

用法二:对于数组而言,可以使用 number 作为方括号的参数,或者使用字符串 'length' 为参数

// MyArray 的类型是 { [key:number]: string }
const MyArray = ['a','b','c'];

// 等同于 (typeof MyArray)[number]
type Person = typeof MyArray[number] // 返回 string

type Len = typeof MyArray['length'] // 返回 number

3. is 的秘密

is 运算符用来描述返回值属于 true 还是 false

当函数返回布尔值的时候,可以使用 is 运算符,限定返回值与参数之间的关系。

const isString = (val: unknown): val is string => typeof val === 'string';

is 运算符一般用于描述函数的返回值类型,写法一般采用固定 参数名 is Type 的形式,即左侧为当前函数的参数名,右侧为某一种类型。

is 运算符主要用于类型保护:

多年老鸟教你TypeScript要做的性能优化、关键字进阶与实践、常用几个技巧

上图可以看到 str. 后面的属性都是字符串特有的,这就做到了类型保护的功能;

但是我们细想一想,如果把上面例子中的 val is string 换成 boolean 可以吗?

当然是可以的,只是呢它达不到类型保护的功能;

多年老鸟教你TypeScript要做的性能优化、关键字进阶与实践、常用几个技巧

会发现 str. 后面的属性只有独属于 Boolean 类型的几个属性了;

而且在 Vue3 、Vueuse 等相关开源库中都大量使用到了该用法:Vue3 场景链接

4. as 的秘密

我们常用 as 关键字来转换一个类型,这样极大的方便了我们的编程,毕竟遇事不决就直接 as any 搞定,要是还搞不定,那就双重断言 as any as any, 简直不要太完美;

但是 as 其实还有更高级的用法

用法一:键名重映射

type A = {
  foo: number;
  bar: number;
};

type B = {
  [p in keyof A as `${p}ID`]: number;
};

// 等同于
type B = {
  fooID: number;
  barID: number;
};

在例子中,类型 B 是类型 A 的映射,但在映射时把属性名改掉了,在原始属性名后面加上了字符串 ID。

这就叫做键名重映射

用法二:属性的过滤

type User = {
  name: string,
  age: number
}

type Filter<T> = {
  [K in keyof T as T[K] extends string ? K : never]: string
}

type FilteredUser = Filter<User> // { name: string }

在例子中,映射 K in keyof T 获取类型 T 的每一个属性以后,然后使用 as Type 修改键名。

它的键名重映射 as T[K] extends string ? K : never,使用了条件运算符。

意思就是如果属性值 T[K] 的类型是字符串,那么属性名不变,否则属性名类型改为 never,即这个属性名不存在。

这样就等于过滤了不符合条件的属性,只保留属性值为字符串的属性。

由于键名重映射可以修改键名类型,所以原始键名的类型不必是 string | number | symbol,任意的联合类型都可以用来进行键名重映射。

5. 其它关键字总结归纳

5.1. in

JavaScript 语言中,in 运算符用来确定对象是否包含某个属性名。

TypeScript 语言的类型运算中,in 运算符有不同的用法,用来取出(遍历)联合类型的每一个成员类型。

type U = 'a'|'b'|'c';

type Foo = {
  [Prop in U]: number;
};
// 等同于
type Foo = {
  a: number,
  b: number,
  c: number
};

5.2 extends

条件运算符 TypeA extends TypeB ? A : B 可以根据当前类型是否符合某种条件,返回不同的类型。

T extends U ? X : Y

// true
type T = 1 extends number ? true : false;

5.3 infer

infer 关键字用来定义泛型里面推断出来的类型参数,也就相当于声明一个变量,而不是外部传入的类型参数。

它通常跟条件运算符一起使用,用在 extends 关键字后面的父类型之中(其中父类型包括函数类型等多种类型集合)。

type Flatten<Type> =
  Type extends Array<infer Item> ? Item : Type;

5.4 模板字符串

TypeScript 允许使用模板字符串,构建类型;

模板字符串的最大特点,就是内部可以引用其他类型;

type World = "world";

// "hello world"
type Greeting = `hello ${World}`

注意:模板字符串可以引用的类型一共6种,分别是 string、number、bigint、boolean、null、undefined引用这6种以外的类型会报错。

5.5 typeof 运算符

在 JavaScript 语言中,typeof 运算符是一个一元运算符,返回一个字符串,代表操作数的类型。

TypeScript 将typeof运算符移植到了类型运算,它的操作数依然是一个值,但是返回的不是字符串,而是该值的 TypeScript 类型

const a = { x: 0 };
const b = { y: 0 } as const;

type T0 = typeof a;   // { x: number }
type T1 = typeof a.x; // number
type T2 = typeof b; // {  readonly y: 0;  }

注意点:

  • JavaScript 的 typeof 和 TypeScript 的 typeof 使用时切勿混淆,它们的一个重要区别在于,编译后,前者会保留,后者会被全部删除。
  • TypeScript 的 typeof 类型时,只能用在类型运算之中(即跟类型相关的代码之中),不能用在值运算。
  • TypeScript 的 typeof 命令的参数不能是类型。

三、TS 常用的几个使用技巧

1. Type Guards 类型保护

function isNumber(value: any): value is number {
  return typeof value === "number";
}

const validateAge = (age: any) => {
  if (isNumber(age)) {
    // 操作数字 age
  } else {
    console.error("The age must be a number");
  }
};

也就是上面讲的 is 关键词的使用;

使用语法就是:参数 is 类型

2. Immutable Types 不可变类型(const assertions)

也称之为 const 断言;

使用语法: as const

const ErrorMessages = {
  InvalidEmail: "Invalid email",
  InvalidPassword: "Invalid password",
  // ...
} as const;

// 将会报错
ErrorMessages.InvalidEmail = "New error message";

以上类型将会被推导成为不可变类型,如图所示:

多年老鸟教你TypeScript要做的性能优化、关键字进阶与实践、常用几个技巧

as const 会让 TypeScript 将 ErrorMessages 对象中的属性标记为只读(readonly)。

这意味着,你不能对这些属性进行修改。

此外,as const 还会让 TypeScript 为每个属性推断出一个更精确的类型,即它们的字面量类型,而不是一般的字符串类型。

所以,ErrorMessages 的类型会被推断为:

{
  readonly InvalidEmail: "Invalid email",
  readonly InvalidPassword: "Invalid password",
  // ...
}

注意:as const 不会将上下文的表达式(对象类型)转换为不可变类型

let arr = [1, 2, 3, 4];
let foo = {
  name: "foo",
  contents: arr,
} as const;

foo.name = "bar"; // 报错!
foo.contents = []; // 报错!

foo.contents.push(5); // 这将可以正常运行!

我们如果将它转换为不可变类型呢?


let foo = {
  name: "foo",
  contents: [1, 2, 3, 4],
} as const;

foo.contents.push(5); // 报错 Property 'push' does not exist on type 'readonly [1, 2, 3, 4]'

3. satisfies 代替 as

在 TypeScript4.9 中新引入了 satisfies 操作符,所以使用时请注意当前 TypeScript 版本;

当我们希望确保某些 TypeScript 的表达式某些 TypeScript 的类型匹配,又希望以该表达式的类型来推理的时候,satisfies 操作符 就应运而生了。

1. 场景一:


type Colors = "red" | "green" | "blue";
type RGB = [red: number, green: number, blue: number];

const palette: Record<Colors, string | RGB> = {
    red: [255, 0, 0],
    green: "#00ff00",
    blue: [0, 0, 255]
};

// 调用数组的 at 方法
const redComponent = palette.red.at(0);
// 调用 string 的 toUpperCase 方法,此处会报错:Property 'toUpperCase' does not exist on type 'string | RGB'.
const greenNormalized = palette.green.toUpperCase();

上例 palette 的类型为 Record<Colors, string | RGB>,那么当我们调用 palette.green.toUpperCase() 会报错;

主要是因为 RGB 类型上没有 toUpperCase() 方法,所以就会报错;

那可能有同学会问, 咱们 palette.green 的值就是一个字符串啊,为啥还会报错?

虽然我们都知道他是一个字符串,但是 TypeScript 不知道啊!

所以这个就需要我们反向推导 palette.green 的类型;

as 关键字 可以吗,答案是肯定不可以的,as 关键字 和上面的 类型注解 是一样的用法,也会报相同的错;


const palette = {
    red: [255, 0, 0],
    green: "#00ff00",
    blue: [0, 0, 255]
} as Record<Colors, string | RGB>

// 调用 string 的方法,此处会报错:Property 'toUpperCase' does not exist on type 'string | RGB'.
const greenNormalized = palette.green.toUpperCase();

在以前还真没办法解决,在 TypeScript4.9 版本中新引入了一个关键字 satisfies 操作符

所以我们只需要改成:


const palette = {
    red: [255, 0, 0],
    green: "#00ff00",
    blue: [0, 0, 255]
} satisfies Record<Colors, string | RGB>

// 正常运行:调用数组的 at 方法
const redComponent = palette.red.at(0);
// 正常运行:调用 string 的方法
const greenNormalized = palette.green.toUpperCase();

虽然我们知道 palette 属性值 的类型为 联合类型 string | RGB

但是我们的 satisfies 关键字 反向推导出了现在 palette.green 的类型是一个 string,所以现在调用 toUpperCase() 是没有任何问题的;

2. 场景二:

多年老鸟教你TypeScript要做的性能优化、关键字进阶与实践、常用几个技巧

上例中是一个正常的 TypeScript 写法,其中 a 变量虽然有三个值,但是咱们的编辑器是 没法推导出来属性 link 的

因为 interface MyElement 中可以无限随便追加属性值的写法 [key: string]: any

那么我们需要怎么做呢?

多年老鸟教你TypeScript要做的性能优化、关键字进阶与实践、常用几个技巧

是的,就是这样简单;

所以大多数情况下 satisfies 是可以代替 as 使用的,但是 as 是无法代替 satisfies

4. any VS unknown VS never

  • any:
    • 这个就不多讲了,它就是 AnyScript 的重要组成部分。
  • unknown:
    • 不能直接赋值给其他类型的变量(除了any 类型和 unknown 类型);
    • 不能直接调用 unknown 类型变量的方法和属性;
    • unknown 类型变量能够进行的运算是有限的,只能进行比较运算(运算符==、===、!=、!==、||、&&、?)、取反运算(运算符!)、typeof 运算符和 instanceof 运算符这几种,其他运算都会报错;
    • 所以 keyof unknwon 返回的 never;
  • never:
    • never 类型的使用场景,主要是在一些类型运算之中,保证类型运算的完整性;
    • never 类型可以赋值给任意其他类型呢?这也跟集合论有关,空集是任何集合的子集。TypeScript 就相应规定,任何类型都包含了never 类型。因此,never 类型是任何其他类型所共有的,TypeScript 把这种情况称为**“底层类型”(bottom type)**。
  • 一句话概括就是 TypeScript 有两个“顶层类型”(any 和 unknown),但是“底层类型”只有 never 唯一一个。

也就是说:

  • 任意类型 extends any 都是成立的,any extends 非 unknown 任意类型都是不成立的;
type TestAnyUnknown = unknown extends any ? true : false // true
type TestAnyString = string extends any ? true : false // true
type TestUnknownAny = any extends unknown ? true : false // true
  • 任意类型 extends unknown 都是成立的,unknown extends 非any任意类型 都是不成立的;
type TestUnknown = any extends unknown ? true : false // true
type TestStringUnknown = string extends unknown ? true : false // true
type TestUnknownString = unknown extends string ? true : false // false
  • never extends 任意类型都是成立的,任意类型 extends never 都是不成立的;
type TestNever = never extends string ? true : false // true
type TestToNever = unknown extends never ? true : false // false

5. Record<string, any> 代替 object

Record 是内置的一个类型工具,也是一个较为常用的工具

在一般实际项目中,很多同学喜欢声明一个对象类型时,喜欢这么写:

interface Person {
  [key: string]: unknown
}

const Human: Person = {
  name: "Steve",
  age: 42
}

虽然这是在 TS 中对象类型的一种有效解决方案,但它在编写复杂类型时较为复杂,而且局限性也大;

例如,想使用一些特定的键值:

type AllowedKeys = 'name' | 'age';

interface Person {
  [key: AllowedKeys]: unknown 
  // error: An index signature parameter type cannot be a literal type or generic type. Consider using a mapped object type instead.
}

const Human: Person = {
  name: "Steve",
  age: 42
}

这样是会报错的,那么我们应该怎么做呢?

type AllowedKeys = 'name' | 'age';

type Person = Record<AllowedKeys, unknown>;

const Human: Person = {
  name: "Steve",
  age: 42
}

至此,相关技巧已总结完毕;

如果您有更好的使用技巧,欢迎补充,笔者会添加对应的技巧以及署名!!!

实战(兴趣参加)

讲了再多,也没有实际动手来得快,为了巩固加深大家的印象;

我们一起来动手实现一个实现 ArrayToObject,把一个数组中的值,返回并转成一个对象,实现后可把链接展示在评论区

const ParamsArray = ["a=1","b=2","c=3"]

// 提示:涉及到的知识点:infer、keyof、in、[]、extends、typeof、as const、模版字符串、Record<>

type ArrayToObject<T extends readonly string[] = []> = ?
type ObjectValue = ArrayToObject<typeof ParamsArray>
    
// 返回以下对象
// type ObjectValue = {
//     a: "1";
//     b: "2";
//     c: "3";
// }


最后

感谢大家的支持,码字实在不易,其中如若有错误,望指出,如果您觉得文章不错,记得 点赞关注加收藏 哦 ~

转载自:https://juejin.cn/post/7272229204870660154
评论
请登录