賦予 TypeScript 更多可能性

本文爲來自飛書 aPaaS Growth 研發 團隊成員的文章,已授權 ELab 發佈。

aPaaS Growth 團隊專注在用戶可感知的、宏觀的 aPaaS 應用的搭建流程,及租戶、應用治理等產品路徑,致力於打造 aPaaS 平臺流暢的 “應用交付” 流程和體驗,完善應用構建相關的生態,加強應用搭建的便捷性和可靠性,提升應用的整體性能,從而助力 aPaaS 的用戶增長,與基礎團隊一起推進 aPaaS 在企業內外部的落地與提效。

背景

之前在技術需求中曾調研了基於 TypeScript 的數據校驗方案,其中調研了一個叫 Deepkit 的第三方庫,可以將 TypeScript 的類型信息保留到運行時進行消費。

TypeScript 帶來的

傳統開發上,Javascript 基本沒有提供任何類型保護,所有的類型錯誤都需要在運行時才能發現,而 TypeScript 爲開發者提供了一套靜態類型檢查的方案,它提倡開發者在源碼中主動聲明類型信息,並與對應的變量和操作相匹配,並在編譯階段進行檢查,類型相關的錯誤在編譯時就暴露出來,一方面使代碼更規範了,一方面也極大程度地規避了許多代碼錯誤,提高了代碼的健壯性。

TypeScirpt 擁有完備的類型系統。但很可惜,它在這方面的能力在運行時幾乎完全不存在。TypeScript Compiler 在編譯源碼時會刪除類型信息,不對運行時造成任何開銷。

但其實在許多場景下,運行時的類型信息都是極具價值的!

爲什麼需要運行時類型

爲什麼我們需要運行時的類型信息呢?讓我們看看下面兩個場景

數據校驗

數據校驗並不是侷限於傳統前端所關注的表單校驗,需要數據校驗的場景數不勝數,比如:

序列化與反序列化

序列化是將數據類型轉換爲適合傳輸或存儲的格式的過程。反序列化是撤消此操作的過程,這個過程需要保證是無損的。對於前端開發者來說,接觸的最多的應該就是 JSON.parse()JSON.stringify() 這兩個方法。在簡單場景下,用這兩個方法做序列化和反序列化可能沒有問題,但是在複雜場景中就不一定了,因爲這兩個方法並不能保證數據是無損的。

例如下面這個場景

const date = new Date();
const dateString = JSON.stringify(date);//"2022-11-02T17:49:03.240Z"
const dateJson = JSON.parse(dateString);//"2022-11-02T17:49:03.240Z"

對於日期類型的數據,先用 JSON.stringify(date) 將其序列化成了適合傳輸的格式,再用 JSON.parse(dateString) 反序列化,發現日期這個類型在過程中已經丟失,最後反序列化的結果爲一個字符串,這顯然是不符合預期的。因此,在序列化和反序列化的過程中,類型信息也十分重要。

而 DeepKit 使將 TypeScript 類型保留到運行時成爲現實。

快速開始

官方文檔站:https://deepkit.io/

前置

使用 DeepKit 需要安裝兩個包:

npm install --save @deepkit/type
npm install --save-dev @deepkit/type-compiler

然後需要在 tsconfig.json 中配置 "reflection": true 。如果需要使用裝飾器,還需要加入"experimentalDecorators": true 參數

// tsconfig.json
{
    "compilerOptions":{
        "module":"CommonJS",
        "target":"es6",
        "moduleResolution":"node",
        "experimentalDecorators":true
    },
    "reflection":true,
}

類型信息

DeepKit 定義了兩種用於描述運行時的類型信息的數據結構,分別是類型對象和反射類。

類型對象

使用 typeOf 方法可以快速獲取某個類型對應的類型對象。

import { typeOf } from '@deepkit/type';
type Title<T> = T extends true ? string : number;

typeOf<Title<true>>();
//Type {kind: 5, typeName: 'Title', typeArguments: [{kind: 7}]}

從上面的例子中,我們可以看到一個類型對象的基本數據結構(當然,這還不是它的全貌)。詳細的類型對象定義:https://github.com/deepkit/deepkit-framework/blob/feature/autotype/packages/type/src/reflection/type.ts#L21-L452

enum ReflectionKind {
  never,    //0
  any,     //1
  unknown, //2
  void,    //3
  object,  //4
  string,  //5
  number,  //6
  boolean, //7
  symbol,  //8
  bigint,  //9
  null,    //10
  undefined, //11

  //... and even more
}

反射類

反射類多用於 類 / 接口 / 對象類型等等比較複雜的場景

import { ReflectionClass } from '@deepkit/type';

interface User {
    id: number;
    username: string;
}

const reflection = ReflectionClass.from<User>();

reflection.getProperty('id'); //ReflectionProperty,記錄id類型信息

reflection.getProperty('id').name; //'id'
reflection.getProperty('id').type; //{kind: ReflectionKind.number}
reflection.getProperty('id').isOptional(); //false
reflection.removeProperty('id');
reflection.getProperty('id');//Error: No property id found in User

對於複雜場景,我們可以通過 ReflectionClass.from 方法得到類型對應的放射類實例 ReflectionClass ,通過調用 ReflectionClass 中的方法可以獲取更深層次的類型信息,也可以對類型信息做一些操作。

驗證

需要數據驗證的場景數不勝數,接口參數校驗,數據庫實現等都高度依賴數據校驗,以此保證數據的安全性。

DeepKit 提供了 is 和 validate 兩個函數,用於校驗一個值是否符合類型定義。

interface People {
  name: string
  age: number,
  info?: {
    address?: string,
    phone: number
  }
}

const peopleA = {
    name: 'Jack',
    age: 20,
}

const peopleB = {
    name: 'Peter',
    age: 18,
    info: {}
}

is<People>(peopleA)//true
is<People>(peopleB)//false

is 函數接收類型信息,並對參數中的數據進行校驗,返回一個布爾值。如上面的例子,定義了一個 People 的 interface,並對 peopleA 和 peopleB 兩個數據進行校驗,可以看出 peopleA 是符合 People 的 定義的,所以返回is<People>(peopleA)會返回 true 。peopleB 中的 info 屬性缺少了必填的 phone 字段,因此is<People>(peopleB) 會返回 false 。

validate<People>(peopleA)//[]

validate<People>(peopleB)
// [{
//   path: 'info.phone',
//   code: 'type',
//   message: 'Not a number'
// }]

validate 函數和 is 函數的用法類似,區別是 validate 函數並不是返回一個布爾值 ,而是一個包含錯誤信息的數組。

序列化

DeepKit 中 serialize/deserialize 兩個方法,爲用戶提供了序列化 / 反序列化的能力

import { serialize } from '@deepkit/type';

class MyModel {
  id: number = 0;
  created: Date = new Date;

  constructor(public name: string) {
  }
}

const model = new MyModel('Peter');

const jsonObject = serialize<MyModel>(model);
//{
//  id: 0,
//  created: 2022-11-02T17:49:03.240Z,
//  name: 'Peter'
//}

serialize 方法接收類型信息和需要序列化的數據,將數據序列化爲符合類型定義的 JSON 對象。

const myModel = deserialize<MyModel>({
    id: 5,
    created: 'Sat Oct 13 2018 14:17:35 GMT+0200',
    name: 'Peter',
});

is<Date>(myModel.created)// true

deserialize 方法接收類型信息和需要反序列化的數據,將數據反序列化爲符合類型信息定義的數據。代碼中的 created 字段會被反序列化爲 Date 字段。

類型裝飾器

一句話概括裝飾器:裝飾器本質上就是一個函數,可以在運行時對被裝飾對象進行自定義的加工處理。

DeepKit 中提供了一套類型裝飾器,這裏的類型裝飾器和 TypeScript 的裝飾器並不相同,TypeScript 多用於對類的裝飾,類型裝飾器顧名思義是對類型的裝飾。這些類型裝飾器可以被當作一個正常的 TypeScript 類型使用。

舉一個簡單的例子

import { integer } from '@deepkit/type';

// case 1
type count = integer;
is<count>(1) // true
is<count>(1.1) // false

我們對定義 count 類型爲 integer(整型),可以看到,1.1 這個浮點數類型並沒有通過校驗。

除此之外,DeepKit 還實現瞭如 PrimaryKey(主鍵),maxLength/minLength(最小 / 最大長度)等功能的類型裝飾器。我們可以把這些類型裝飾器看作對於 TypeScript 類型的拓展,這些類型裝飾器使 TypeScript 能夠實現數據庫級別的類型定義。也正是基於這套拓展後的運行時類型,驗證和序列化可以有更多的約束,DeepKit 也實現了一套高性能的 ORM 。

More

@deepKit/type 給我們提供了一套運行時調用類型信息的方案。除此之外,DeepKit 的作者還基於類型信息和反射機制實現了更多的能力。

如何保證性能

爲了儘量壓縮運行時的額外開銷,DeepKit 的作者做出了不少優化。

類型緩存

在未使用泛型的情況下,DeepKit 會對使用到的類型對象進行緩存

//  case1
type MyType = string;

typeOf<MyType>() === typeOf<MyType>(); //true

//  case2
type MyType<T> = T;

typeOf<MyType<string>>() === typeOf<MyType<string>>();//false

可以看到,對於 case1 ,Mytype 對應的類型對象會被緩存,因此兩次typeOf<MyType>() 的結果相等;但是對於泛型來說,我們無法確定傳入的 T 具體是什麼類型(理論上會有無限種),因此不會結果進行緩存,每次都會創建一個新的類型對象。

類型編譯器

DeepKit 的核心原理是一個類型編譯器,它會介入 TypeScript 的編譯流程,保留類型信息, 在這個過程中,Deepkit 的類型編譯器會讀取源碼中的類型信息,產生相關的字節碼(爲了使它儘可能小),並將其插入 AST 中,將其轉化爲另一個包含這些字節碼信息的 TypeScript AST。

在運行時,DeepKit 會有一個迷你虛擬機,負責解析和執行這些字節碼,最後會返回一個類型對象。

更詳細的原理可以參考:https://github.com/microsoft/TypeScript/issues/47658

在 DeepKit 官方提供的性能圖中,可以看到 DeepKit 在數據讀寫上的表現是比較優秀的,這也歸功於 DeepKit 提供的 運行時類型信息,這種預先知曉類型信息的機制可以使 序列化 / 驗證等更加快速高效。

總結

DeepKit 是市場上第一個在 JavaScript 運行時提供全套 TypeScript 類型的解決方案。它使前端 / 服務端可以共用一套 TypeScript 定義的數據模型,並且使用基於 TypeScript 實現的一套反射機制。

但它依舊存在一些不足,比如 不支持外部類型,若代碼中使用的類型信息來自第三方,且第三方庫也沒有經過 deepkit 的類型編譯器的話,外部類型的類型信息在運行時也會全部丟失。

官方文檔站:https://deepkit.io/

一些討論

在 TypeScript 的倉庫中,其實已經有許多人提出了 issue,對在運行時保留 Typescript 的類型信息提出了自己的設想。可以看出,在基於 TypeScript 支持動態類型這件事情上,是有需求的,但是 TypeScript 始終是保持保留意見,並沒有實質去支持相關能力。

個人的看法,根本上是和 TypeScript 的設計目標 [1] 掛鉤, TypeScript 官方團隊並不希望 TypeScript 會對運行時造成額外的開銷,並且希望生成的 JavaScript 儘量純淨。TypeScript 官方團隊 的保守嚴謹造就了 TypeScript 的成功。可能正因如此,TypeScript 官方團隊才一直對支持運行時類型持保守態度。

參考文獻

https://deepkit.io/ https://github.com/microsoft/TypeScript/issues/47658

參考資料

[1]

設計目標: https://github.com/Microsoft/TypeScript/wiki/TypeScript-Design-Goals#goals

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/mrm9zNq0q8cYJDXatHh6-w