精讀《Typescript 4.5-4.6 新特性》

黃子毅 發表於 2022-04-13

新增 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 許可證