TS AST转换实例:从类型声明生成初始化数据

前言

如果在TS中声明了一些对象类型,使用这些对象类型来注解变量时,这些变量值的初始化往往需要自己手动按对象结构填写一遍,尽管有了TS类型声明和IDE的智能提示的加持下,这个填写过程还是很透明快速的;但是仔细一想,明明在声明这些对象类型的时候已经填过一层结构,为啥在数据初始化的时候又不得不去再填一遍,难道就不能自动根据声明的结构和一些简单的规则直接生成我们想要的初始化数据吗?

这是可以做到的;答案就来自于TS本身提供的编译器相关的API,只要从编译的角度去看待上面的问题,那么解决方案还是“比较轻松”的;

实现流程

img

原理

从原理上来说,就是借助JSDoc语法,根据AST结构找到特定用于生成初始数据的注释,返回一份新的AST结构,最后根据新的AST生成输出的代码;

TS编译流程

img

TS编译器核心相关的功能大致上有这么几个:

  • 预处理器:加载源码文件及其依赖文件(通过import语句导入或者<refrence path=xxx />标签语法导入的声明文件)和tsconfig.json配置文件中额外加载的声明文件,并将这些文件加入到编译上下文中;
  • 解析器:对源码文件做出词法分析,并得到相应的AST(抽象语法树)结构;
  • 结合器:遍历AST,找到其中的具名声明结点,并将同一实体的不同声明进行归纳(这个实体通常是Identifier,即标识符,也就是具名的)形成这个实体的Symbol,同时Symbol内也会记录对应声明的有效作用域
  • 类型检查器:根据得到Symbol信息得出相应的Type信息,以节点的Type信息作为预期语义,以节点的AST信息作为实际语义,对节点做出语义诊断,然后得出相应的诊断信息(如error/warning等);
  • 发射器:发射器根据编译选项以及AST信息(SourceFile结点)输出相应的目标代码(包含js(x)/js.map/d.ts);

相关概念

  • SymbolSymbol是专门用于连接同一实体AST中所有的具名声明结点(相应地自然就有匿名声明结点,声明既包括一般语法的声明,也包括类型系统中的类型声明),因此是语义系统中的基本结构[1]

  • TypeType则是语义系统中另一种基本结构,包括具名和匿名的类型;Type是从Symbol推断得到的最佳公共类型best-common-type),这里的类型可以是手动注解的,当没有手动注解时就会从值的内容进行推导;

    img

  • 诊断信息semantic diagnostic):即语义诊断信息,语义和语法是两种不同的系统,语义存在于类型系统中,是专门用于描述类型关系的,而诊断信息就是类型检查的产物;

TS AST数据结构

每一个待编译源码文件经过解析器的词法分析后,都会得到一个AST,该树的顶点结点SourceFile一般结点Node,而叶结点则是一些特殊的Node结点,如下图所示:

img

  • NodeAST中最基本的结点,通常不是叶结点;一般包含pos(在源码文件中的起始位置)、end(在源码文件中的结束位置)和parent(父结点)等属性,具体结构可以查看TS源码中的Node interface定义;
  • SourceFileAST中的顶点,本身也是一种Node,还包含了一些源码文件相关的信息以及获取源码的方法;

TS编译常用API

注:以下均为ts namespace下的API;XXX意为某个Node interface的名称;

  • createSourceFile:根据源码文本生成一个相应的AST(即SourceFile);
  • createPrinter:生成TS代码打印器实例,可以根据AST结点(包括SourceFile)信息生成相应的TS代码;
  • transform:根据提供的AST transformer工厂函数,对AST结点进行转换;
  • isXXX:快速判断某个AST结点是否为某种特定的结点;
  • factory.createXXX:创建一个新的AST结点;
  • factory.updateXXX:修改一个存在的AST结点;之前可以通过直接修改结点属性来修改结点信息,现在已经不推荐这么做了(新版API会逐渐把结点属性全部设置为只读);
  • visitNode:访问AST结点,可以通过访问器函数返回值来替代或删除当前结点;
  • visitEachChild:用法同visitNode类似,不过会向下遍历树的子节点
  • forEachChild:用于遍历AST结点的所有子结点,跟访问器不同,这里的返回值并不会影响遍历的结点;

上述所有的API详情建议查看源码,或者直接在IDE中查看代码提示(很详细,有的甚至给出了代码示例);factoryTS4.0新推出的工厂函数API,用于替代之前散乱的结点修改API,具体理由可以查看这个pull,从实践上来看新版的工厂函数API用起来确实更加统一了,使用成本就低了,毕竟和结点的interface一一对应了,老版API还会存在工厂函数名称与interface名称不一致的情况,写起来就有点坑;

实现细节(仅供参考)

本文中提到的需求并不需要通过创造新语法来实现,只是通过修改AST结构即可实现,因此还算是“比较轻松”;

JSDoc基本语法

JSDoc是一种扩展js注释用法的插件(主要是生成文档和提示),不过现在在各种IDE的广泛支持下,JSDoc算是一种事实上的注释标准了,且可以承担一部分同TS类似的类型注解功能;JSDoc可以被编译器识别成正确语法,且TS编译器解析时也会对JSDoc语法解析并生成AST结点;因此JSDoc语法可以作为语法扩展的一个突破点,其大致语法如下:

1
2
3
4
5
6
7
/**
* comment
* @tagName tag-comment
*/
interface XXX {
// ...
}

其本质上是利用了多行注释的部分,然后基于文本解析进行具体的细分:

  • comment:注释文本部分;
  • tagName:注释标签;
  • tag-comment:注释标签说明文本;

事实上,JSDoc根据功能特性已经内置了一些标签,且标签后面的文本部分也做了更具体的语法切分;不过作为基于JSDoc语法的扩展,一般都是使用自定义的标签名称;

JSDoc结点

TS中的JSDoc结点并非是单独存在的,而是依附在JSDoc注释后面的语法结点,可以通过该结点的jsDoc属性来访问,不过需要注意的是目前jsDoc属性属于API的内部属性,因此结点接口声明文件上并未暴露;如:

1
2
3
4
5
6
7
8
9
10
11
/**
* xxx
* @param a 参数1
*/
function t1 (a: Test): void {
let b = 'xx'
console.log(a)
if (a.name) {
// 123
}
}

img

因此通过这些特性就可以定制一些自定义的JSDoc标签和语法格式,然后通过AST结点进行匹配,进而定制相应的AST结点;

DSL设计

1
2
3
4
5
6
7
8
9
/**
* @model
*/
interface XXX {
/** @default value */
prop1: string;
prop2: number;
// ...
}
  • @modelmodel标签用于标记该声明类型需要进行初始化,使用特定标记可以加快遍历速度;
  • @defaultdefault标签用于显式标记属性的默认值,没有指定的则使用默认规则;
  • value:用于指定默认值,可以是字面量,也可以是匿名函数,事实上其本质也可以是一段代码,取决于具体需求,可以解析成一个值就行ir;
1
2
3
4
5
6
7
8
// 输出的目标初始化函数
function initXXX (): XXX {
return {
prop1: xxx,
prop2: xxx
// ...
}
}

AST修改流程

img

后话

修改语言特性一直都让人望而却步,好像编译器和AST是有点遥不可及的东西;不过事实上接触了TS的编译器API后,发现其AST结构相当的透明,API自由度也是很大的,且相当的规范统一;再加上各种用于辅助查看AST结构的网站,其实修改AST也不是那么的遥远和困难;所以,如果不满足语言特性的功能,可以大胆地尝试自己去扩展或者修改。

相关文档


  1. https://github.com/microsoft/TypeScript/wiki/Architectural-Overview#data-structures ↩︎