关于前端:精读Typescript-4546-新特性

56次阅读

共计 7879 个字符,预计需要花费 20 分钟才能阅读完成。

新增 Awaited 类型

Awaited 能够将 Promise 理论返回类型抽出来,依照名字能够了解为:期待 Promise resolve 了拿到的类型。上面是官网文档提供的 Demo:

// A = string
type A = Awaited<Promise<string>>;

// B = number
type B = Awaited<Promise<Promise<number>>>;

// C = boolean | number
type C = Awaited<boolean | Promise<number>>;

捆绑的 dom lib 类型能够被替换

TS 因开箱即用的个性,捆绑了所有 dom 内置类型,比方咱们能够间接应用 Document 类型,而这个类型就是 TS 内置提供的。

兴许有时不想随着 TS 版本升级而降级连带的 dom 内置类型,所以 TS 提供了一种指定 dom lib 类型的计划,在 package.json 申明 @typescript/lib-dom 即可:

{
 "dependencies": {"@typescript/lib-dom": "npm:@types/web"}
}

这个个性晋升了 TS 的环境兼容性,但个别状况还是倡议开箱即用,省去繁琐的配置,我的项目更好保护。

模版字符串类型也反对类型收窄

export interface Success {type: `${string}Success`;
    body: string;
}

export interface Error {type: `${string}Error`;
    message: string;
}

export function handler(r: Success | Error) {if (r.type === "HttpSuccess") {
        // 'r' has type 'Success'
        let token = r.body;
    }
}

模版字符串类型早就反对了,但当初才反对依照模版字符串在分支条件时,做类型收窄。

减少新的 –module es2022

尽管能够应用 –module esnext 放弃最新个性,但如果你想应用稳固的版本号,又要反对顶级 await 个性的话,能够应用 es2022。

尾递归优化

TS 类型零碎反对尾递归优化了,拿上面这个例子就好了解:

type TrimLeft<T extends string> =
    T extends ` ${infer Rest}` ? TrimLeft<Rest> : T;

// error: Type instantiation is excessively deep and possibly infinite.
type Test = TrimLeft<"oops">;

在没有做尾递归优化前,TS 会因为堆栈过深而报错,但当初能够正确返回执行后果了,因为尾递归优化后,不会造成逐步加深的调用,而是执行完后立刻退出以后函数,堆栈数量始终保持不变。

JS 目前还没有做到主动尾递归优化,但能够通过自定义函数 TCO 模仿实现,上面放出这个函数的实现:

function tco(f) {
  var value;
  var active = false;
  var accumulated = [];
  return function accumulator(...rest) {accumulated.push(rest);
    if (!active) {
      active = true;
      while (accumulated.length) {value = f.apply(this, accumulated.shift());
      }
      active = false;
      return value;
    }
  };
}

外围是把递归变成 while 循环,这样就不会产生堆栈。

强制保留 import

TS 编译时会把没用到的 import 干掉,但这次提供了 --preserveValueImports 参数禁用这一个性,起因是以下状况会导致误移除 import:

import {Animal} from "./animal.js";

eval("console.log(new Animal().isDangerous())");

因为 TS 无奈分辨 eval 里的援用,相似的还有 vue 的 setup 语法:

<!-- A .vue File -->
<script setup>
import {someFunc} from "./some-module.js";
</script>

<button @click="someFunc">Click me!</button>

反对变量 import type 申明

之前反对了如下语法标记援用的变量是类型:

import type {BaseType} from "./some-module.js";

当初反对了变量级别的 type 申明:

import {someFunc, type BaseType} from "./some-module.js";

这样不便在独立模块构建时,平安的抹去 BaseType,因为单模块构建时,无奈感知 some-module.js 文件内容,所以如果不特地指定 type BaseType,TS 编译器将无奈辨认其为类型变量。

类公有变量查看

蕴含两个个性,第一是 TS 反对了类公有变量的查看:

class Person {#name: string;}

第二是反对了 #name in obj 的判断,如:

class Person {
    #name: string;
    constructor(name: string) {this.#name = name;}

    equals(other: unknown) {
        return other &&
            typeof other === "object" &&
            #name in other && // <- this is new!
            this.#name === other.#name;
    }
}

该判断隐式要求了 #name in otherother 是 Person 实例化的对象,因为该语法仅可能存在于类中,而且还能进一步类型缩窄为 Persion 类。

Import 断言

反对了导入断言提案:

import obj from "./something.json" assert {type: "json"};

以及动静 import 的断言:

const obj = await import("./something.json", {assert: { type: "json"}
})

TS 该个性反对了任意类型的断言,而不关怀浏览器是否辨认。所以该断言如果要失效,须要以下两种反对的任意一种:

  • 浏览器反对。
  • 构建脚本反对。

不过目前来看,构建脚本反对的语法并不对立,比方 Vite 对导入类型的断言有如下两种形式:

import obj from "./something?raw"

// 或者借鉴的语法 blob 加载模式
const modules = import.meta.glob(
  './**/index.tsx',
  {assert: { type: 'raw'},
  },
);

所以该导入断言至多在将来能够对立构建工具的语法,甚至让浏览器原生反对后,就不须要构建工具解决 import 断言了。

其实齐全靠浏览器解析要走的路还有很远,因为一个简单的前端工程至多有 3000~5000 个资源文件,目前生产环境不可能应用 bundless 一个个加载这些资源,因为速度太慢了。

const 只读断言

const obj = {a: 1} as const

obj.a = 2 // error

通过该语法指定对象所有属性为 readonly

利用 realpathSync.native 实现更快加载速度

对开发者没什么感知,就是利用 realpathSync.native 晋升了 TS 加载速度。

片段主动补全加强

在 Class 成员函数与 JSX 属性的主动补全性能做了加强,在应用了最新版 TS 之后应该早已有了体感,比方 JSX 书写标签输出回车后,会主动依据类型补全内容,如:

<App cla />
//    ↑回车↓
//        <App className="|" />
//                        ↑光标主动移到这里 

代码能够写在 super() 前了

JS 对 super() 的限度是此前不能够调用 this,但 TS 限度的更严格,在 super() 前写任何代码都会报错,这显然过于严格了。

当初 TS 放宽了校验策略,仅在 super() 前调用 this 会报错,而执行其余代码是被容许的。

这点其实早就该改了,这么严格的校验策略让我一度认为 JS 就是不容许 super() 前调用任何函数,但想想也感觉不合理,因为 super() 示意调用父类的 constructor 函数,之所以不主动调用,而须要手动调用 super() 就是为了开发者能够灵便决定哪些逻辑在父类构造函数前执行,所以 TS 之前一刀切的行为实际上导致 super() 失去了存在的意义,成为一个没有意义的模版代码。

类型收窄对解构也失效了

这个个性真的很厉害,即解构后类型收窄仍然失效。

此前,TS 的类型收窄曾经很弱小了,能够做到如下判断:

function foo(bar: Bar) {if (bar.a === '1') {bar.b // string 类型} else {bar.b // number 类型}
}

但如果提前把 a、b 从 bar 中解构进去就无奈主动收窄了。当初该问题也失去了解决,以下代码也能够失常失效了:

function foo(bar: Bar) {const { a, b} = bar
  if (a === '1') {b // string 类型} else {b // number 类型}
}

深度递归类型查看优化

上面的赋值语句会产生异样,起因是属性 prop 的类型不匹配:

interface Source {prop: string;}

interface Target {prop: number;}

function check(source: Source, target: Target) {
    target = source;
    // error!
    // Type 'Source' is not assignable to type 'Target'.
    //   Types of property 'prop' are incompatible.
    //     Type 'string' is not assignable to type 'number'.
}

这很好了解,从报错来看,TS 也会依据递归检测的形式查找到 prop 类型不匹配。但因为 TS 反对泛型,如下写法就是一种有限递归的例子:

interface Source<T> {prop: Source<Source<T>>;}

interface Target<T> {prop: Target<Target<T>>;}

function check(source: Source<string>, target: Target<number>) {target = source;}

实际上不须要像官网阐明写的这么简单,哪怕是 props: Source<T> 也足以让该例子有限递归上来。TS 为了确保该状况不会出错,做了递归深度判断,过深的递归会终止判断,但这会带来一个问题,即无奈辨认上面的谬误:

interface Foo<T> {prop: T;}

declare let x: Foo<Foo<Foo<Foo<Foo<Foo<string>>>>>>;
declare let y: Foo<Foo<Foo<Foo<Foo<string>>>>>;

x = y;

为了解决这一问题,TS 做了一个判断:递归爱护仅对递归写法的场景失效,而下面这个例子,尽管也是很深层次的递归,但因为是一个集体肉写进去的,TS 也会不厌其烦的一个个递归上来,所以该场景能够正确 Work。

这个优化的外围在于,TS 能够依据代码构造解析哪些是“十分形象 / 启发式”写法导致的递归,哪些是一个个枚举产生的递归,并对后者的递归深度查看进行豁免。

加强的索引推导

上面的官网文档给出的例子,一眼看上去比较复杂,咱们来拆解剖析一下:

interface TypeMap {
    "number": number;
    "string": string;
    "boolean": boolean;
}

type UnionRecord<P extends keyof TypeMap> = {[K in P]:
    {
        kind: K;
        v: TypeMap[K];
        f: (p: TypeMap[K]) => void;
    }
}[P];

function processRecord<K extends keyof TypeMap>(record: UnionRecord<K>) {record.f(record.v);
}

// This call used to have issues - now works!
processRecord({
    kind: "string",
    v: "hello!",

    // 'val' used to implicitly have the type 'string | number | boolean',
    // but now is correctly inferred to just 'string'.
    f: val => {console.log(val.toUpperCase());
    }
})

该例子的目标是实现 processRecord 函数,该函数通过辨认传入参数 kind 来主动推导回调函数 fvalue 的类型。

比方 kind: "string",那么 val 就是字符串类型,kind: "number",那么 val 就是数字类型。

因为 TS 这次更新解决了之前无奈辨认 val 类型的问题,咱们不须要关怀 TS 是怎么解决的,只有记住 TS 能够正确辨认该场景(有点像围棋的定式,对于经典例子最好逐个学习),并且了解该场景是如何结构的。

如何做到呢?首先定义一个类型映射:

interface TypeMap {
    "number": number;
    "string": string;
    "boolean": boolean;
}

之后定义最终要的函数 processRecord:

function processRecord<K extends keyof TypeMap>(record: UnionRecord<K>) {record.f(record.v);
}

这里定义了一个泛型 K,K extends keyof TypeMap 等价于 K extends 'number' | 'string' | 'boolean',所以这里是限定了以下泛型 K 的取值范畴,值为这三个字符串之一。

重点来了,参数 record 须要依据传入的 kind 决定 f 回调函数参数类型。咱们先设想以下 UnionRecord 类型怎么写:

type UnionRecord<K extends keyof TypeMap> = {
  kind: K;
  v: TypeMap[K];
  f: (p: TypeMap[K]) => void;
}

如上,天然的想法是定义一个泛型 K,这样 kindf, p 类型都能够示意进去,这样 processRecord<K extends keyof TypeMap>(record: UnionRecord<K>)UnionRecord<K> 就示意了将以后接管到的理论类型 K 传入 UnionRecord,这样 UnionRecord 就晓得理论解决什么类型了。

原本到这里该性能就曾经完结了,但官网给的 UnionRecord 定义稍有些不同:

type UnionRecord<P extends keyof TypeMap> = {[K in P]:
    {
        kind: K;
        v: TypeMap[K];
        f: (p: TypeMap[K]) => void;
    }
}[P];

这个例子特意晋升了一个复杂度,用索引的形式绕了一下,可能之前 TS 就无奈解析这种模式吧,总之当初这个写法也被反对了。咱们看一下为什么这个写法与下面是等价的,下面的写法简化一下如下:

type UnionRecord<P extends keyof TypeMap> = {[K in P]: X
}[P];

能够解读为,UnionRecord 定义了一个泛型 P,该函数从对象 {[K in P]: X } 中依照索引(或了解为下标)[P] 获得类型。而 [K in P] 这种形容对象 Key 值的类型定义,等价于定义了复数个类型,因为正好 P extends keyof TypeMap,你能够了解为类型开展后是这样的:

type UnionRecord<P extends keyof TypeMap> = { 
  'number': X,
  'string': X,
  'boolean': X
}[P];

而 P 是泛型,因为 [K in P] 的定义,所以必然能命中下面其中的一项,所以实际上等价于上面这个简略的写法:

type UnionRecord<K extends keyof TypeMap> = {
  kind: K;
  v: TypeMap[K];
  f: (p: TypeMap[K]) => void;
}

参数控制流剖析

这个个性字面意思翻译挺奇怪的,还是从代码来了解吧:

type Func = (...args: ["a", number] | ["b", string]) => void;

const f1: Func = (kind, payload) => {if (kind === "a") {payload.toFixed();  // 'payload' narrowed to 'number'
    }
    if (kind === "b") {payload.toUpperCase();  // 'payload' narrowed to 'string'
    }
};

f1("a", 42);
f1("b", "hello");

如果把参数定义为数组且应用或并列枚举时,其实就潜在蕴含了一个运行时的类型收窄。比方当第一个参数值为 a 时,第二个参数类型就确定为 number,第一个参数值为 b 时,第二个参数类型就确定为 string

值得注意的是,这种类型推导是从前到后的,因为参数是自左向右传递的,所以是后面推导出前面,而不能是前面推导出后面(比方不能了解为,第二个参数为 number 类型,那第一个参数的值就必须为 a)。

移除 JSX 编译时产生的非必要代码

JSX 编译时干掉了最初一个没有意义的 void 0, 缩小了代码体积:

- export const el = _jsx("div", { children: "foo"}, void 0);
+ export const el = _jsx("div", { children: "foo"});

因为改变很小,所以能够借机学习一下 TS 源码是怎么批改的,这是 PR DIFF 地址。

能够看到,批改地位是 src/compiler/transformers/jsx.ts 文件,改变逻辑为移除了 factory.createVoidZero() 函数,该函数正如其名,会创立开端的 void 0,除此之外就是大量的 tests 文件批改,其实了解了源码上下文,这种批改并不难。

JSDoc 校验提醒

JSDoc 正文因为与代码是拆散的,随着一直迭代很容易与理论代码产生分叉:

/**
 * @param x {number} The first operand
 * @param y {number} The second operand
 */
function add(a, b) {return a + b;}

当初 TS 能够对命名、类型等不统一给出提醒了。顺便说一句,用了 TS 就尽量不要用 JSDoc,毕竟代码和类型拆散随时有不统一的危险产生。

总结

从这两个更新来看,TS 曾经进入成熟期,但 TS 在泛型类的问题上仍然还处于晚期阶段,有大量简单的场景无奈反对,或者没有优雅的兼容计划,心愿将来能够不断完善简单场景的类型反对。

探讨地址是:精读《Typescript 4.5-4.6 新个性》· Issue #408 · dt-fe/weekly

如果你想参加探讨,请 点击这里,每周都有新的主题,周末或周一公布。前端精读 – 帮你筛选靠谱的内容。

关注 前端精读微信公众号

<img width=200 src=”https://img.alicdn.com/tfs/TB165W0MCzqK1RjSZFLXXcn2XXa-258-258.jpg”>

版权申明:自在转载 - 非商用 - 非衍生 - 放弃署名(创意共享 3.0 许可证)

正文完
 0