天天看點

穿越類型邊界:在 TS、JSON schema 與 JS 運作時之間建構統一類型系統

近期我們開源了用于開發低代碼工具的架構 Sunmao(榫卯)。在 Sunmao 中,我們為了提升多個場景下的開發、使用體驗,設計了一套貫穿 TS(Typescript)、JSON schema 和 JS(Javascript)運作時的類型系統。

為什麼 Sunmao 需要類型系統

首先要介紹一下 Sunmao 中的兩項核心設計:

  • 角色劃分
  • 可擴充性

角色劃分是指 Sunmao 将使用者劃分為了元件開發者與應用建構者兩個角色。

元件開發者更加關注代碼品質、性能以及使用者體驗等部分,并以此為标準創造出可複用的元件。當元件開發者以他們趁手的方式開發了一個新的元件,他們就可以将該元件封裝為一個 Sunmao 元件并注冊到元件庫中。

應用建構者則選用已有的元件,并實作應用相關的業務邏輯。結合元件與 Sunmao 的平台特性,應用建構者可以更高效地完成這一工作。

之是以将角色進行劃分,是因為每時每刻都有應用被開發,但元件疊代的頻率則低得多。是以在 Sunmao 的幫助下,使用者可以将元件開發的任務交給少量進階前端工程師或者基于開源項目完成,而将應用搭建的工作交由初級前端工程師、後端工程師、甚至是無代碼開發經驗的人完成。

可擴充性則是指大部分 Sunmao 元件代碼并不維護在 Sunmao 内部,而是動态注冊的。這也就要求 Sunmao 的 GUI 編輯器能夠感覺到各個元件可配置的内容,并呈現出合理的編輯器 UI。

應用中繼資料

不難看出,為了滿足角色劃分與可擴充性的需求,我們需要在不同角色之間維護一份中繼資料供雙方協同,并最終将中繼資料渲染為應用。

穿越類型邊界:在 TS、JSON schema 與 JS 運作時之間建構統一類型系統

元件開發者在實作元件代碼後,将可配置的部分定義為中繼資料的格式,應用建構者根據場景配置具體的中繼資料。

響應式狀态管理

另一方面,Sunmao 中為了降低應用建構者的開發難度,設計了一套高效的響應式狀态管理機制,我們會在一篇單獨的文章中分享它的設計細節。

眼下可以簡單的了解為 Sunmao 允許每個元件将自己的狀态對外暴露,其餘任意元件可以通路該狀态并建立依賴關系,當狀态發生變更時自動重新渲染。

例如一個 id 為 demo_input 的輸入框對外暴露了​

​目前輸入内容​

​這一狀态,另一個 id 為 demo_text 的文本元件響應式的展示了目前輸入内容的長度。

穿越類型邊界:在 TS、JSON schema 與 JS 運作時之間建構統一類型系統
穿越類型邊界:在 TS、JSON schema 與 JS 運作時之間建構統一類型系統

類型與開發體驗

是以,我們在 Sunmao 中為了提升不同角色的開發體驗,就産生了以下類型需求:

  • 應用中繼資料是有類型的。
  • GUI 編輯器可以根據中繼資料類型呈現出合理的編輯器 UI。
  • 基于中繼資料類型,可以對應用建構者配置的具體值進行校驗。
  • 元件對外暴露的狀态是有類型的。
  • 應用建構者在使用這些狀态時,可以獲得編輯器補全等特性。
  • Sunmao 元件 SDK 是有類型的。
  • 元件開發者定義中繼資料類型之後,實際使用 SDK 開發元件時應該獲得類型保護,降低将元件接入 Sunmao 的成本。

考慮到可移植性、序列化能力以及生态,我們最終選擇使用 JSON schema 描述中繼資料類型。

一個簡化過後的輸入框元件中繼資料定義如下:

{
  "version": "demo/v1",
  "metadata": {
    "name": "input"
  },
  "spec": {
    "properties": {
      "type": "object",
      "properties": {
        "defaultValue": {
          "type": "string"
        },
        "size": {
          "type": "string",
          "enum": ["sm", "md", "lg"]
        }
      }
    },
    "state": {
      "type": "object",
      "properties": {
        "value": {
          "type": "string"
        }
      }
    }
  }
}      

這段中繼資料描述了:

  • 輸入框接受​

    ​defaultValue​

    ​​ 和​

    ​size​

    ​ 兩種配置,用于指定輸入框的初始值與大小。
  • 輸入框會将​

    ​value​

    ​ 狀态對外暴露,用于通路輸入框目前輸入的内容。

基于 JSON schema 的中繼資料定義已經足夠讓 GUI 編輯器根據它呈現 UI。接下來的目标就是将 JSON schema 的類型定義複用到 Sunmao 元件 SDK 與編輯器的運作時中。

穿越類型邊界:在 TS、JSON schema 與 JS 運作時之間建構統一類型系統

連通 TS 與 JSON schema

為了提供最好的類型體驗,Sunmao 的元件 SDK 基于 TS 開發,目前使用 React 作為 UI 架構。

中繼資料中 ​

​spec.properties​

​ 即應用建構者配置的部分,會以 React 元件 props 參數的形式傳入,用于實作元件的邏輯。

通常,我們會使用 TS 定義 props 的類型,同樣以輸入框元件為例,props 的類型定義如下。

type InputProps = {
  defaultValue: string;
  size: "sm" | "md" | "lg";
};

function Input(props: InputProps) {
  // implement the component
}      

這時問題出現了,作為元件開發者,即需要定義 JSON schema 類型,也需要定義 TS 類型,不論是初次定義還是後續維護都是額外的負擔。

是以我們要尋求一種方式使元件開發者僅定義一次,就能同時生成 JSON schema 與 TS 類型。

首先我們用 TS 實作一個簡單的 JSON schema builder,僅支援建構 number 類型的 schema:

class TypeBuilder {
  public Number() {
    return this.Create({ type: "number" });
  }

  protected Create<T>(schema: T): T {
    return schema;
  }
}

const builder = new TypeBuilder();
const numberSchema = builder.Number(); // -> { "type": "number" }      

JSON schema 顧名思義,是以 JSON 形式存在的,屬于運作時的一部分,而 TS 的類型隻存在于編譯階段。

在這個簡易的 TypeBuilder 中我們建構了運作時對象 ​

​numberSchema​

​​,值為 ​

​{ type: "number" }​

​​。下一個目标就是如何将 ​

​numberSchema​

​​ 這個運作時對象與 TS 中的 ​

​number​

​ 類型建立關聯。

沿着建立關聯這個思路,我們定義一個 TS 類型 ​

​TNumber​

​:

type TNumber = {
  static: number;
  type: "number";
};      

在 ​

​TNumber​

​​ 中,包含了一個 number 類型的 JSON schema 結構,同時有一個 ​

​static​

​ 字段指向了 TS 中的 number 類型。

在此基礎上,優化一下我們的 TypeBuilder:

class TypeBuilder {
  public Number(): TNumber {
    return this.Create({ type: "number" });
  }

  protected Create<T>(schema: Omit<T, "static">): T {
    return schema as any;
  }
}

const builder = new TypeBuilder();
const numberSchema = builder.Number(); // typeof numberSchema -> TNumber      

這裡的關鍵技巧是 ​

​return schema as any​

​​ 的處理。在 ​

​this.Create​

​​ 的調用中,并沒有真正傳入 ​

​static​

​​ 字段。但當調用 ​

​Number​

​​ 時,期望 ​

​this.Create​

​​ 的泛型傳回 ​

​TNumber​

​​ 類型,包含 ​

​static​

​ 字段。

正常情況下,​

​this.Create​

​​ 無法通過類型校驗,而 ​

​as any​

​​ 的斷言可以欺騙編譯器,使它認為我們傳回了包含 ​

​static​

​​ 的 ​

​TNumber​

​​ 類型,但在運作時并沒有真正的引入額外的 ​

​static​

​ 字段。

此時,運作時對象的 ​

​numberSchema​

​​ 的 TS 類型已經指向了 TNumber,而 ​

​TNumber['static']​

​​ 指向的就是最終期望的 ​

​number​

​ 類型。

至此,我們就連通了 TS 與 JSON schema。

為了簡化代碼中的使用,我們還可以實作一個泛型 ​

​Static​

​ 用于擷取 TypeBuilder 建構出來的運作時對象的類型:

type Static<T extends { static: unknown }> = T["static"];

type MySchema = Static<typeof numberSchema>; // -> number      

将這一技巧拓展至 string 類型的 JSON schema 同樣适用:

type TNumber = {
  static: number;
  type: "number";
};

+type TString = {
+  static: string;
+  type: "string";
+};

class TypeBuilder {
  public Number(): TNumber {
    return this.Create({ type: "number" });
  }

+ public String(): TString {
+   return this.Create({ type: "string" });
+ }

  protected Create<T>(schema: Omit<T, "static">): T {
    return schema as T;
  }
}      

當然在實際使用的過程中還有許多的細節,例如 JSON schema 除基礎類型資訊之外,還支援配置許多其他附加資訊;以及更複雜的 JSON schema 類型 AnyOf、OneOf 等如何與 TS 類型結合。

是以在 Sunmao 中,我們最終使用的是更為完善的開源項目 typebox 實作 TypeBuilder。

一個更複雜的 schema 示例:

const inputSchema = Type.Object({
  defaultValue: Type.String(),
  size: Type.StringEnum(["sm", "md", "lg"]),
});
/* JSON schema
{
  "type": "object",
  "properties": {
    "defaultValue": {
      "type": "string"
    },
    "size": {
      "type": "string",
      "enum": ["sm", "md", "lg"]
    }
  }
}
*/

type InputProps = Static<typeof inputSchema>;
/* TS type
{
  defaultValue: string;
  size: "sm" | "md" | "lg";
};
*/      

在 JS 運作時中推斷類型

實作了 JSON schema 與 TS 的結合之後,我們進一步思考如何在編輯器的 JS 運作時中推斷類型,為應用建構者提供自動補全等特性。

在 Sunmao 編輯器中,通過名為​

​表達式​

​的特性支援編寫 JS 代碼,并可以通路應用中所有元件的響應式狀态。

穿越類型邊界:在 TS、JSON schema 與 JS 運作時之間建構統一類型系統

表達式的靈活之處在于支援任意合法的 JS 文法,例如編寫更為複雜一些的多行表達式:

{{(() => {
  function response(value) {
    if (value === 'hello') {
      return 'world'
    }
    return value
  }
  const res = response(demo_input.value);
  return String(res);
})()}}      

在分析 JS 運作時類型推斷方法之前,先展示一下類型推斷在表達式中的應用:

穿越類型邊界:在 TS、JSON schema 與 JS 運作時之間建構統一類型系統

從示範中可以清晰的看到,對函數 ​

​response​

​​ 傳回的變量 ​

​res​

​ 我們準确推斷了其類型,進而進一步補全了對應類型變量的方法。

值得注意的是,當 ​

​response​

​​ 傳入 string 類型的變量時,​

​res​

​​ 的類型也被推斷為了 string,而當傳入值變為 number,傳回值的推斷結果也變為了 number。這與 ​

​response​

​ 函數的内部實作邏輯是相符的。

但表達式中包含的隻是正常的 JS 文法,而不是擁有類型的 TS 代碼,Sunmao 是如何從中推斷類型的呢?實際上我們使用了 JS 代碼分析引擎 tern 來實作這一點。

Tern 的由來

Tern 的作者 Marijn Haverbeke 也是前端領域使用廣泛的開源項目 CodeMirror、Acorn 等項目的作者。

Marijn 在開發基于運作在 Web 中的代碼編輯器 CodeMirror 的過程中産生了對于“代碼補全”這一功能的需求,由此開發了 Tern,用于分析 JS 代碼并推斷代碼中的類型,最終實作代碼補全。

在開發 Tern 的過程中 Marijn 又發現在編輯器場景下,代碼通常處于不完整且文法不合法的狀态,是以開發了能夠解析“不合法 JS”的 JS parser:Acorn。

值得一提的是,Tern 中所實作的類型推斷算法主要參考了論文《Fast and Precise Hybrid Type Inference for JavaScript》,該篇論文的作者是當時在 Mozilla 負責開發火狐浏覽器 JS 引擎 SpiderMonkey 的工程師 Brian Hackett 和 Shu-yu Guo,論文中描述了 SpiderMonkey 所使用的類型推斷算法。

不過 Marijn 也在自己的部落格中介紹,Tern 的場景與 SpiderMonkey 并不相同。Tern 從編輯器補全的場景出發,可以實作的更為激進,使用更多近似、犧牲一定精度以提供更好的推斷結果或更少的性能開銷。

Tern 的類型推斷算法。

Tern 通過代碼靜态分析,建構代碼對應的類型圖結構(type graph)。Graph 的每個 node 為程式中的變量或表達式,以及目前推斷出得類型;每條 edge 則為變量之間的傳播關系。

首先從一段簡單的代碼了解 type graph 與傳播。

const x = Math.E;
const y = x;      

對于這段代碼,tern 會建構出如下圖所示的 type graph:

穿越類型邊界:在 TS、JSON schema 與 JS 運作時之間建構統一類型系統

​Math.E​

​​ 作為 JS 标準變量在 tern 中已經被預先定義為 number 類型,而對變量 ​

​x​

​​ 和 ​

​y​

​​ 的指派則生成了 type graph 中的 edge,​

​Math.E​

​​ 的類型也順着 edge 傳播,将 ​

​x​

​​ 和 ​

​y​

​ 的類型傳播為 number,完成了類型推斷。

如果将代碼略作修改,tern 的推斷結果可能會出乎你的意料:

const x = Math.E;
const y = x;
x = "hello";      
穿越類型邊界:在 TS、JSON schema 與 JS 運作時之間建構統一類型系統

當再次對 ​

​x​

​​ 指派為 string 類型時,其實變量 ​

​y​

​​ 的結果并不會随之改變(number 在 JS 中是基礎類型,沒有引用關系)。但是在 tern 的 type graph 中,向 ​

​x​

​​ 指派的動作會為其增加 string 類型,并順着 edge 傳播給 ​

​y​

​​,在 tern 的類型推斷下,​

​x​

​​ 和 ​

​y​

​ 都同時具備 string 和 number 兩個類型。

這與實際的代碼結果(​

​x​

​​ 為 string,​

​y​

​​ 為 ​

​number​

​)顯然是不符的,但這就是 tern 為了降低 type graph 建構成本與算法邏輯所做的近似處理:忽略控制流,假設程式中的所有操作均在同一個時間點發生。并且通常這樣的近似推理方式對于代碼補全場景并沒有太大的不利影響。

在代碼中還存在更為複雜的類型傳播場景,比較典型的是函數的調用。以另一段代碼為例:

function foo(x, y) {
  return x + y;
}
function bar(a, b) {
  return foo(b, a);
}
const quux = bar("goodbye", "hello");      
穿越類型邊界:在 TS、JSON schema 與 JS 運作時之間建構統一類型系統

可以看出根據 tern 建構的 type graph,在多次函數調用後仍然可以推斷出 ​

​quxx​

​ 的類型為 string。

對于更複雜的場景,例如逆向推斷、繼承、泛型函數等的 type graph 建構技巧,可以參考上文中的部落格連結。

在 Sunmao 中使用 tern

基于 tern 提供的類型推斷能力,已經可以解決 Sunmao 表達式中正常 JS 的代碼補全需求。但上文提到 Sunmao 的表達式可以通路所有元件的響應式狀态。這些狀态被自動注入到 JS scope 中而不存在于表達式的代碼内,是以 tern 無法感覺它們的存在以及類型。

不過 tern 提供了一套 definition 機制,可以向它聲明環境中已經存在的變量及類型。而在 Sunmao 中元件通過 JSON schema 定義了對外暴露的狀态類型,是以我們可以通過一個 JSON schema 與 tern definition 的轉換函數自動為 tern 提供這部分類型聲明:

function generateTypeDefFromJSONSchema(schema: JSONSchema7) {
  switch (schema.type) {
    case "array": {
      const arrayType = `[${Types.ARRAY}]`;
      return arrayType;
    }
    case "object": {
      const objType: Record<string, string | Record<string, unknown>> = {};
      const properties = schema.properties || {};
      Object.keys(properties).forEach((k) => {
        if (k in properties) {
          const nestSchema = properties[k];
          if (typeof nestSchema !== "boolean") {
            objType[k] = generateTypeDefFromJSONSchema(nestSchema);
          }
        }
      });
      return objType;
    }
    case "string":
      return "string";
    case "number":
    case "integer":
      return "number";
    case "boolean":
      return "bool";
    default:
      return "?";
  }
}      

在一些場景中元件的狀态 JSON schema 較為寬松,是以我們還會對上述方法稍加改造,從狀态的運作時實際值中讀取類型,動态生成 tern definition,提供更多類型聲明資訊。

小結

通過文中的方法,我們實作了隻維護一份類型定義,自動在 TS、JSON schema 與 JS 運作時三者之間建構統一的類型系統,提升 Sunmao 中不同角色的開發體驗。

  • 響應式狀态如何實作按需渲染
  • 完成類型推斷後如何開發支援混合高亮與代碼補全的編輯器
  • ...