关于前端:穿越类型边界在-TSJSON-schema-与-JS-运行时之间构建统一类型系统

28次阅读

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

近期咱们开源了用于开发低代码工具的框架 Sunmao(榫卯)。在 Sunmao 中,咱们为了晋升多个场景下的开发、应用体验,设计了一套贯通 TS(Typescript)、JSON schema 和 JS(Javascript)运行时的类型零碎。

为什么 Sunmao 须要类型零碎

首先要介绍一下 Sunmao 中的两项外围设计:

  • 角色划分
  • 可扩展性

角色划分 是指 Sunmao 将使用者划分为了组件开发者与利用构建者两个角色。

组件开发者更加关注代码品质、性能以及用户体验等局部,并以此为规范发明出可复用的组件。当组件开发者以他们趁手的形式开发了一个新的组件,他们就能够将该组件封装为一个 Sunmao 组件并注册到组件库中。

利用构建者则选用已有的组件,并实现利用相干的业务逻辑。联合组件与 Sunmao 的平台个性,利用构建者能够更高效地实现这一工作。

之所以将角色进行划分,是因为每时每刻都有利用被开发,但组件迭代的频率则低得多。所以在 Sunmao 的帮忙下,用户能够将组件开发的工作交给大量高级前端工程师或者基于开源我的项目实现,而将利用搭建的工作交由高级前端工程师、后端工程师、甚至是无代码开发教训的人实现。

可扩展性 则是指大部分 Sunmao 组件代码并不保护在 Sunmao 外部,而是动静注册的。这也就要求 Sunmao 的 GUI 编辑器可能感知到各个组件可配置的内容,并呈现出正当的编辑器 UI。

利用元数据

不难看出,为了满足角色划分与可扩展性的需要,咱们须要在不同角色之间保护一份元数据供单方协同,并最终将元数据渲染为利用。

组件开发者在实现组件代码后,将可配置的局部定义为元数据的格局,利用构建者依据场景配置具体的元数据。

响应式状态治理

另一方面,Sunmao 中为了升高利用构建者的开发难度,设计了一套高效的响应式状态管理机制,咱们会在一篇独自的文章中分享它的设计细节。

眼下能够简略的了解为 Sunmao 容许每个组件将本人的状态对外裸露,其余任意组件能够拜访该状态并建设依赖关系,当状态产生变更时主动从新渲染。

例如一个 id 为 demo_input 的输入框对外裸露了 以后输出内容 这一状态,另一个 id 为 demo_text 的文本组件响应式的展现了以后输出内容的长度。

类型与开发体验

因而,咱们在 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"}
      }
    }
  }
}

这段元数据形容了:

  • 输入框承受 defaultValuesize 两种配置,用于指定输入框的初始值与大小。
  • 输入框会将 value 状态对外裸露,用于拜访输入框以后输出的内容。

基于 JSON schema 的元数据定义曾经足够让 GUI 编辑器依据它出现 UI。接下来的指标就是将 JSON schema 的类型定义复用到 Sunmao 组件 SDK 与编辑器的运行时中。

连通 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 的断言能够坑骗编译器,使它认为咱们返回了蕴含 staticTNumber 类型,但在运行时并没有真正的引入额定的 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 代码,并能够拜访利用中所有组件的响应式状态。

表达式的灵便之处在于反对任意非法的 JS 语法,例如编写更为简单一些的多行表达式:

<!– prettier-ignore –>

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

在剖析 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:

Math.E 作为 JS 规范变量在 tern 中曾经被事后定义为 number 类型,而对变量 xy 的赋值则生成了 type graph 中的 edge,Math.E 的类型也顺着 edge 流传,将 xy 的类型流传为 number,实现了类型推断。

如果将代码略作批改,tern 的推断后果可能会出乎你的预料:

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

当再次对 x 赋值为 string 类型时,其实变量 y 的后果并不会随之扭转(number 在 JS 中是根底类型,没有援用关系)。然而在 tern 的 type graph 中,向 x 赋值的动作会为其减少 string 类型,并顺着 edge 流传给 y,在 tern 的类型推断下,xy 都同时具备 string 和 number 两个类型

这与理论的代码后果(x 为 string,ynumber)显然是不符的,但这就是 tern 为了升高 type graph 构建老本与算法逻辑所做的近似解决:疏忽控制流,假如程序中的所有操作均在同一个工夫点产生。并且通常这样的近似推理形式对于代码补全场景并没有太大的不利影响。

在代码中还存在更为简单的类型流传场景,比拟典型的是函数的调用。以另一段代码为例:

function foo(x, y) {return x + y;}
function bar(a, b) {return foo(b, a);
}
const quux = bar("goodbye", "hello");

能够看出依据 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 中不同角色的开发体验。

后续咱们还会介绍与之相干的 Sunmao 功能设计,包含

  • 响应式状态如何实现按需渲染
  • 实现类型推断后如何开发反对混合高亮与代码补全的编辑器

如果你感兴趣,能够在开源社区中关注、参加 Sunmao 我的项目,也欢送向咱们投递简历。

正文完
 0