最近,我们开源了一个用于开发低代码工具的框架Sunmao(榫卯)。在三猫,为了提升多场景下的开发和使用体验,我们设计了一个贯穿TS(Typescript)、JSONschema和JS(Javascript)runtime的类型系统。为什么孙猫需要类型系统首先介绍一下孙猫的两个核心设计:角色划分和扩展性角色划分是指孙猫将用户分为两种角色:组件开发者和应用构建者。组件开发人员更加关注代码质量、性能和用户体验,并以此为标准来创建可重用的组件。组件开发者在按照自己的方式开发新的组件时,可以将该组件打包为三猫组件,注册到组件库中。应用程序构建器选择现有组件并实施与应用程序相关的业务逻辑。将组件与三猫的平台特性相结合,应用程序构建者可以更有效地做到这一点。角色划分的原因是应用程序一直在开发,但组件的迭代频率要低得多。因此,在孙猫的帮助下,用户可以将组件开发的任务交给少数资深前端工程师或基于开源项目,将应用构建的工作交给初级前端工程师,后面-终端工程师,甚至是没有代码开发经验的人。结束。可扩展性是指孙猫的大部分组件代码都不是在孙猫内部维护的,而是动态注册的。这也需要三猫的GUI编辑器能够感知各个组件的可配置内容,并呈现合理的编辑器UI。应用元数据不难看出,为了满足角色划分和可扩展性的要求,我们需要在不同角色之间维护一份元数据,供双方协作使用,最终将元数据渲染成一个应用程序。组件开发者实现组件代码后,将可配置部分定义为元数据格式,应用构建者根据场景配置具体的元数据。响应式状态管理另一方面,为了降低应用构建者的开发难度,孙茂设计了一套高效的响应式状态管理机制,我们将在另一篇文章中分享其设计细节。目前可以简单理解为,孙茂允许每个组件对外暴露自己的状态,其他任何组件都可以访问这个状态并建立依赖关系,并在状态发生变化时自动重新渲染。例如,一个id为demo_input的输入框暴露当前输入内容的状态,另一个id为demo_text的文本组件响应式显示当前输入内容的长度。类型和开发体验因此,为了提升孙猫不同角色的开发体验,我们有以下类型需求:应用元数据有类型。GUI编辑器可以根据元数据类型呈现合理的编辑器UI。根据元数据类型,可以验证应用程序构建者配置的具体值。组件公开的状态是有类型的。应用程序构建者在使用这些状态时可以获得编辑器完成等功能。孙茂组件SDK打字。组件开发者定义了元数据类型后,在实际使用SDK开发组件时应该获得类型保护,降低将组件集成到商贸的成本。考虑到可移植性、序列化能力和生态性,我们最终选择使用JSONschema来描述元数据类型。一个简化的输入框组件元数据定义如下:{"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配置,用于指定输入框的初始值和大小。输入框会对外暴露值状态,用于访问输入框当前输入的内容。基于JSONschema的元数据定义足以让GUI编辑器根据它来渲染UI。下一个目标是将JSONschema的类型定义复用到三猫组件SDK和编辑器的runtime中。连接TS和JSONschema为了提供最好的类型体验,三猫的组件SDK是基于TS开发的,目前使用React作为UI框架。元数据中的spec.properties是应用构建器配置的部分,会作为React组件的props参数传入,实现组件的逻辑。通常,我们使用TS来定义props的类型。以输入框组件为例,props的类型定义如下。类型InputProps={defaultValue:string;尺寸:“sm”|“MD”|"lg";};functionInput(props:InputProps){//实现组件}此时问题就来了,作为组件开发者,即需要定义JSONschema类型和TS类型。初始定义和后续维护都是额外的负担。因此,我们需要想办法让组件开发者同时定义JSONschema和TStype。首先,我们使用TS实现一个简单的JSON模式构建器,它只支持构建数字类型的模式:classTypeBuilder{publicNumber(){returnthis.Create({type:"number"});}protectedCreate(schema:T):T{returnschema;}}constbuilder=newTypeBuilder();constnumberSchema=builder.Number();//->{"type":"number"}JSONschema,顾名思义,以JSON的形式存在,是runtime的一部分,而TS的type只存在于编译阶段。在这个简单的TypeBuilder中,我们使用值{type:"number"}构建运行时对象numberSchema。下一个目标是如何将运行时对象numberSchema与TS中的数字类型相关联。按照建立关联的思路,我们定义一个TS类型TNumber:typeTNumber={static:number;输入:“数字”;};TNumber中包含了一个number类型的JSONschema结构,TS中有一个静态字段指向改变了的number类型。在此基础上优化我们的TypeBuilder:classTypeBuilder{publicNumber():TNumber{returnthis.Create({type:"number"});}protectedCreate(schema:Omit):T{returnschemaasany;}}constbuilder=newTypeBuilder();constnumberSchema=builder.Number();//typeofnumberSchema->TNumber这里的关键技巧是将返回模式处理为any。在调用this.Create时,并没有真正传入静态字段。但是在调用Number时,期望this.Create的泛型返回TNumber类型,包括静态字段。一般情况下,this.Create是不能通过类型验证的,asany的断言可以骗过编译器认为我们返回的是一个包含static的TNumber类型,但它并没有真正在运行时引入额外的static字段。此时runtime对象的numberSchema的TS类型已经指向了TNumber,TNumber['static']指向了最终期望的数字类型。到目前为止,我们已经连接了TS和JSON模式。为了简化代码中的使用,我们也可以实现一个泛型的Static来获取TypeBuilder构建的运行时对象的类型:typeStatic=T["static"];typeMySchema=静态;//->number将此技术扩展到字符串类型的JSON模式:typeTNumber={static:number;type:"number";};+typeTString={+static:string;+type:"string";+};classTypeBuilder{publicNumber():TNumber{returnthis.Create({type:"number"});}+publicString():TString{+returnthis.Create({type:"string"});+}protectedCreate(schema:Omit):T{returnschemaasT;}}当然在实际使用过程中还有很多细节,比如JSONschema除了基本的类型信息之外,还支持配置很多其他的附加信息;以及更复杂的JSON模式类型AnyOf、OneOf等如何与TS类型结合。所以在三毛,我们最终还是使用了比较完善的开源项目typebox来实现TypeBuilder。一个更复杂的模式示例:constinputSchema=Type.Object({defaultValue:Type.String(),size:Type.StringEnum(["sm","md","lg"]),});/*JSON模式{"type":"object","properties":{"defaultValue":{"type":"string"},"size":{"type":"string","enum":["sm","md","lg"]}}}*/typeInputProps=Static;/*TStype{defaultValue:string;尺寸:“sm”|“MD”|"lg";};*/在JSRuntime中推断类型在实现了JSONschema和TS的结合之后,我们进一步思考如何在编辑器的JSruntime中推断类型,为应用程序构建者提供自动完成等功能。在孙猫编辑器中,一个叫做表达式的特性支持编写JS代码,并且可以访问应用程序中所有组件的反应状态。表达式的灵活性在于支持任何合法的JS语法,比如写更复杂的多行表达式:{{(()=>{functionresponse(value){if(value==='hello'){return'world'}returnvalue}constres=response(demo_input.value);returnString(res);})()}}在分析JS运行时类型推断方法之前,先展示应用oftypeinferenceinexpressions:从演示中我们可以清楚的看到,我们已经准确的推断出了函数response返回的变量res的类型,从而进一步完成了对应类型变量的方法。值得注意的是,当response传入一个string类型的变量时,res的类型也被推断为string,而当传入值变为number时,返回值的推断结果也变为number。这与响应函数的内部实现逻辑是一致的。但是表达式只包含常规的JS语法,没有类型化的TS代码,孙茂是如何从中推断出类型的呢?我们实际上使用tern,一个JS代码分析引擎来做这件事。Tern的出身MarijnHaverbeke,Tern的作者,也是CodeMirror、Acorn等前端领域广泛使用的开源项目的作者。在开发基于Web运行的代码编辑器CodeMirror的过程中,Marijn对“代码补全”功能有需求。于是开发了Tern来分析JS代码,推断代码中的类型,最终实现了代码补全。在开发Tern的过程中,Marijn发现在编辑器场景中,代码通常不完整,语法不合法,于是他开发了一个可以解析“非法JS”的JS解析器:Acorn。值得一提的是,Tern中实现的类型推断算法主要参考了论文《Fast and Precise Hybrid Type Inference for JavaScript》。这篇论文的作者是当时在Mozilla负责开发FirefoxJS引擎SpiderMonkey的工程师BrianHackett和Shu-yuGuo。SpiderMonkey使用的类型推断算法在中描述。不过,Marijn在博客中也介绍过,Tern的场景与SpiderMonkey不同。从编辑器补全场景开始,Tern可以更加激进,使用更多的近似值并牺牲一定的精度来提供更好的推理结果或更少的性能开销。Tern的类型推断算法。Tern通过对代码的静态分析构造出代码对应的类型图结构。Graph的每个节点是程序中的一个变量或表达式,当前推断出的类型;每条边是变量之间的传播关系。首先从一段简单的代码了解类型图和传播。constx=Math.E;常量y=x;对于这段代码,tern会构建一个类型图,如下图所示:Math.E作为JS标准变量,在tern中已经预定义为数字类型,对于变量x和y的赋值生成类型图中的边,Math.E的类型也沿着边传播,x和y的类型作为number传播,类型推断完成。如果稍微修改一下代码,tern的推理结果可能会让你大吃一惊:constx=Math.E;常量y=x;x="你好";当x再次被赋值为string类型时,变量y的实际结果并不会相应改变(number是JS中的基本类型,没有引用关系)。但是在tern的类型图中,给x赋值的动作会为它添加一个字符串类型,并沿着边传播给y。在tern的类型推断下,x和y都有string和number两种类型。这显然与实际代码结果不一致(x为字符串,y为数字),但这是tern为降低类型图构建和算法逻辑成本所做的近似处理:忽略控制流,假设所有操作在该程序发生在同一时间点。而且通常这样的近似推理方法不会对代码补全场景产生太大的不利影响。代码中还有更复杂的类型传播场景,典型的是函数调用。以另一段代码为例:functionfoo(x,y){returnx+y;}functionbar(a,b){returnfoo(b,a);}constquux=bar("goodbye","你好”);可以看出,按照tern构建的类型图,经过多次函数调用,依然可以推导出quxx的类型为string。更复杂的场景,比如反向推理、继承、泛型函数等类型图构造技巧,可以参考上面的博客链接。基于tern提供的类型推断能力,在孙猫中使用tern,已经可以解决孙猫表达式中正则JS的代码补全需求。但是正如上面所说,孙茂的表达式可以访问所有组件的反应状态。这些状态自动注入到JS作用域中,不存在于表达式的代码中,因此tern不知道它们的存在和类型。但是,tern提供了一种定义机制来声明环境中已经存在的变量和类型。在三猫中,组件通过JSONschema定义对外暴露的state类型,所以我们可以通过JSONschema和terndefinition之间的转换函数,自动为tern提供这部分类型声明:functiongenerateTypeDefFromJSONSchema(schema:JSONSchema7){switch(schema.type){case"array":{constarrayType=`[${Types.ARRAY}]`;返回数组类型;}case"object":{constobjType:Record<字符串,字符串|记录<字符串,未知>>={};const属性=schema.properties||{};Object.keys(properties).forEach((k)=>{if(kinproperties){constnestSchema=properties[k];if(typeofnestSchema!=="boolean"){objType[k]=generateTypeDefFromJSONSchema(nestSchema);}}});返回对象类型;}案例“字符串”:返回“字符串”;案例“数字”:案例“整数”:返回“数字”;案例“布尔值”:返回“布尔值”";default:return"?";}}在某些场景下,组件的stateJSONschema比较松散,所以我们将上面的方法稍微修改一下,在运行时从state的实际值中读取类型,动态地生成tern定义,提供更多的类型声明信息总结通过本文的方法,我们实现了只维护一个类型定义,在TS、JSONschema和JSruntime之间自动构建了一个统一的类型系统,提高了开发效率孙猫体验中的不同角色,后面我们也会介绍相关的孙猫功能设计,包括如何在响应式状态下实现按需渲染,以及如何开发一个支持混合高亮和完成类型推断后代码补全的编辑器……如果您有兴趣,可以在开源社区关注并参与孙猫项目,欢迎投递简历给我们。