基于ts-morph构建统一编译上下文
关于 ts-morph
ts-morph是一层对TS编译API的封装[1],使得操作AST变得简单高效起来,且提供了开箱即用的项目级编译支持,因而可以很快的构建自己的TS项目编译上下文;除此以外,ts-morph基本上保留了所有原始TS AST节点类型的访问,因此如果有啥TS编译API目前还没封装暴露,可以完全退回到原始API进行相应的使用[2],所以不必担心ts-morph会有过度封装而导致无法使用一些底层API。
关于 TS 编译 API
顾名思义,就是用于TS编译的,通常用于获取和编辑TS AST(抽象语法树)信息,直接通过typescript这个包提供;关于TS编译流程、AST数据结构以及常用的编译API就不再赘述,更多可以参考——TS AST转换实例:从类型声明生成初始化数据 | snowdream;
关于 AST
简言之,AST可以获取到源码的所有(合法)语法信息,同时构造(或修改)AST也能得到对应的代码,可以理解为AST结构与代码之间是一种双射关系。
AST的用处很多,常见的language server都会借助AST信息来进行各种语言DX功能开发(如:自动补全、类型显示、语法诊断、代码格式化等等);同时,由于AST结构可以转换为代码,所以修改AST结构可以从语法层面进行代码的修改(这比直接从文本替换来说效率高多了),因此AST也被来扩展语法的使用(前提是合法的语法,即从一种合法的语法转为另一种合法的语法,以便提高开发效率);
P.s:如果修改AST已经不能满足你的语法需求了,那么是时候考虑进入诸如TC39这种语言标准化组织或者开辟自己的语言了😁。
构建 Vue + TS 统一编译上下文
众所周知,Vue单文件本质上就是一个js文件,那么在Vue + TS(即Vue使用脚本语言也是TS)的项目中把Vue文件中也视为TS文件是一种很自然的事情,这样的话就可以把Vue文件加入到TS编译上下文中,那么项目中Vue文件和TS文件可以共享同一套语法信息。
这么做的好处可以看看Volar/Vetur这种Vue Language Server插件就知道了,因为这样就可以把Vue和TS的类型信息一起使用了,如果体验过早期Vue + TS开发的人就会清楚Vue和TS分属不同的编译上下文是多么的痛苦了(具体表现就是Vue template和script不能互用类型信息,Vue和TS不能共享类型信息等)。
自定义文件系统服务器
由于TS编译器并不能直接解析Vue文件,因此需要先将Vue文件识别并转为对应的TS文件,然后手动挂载到项目源码文件中去即可;但是,如果每次都要对Vue文件进行物理的转换实在是不够优雅,这时候就可以用到ts-morph的自定义文件系统了,通过自定义文件系统可以劫持编译上下文获取源码文件的操作,因而可以在内存层面直接返回Vue文件对应编译后的TS源码,而不需要生成一个具体的文件。

需要注意的是,如果直接按照ts-morph文档上[3]那样实现一个自定义文件系统服务器类还是相当繁琐的,因为需要一口气实现多达十几个实例方法,哪怕是一些你不需要自定义的文件操作;不过阅读其源码后发现,其实ts-morph对于其核心功能采用了分包的架构,其中在@ts-morph/common包中可以发现一个名为RealFileSystemHost的文件系统服务器类(可以理解为内置的一个默认的自定义文件系统服务器类),因此可以在此类的基础上继承实现真正的自定义类,从而只需要实现想要自定义的文件操作即可:
1 | import { RealFileSystemHost } from '@ts-morph/common'; |
Vue 文件编译为 TS 源码
如果仅需要提取Vue文件中的TS部分,只需要提取其script部分即可(毕竟template和style部分也不能提供有效的TS信息);而对于Vue的编译,最适合的工具自然是官方的@vue/compiler-sfc包了:
1 | import { compileScript, parse } from '@vue/compiler-sfc'; |
统一编译上下文的应用:Language Server
构建Vue + TS的统一编译上下文自然不是闲着无聊,这里我个人的目的就是为了实现Language Server的子集功能,用于支撑DX;
注:既然现在已经存在这么多
Language Server插件,为啥还要自己造轮子?那是由于如今的Language Server大多是基于LSP[4]构建的CS架构,其客户端面向的是IDE,因此功能太重且耦合;而此处我需要的语言服务并不需要那么多,且客户端也不是IDE那种庞然大物。
获取 Vue props 类型
要从Vue的TS源码中获取props的类型,首先要知道Vue编译成TS的内容为啥,总的来说Vue3编译成TS时本质上全变成了options声明形式(大概是为了兼容,其实options声明形式就是最本质的用法,Vue3加了setup也只是语法糖):
1 | // ... |
不过从结果来看,基于Vue setup模式 + defineProps<Type>(即通过字面量类型来声明props)的时候会与其它声明方式编译的结果有些许不同,这时在setup方法中会直接手动标注props的类型:
1 | // ... |
因此根据这个区别可以采取不同的策略来获取props类型:
Vue setup模式 +defineProps<Type>:直接获取编译后的类型别名;- 其他:从
props对象字面量来推断类型;
1 | /** |
获取单个 prop 类型
由于props本质上是个对象类型,而在TS运行时中获取某个对象类型的属性类型其实很简单:
1 | type PropType = ObjectType['propKey'] |
不过在编译上下文中直接通过AST节点来获取某个对象类型中某个属性的类型就没那么直接了,好在如果这个对象类型是一个具名类型时,ts-morph有一个近乎作弊的方式可以跟在TS运行时一样获取某个属性的类型节点:
1 | // 注:objectType指的就是具名类型节点 |
基于 Vue prop 类型过滤相同类型的变量
假设这里有一堆潜在的可以被用于赋值给具体prop的变量,如何严格的从这些变量中过滤出兼容该prop类型的变量?其实简单的来说,这就是判断两个类型之间的兼容关系。幸运的是,TS官方暴露了一些类型关系判断相关的内部API[5][6],只不过并不是显式的暴露(即在API类型声明中是没有的),其中TypeChecker的isTypeAssignableTo()方法正是用了判断两个类型之间的兼容关系的:
1 | typeChecker.isTypeAssignableTo(typeA, typeB) // 判断typeB是否兼容与typeA;需要注意类型之间的顺序 |
然后这个内部方法ts-morph并没有封装,因为实际使用时需要多一层路径:
1 | typeChecker.compilerObject.isTypeAssignableTo(typeA.compilerType, typeB.compilerType) |
不过由于Vue3的很多数据类型实际都套上了Ref泛型,而获取到的prop类型是不包含Ref泛型的,因此在判断类型兼容性时需要对具体变量进行Ref泛型的解套,不过好在Vue3本身提供了一个UnwrapRef泛型工具,专门用于提取Ref类型包裹的数据类型:
1 | // 使用vue自带的UnwrapRef泛型可以直接解套ref/computedRef类型,获取里面的数据类型 |
一些技巧
类型信息的输出
在使用IDE写TS代码的时候,当hover到某个标识符上时,IDE会自动显示该标识符的类型信息,其实这里的类型信息就是通过编译API内部的格式化API进行输出的,其中格式化的选项(flags)很多,那么如何美观的输出一个类型节点的信息?基本上使用ts.TypeFormatFlags.InTypeAlias进行输出即可:
1 | typeChecker.getTypeText(type, undefined, ts.TypeFormatFlags.InTypeAlias) // 这里就得到了比较美观的类型格式化信息 |
不过默认输出时会自动对过多的信息采取用...来替代(即省略),如果不想省略任何信息,则可以加上ts.TypeFormatFlags.NoTruncation这个flag:
1 | typeChecker.getTypeText(type, undefined, ts.TypeFormatFlags.InTypeAlias | ts.TypeFormatFlags.NoTruncation) |
不过实践证明默认开启省略不是没有道理的,在复杂的嵌套类型中显示全部类型信息简直过于的冗长……
多重泛型对象类型的解套
在写TS的时候,经常看到一些类型显示为多重泛型的包裹,如:
1 | type A = TypeA<TypeB<TypeC<D>>> |
如果此时对类型A的信息进行打印输出,实际上也就是多重泛型,很不直接,因此有没有办法直接看到最终的对象类型结构[7]?实际上通过一个泛型类型就可以对多重的泛型进行解套[8]:
1 | type Expand<T> = T extends infer O ? { [K in keyof O]: O[K] } : never; |
而在编译上下文中就可以直接在源码中注入这个工具泛型,然后使用类型别名的手段即可得到一个解套后的类型节点。
https://ts-morph.com/setup/file-system#custom-file-system ↩︎
https://github.com/microsoft/TypeScript/issues/11728#issuecomment-257023378 ↩︎
https://www.reddit.com/r/typescript/comments/qbbvah/compiler_api_how_to_print_out_fully_reduced/ ↩︎
https://stackoverflow.com/questions/57683303/how-can-i-see-the-full-expanded-contract-of-a-typescript-type/57683652#57683652 ↩︎