基于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 ↩︎