基于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插件就知道了,因为这样就可以把VueTS的类型信息一起使用了,如果体验过早期Vue + TS开发的人就会清楚VueTS分属不同的编译上下文是多么的痛苦了(具体表现就是Vue templatescript不能互用类型信息,VueTS不能共享类型信息等)。

自定义文件系统服务器

由于TS编译器并不能直接解析Vue文件,因此需要先将Vue文件识别并转为对应的TS文件,然后手动挂载到项目源码文件中去即可;但是,如果每次都要对Vue文件进行物理的转换实在是不够优雅,这时候就可以用到ts-morph的自定义文件系统了,通过自定义文件系统可以劫持编译上下文获取源码文件的操作,因而可以在内存层面直接返回Vue文件对应编译后的TS源码,而不需要生成一个具体的文件。

image-20220913184645464

需要注意的是,如果直接按照ts-morph文档上[3]那样实现一个自定义文件系统服务器类还是相当繁琐的,因为需要一口气实现多达十几个实例方法,哪怕是一些你不需要自定义的文件操作;不过阅读其源码后发现,其实ts-morph对于其核心功能采用了分包的架构,其中在@ts-morph/common包中可以发现一个名为RealFileSystemHost的文件系统服务器类(可以理解为内置的一个默认的自定义文件系统服务器类),因此可以在此类的基础上继承实现真正的自定义类,从而只需要实现想要自定义的文件操作即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { RealFileSystemHost } from '@ts-morph/common';

class CustomFS extends RealFileSystemHost {
constructor(private projectRoot: string) {
super();
}

// ...

readFileSync(filePath: string, encoding?: string): string {
// ...
}

// ...
}

Vue 文件编译为 TS 源码

如果仅需要提取Vue文件中的TS部分,只需要提取其script部分即可(毕竟templatestyle部分也不能提供有效的TS信息);而对于Vue的编译,最适合的工具自然是官方的@vue/compiler-sfc包了:

1
2
3
4
5
6
7
8
9
10
import { compileScript, parse } from '@vue/compiler-sfc';

const vueAST = parse(vueSourceCode, {
sourceRoot: 'xxx',
filename: 'xxx',
}) // 先提取vue整体AST

const script = compileScript(vueAST.descriptor, {
id: 'xxx',
}); // 然后再单独将script AST转为TS源码

统一编译上下文的应用:Language Server

构建Vue + TS的统一编译上下文自然不是闲着无聊,这里我个人的目的就是为了实现Language Server的子集功能,用于支撑DX

注:既然现在已经存在这么多Language Server插件,为啥还要自己造轮子?那是由于如今的Language Server大多是基于LSP[4]构建的CS架构,其客户端面向的是IDE,因此功能太重且耦合;而此处我需要的语言服务并不需要那么多,且客户端也不是IDE那种庞然大物。

获取 Vue props 类型

要从VueTS源码中获取props的类型,首先要知道Vue编译成TS的内容为啥,总的来说Vue3编译成TS时本质上全变成了options声明形式(大概是为了兼容,其实options声明形式就是最本质的用法,Vue3加了setup也只是语法糖):

1
2
3
4
5
6
7
8
// ...

export default __defineComponent({
// ...
props: // ...
setup: // ...
// ...
})

不过从结果来看,基于Vue setup模式 + defineProps<Type>(即通过字面量类型来声明props)的时候会与其它声明方式编译的结果有些许不同,这时在setup方法中会直接手动标注props的类型:

1
2
3
4
5
6
7
8
9
10
11
// ...

export default __defineComponent({
// ...
props: // ...
setup: (props: __props) {
const props = __props as SomeType
// ...
}
// ...
})

因此根据这个区别可以采取不同的策略来获取props类型:

  • Vue setup模式 + defineProps<Type>:直接获取编译后的类型别名
  • 其他:从props对象字面量来推断类型;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
/**
* 通过事先对vue组件的ts编译,直接从其ts文件的SourceFile获取props type
*
* 适用于除defineProps<Type>()以外的vue3编译
*/
function getPropsTypeFromAST(ast: SourceFile) {
const props = ast.forEachChild((node) => {
// 通过事先编译好的CarverryComponentProps类型直接获取
if (Node.isTypeAliasDeclaration(node) && node.getName() === 'CarverryComponentProps') {
return node;
}
});

if (!props) {
return null;
}

return props;
}

/**
* 根据vue编译生成的ts代码结构特点,从对应的default export节点截取出该组件的props类型
* @param assignment default export声明节点
* @returns
*/
function getPropsType(assignment: ExportAssignment, ast: SourceFile) {
// 获取到defineComponent参数节点(对象)
const getObj = (node: Node<ts.Node>) => {
if (Node.isObjectLiteralExpression(node)) {
return node;
}

return node.forEachChild<Node<ts.Node>>(getObj) as Node<ts.ObjectLiteralExpression>;
};
const obj = assignment.forEachChild(getObj);
if (!obj) {
return null;
}
// 获取到setup方法声明节点
const setup = obj.forEachChild((node) => {
if (Node.isMethodDeclaration(node) && node.getName() === 'setup') {
return node;
}
});
if (!setup) {
return null;
}
// 获取到props声明节点
const getProps = (node: Node<ts.Node>) => {
// 貌似props的编译根据声明方式有所不同(目前只有setup模式,且通过defineProps<Type>的方式才会编译成props = __props as xxx)的形式
if (Node.isVariableDeclaration(node) && node.getName() === 'props') {
return node;
}

return node.forEachChild<Node<ts.Node>>(getProps) as VariableDeclaration;
};
const props = setup.forEachChild(getProps);
if (!props) {
return getPropsTypeFromAST(ast);
}
const t = props.getType();

if (t.getFlags() === ts.TypeFlags.Any) {
return getPropsTypeFromAST(ast);
}

return props;
}

获取单个 prop 类型

由于props本质上是个对象类型,而在TS运行时中获取某个对象类型的属性类型其实很简单:

1
type PropType = ObjectType['propKey']

不过在编译上下文中直接通过AST节点来获取某个对象类型中某个属性的类型就没那么直接了,好在如果这个对象类型是一个具名类型时,ts-morph有一个近乎作弊的方式可以跟在TS运行时一样获取某个属性的类型节点:

1
2
3
4
5
6
// 注:objectType指的就是具名类型节点
const propType = objectType.getSourceFile().addTypeAlias({
name: 'SomeAliasTypeName',
type: `ObjectTypeName['propKey']`,
}); // 直接返回属性的类型节点
// 这里本质上相当于在源码构造了一段代码:type SomeAliasTypeName = ObjectTypeName['propKey']

基于 Vue prop 类型过滤相同类型的变量

假设这里有一堆潜在的可以被用于赋值给具体prop的变量,如何严格的从这些变量中过滤出兼容该prop类型的变量?其实简单的来说,这就是判断两个类型之间的兼容关系。幸运的是,TS官方暴露了一些类型关系判断相关的内部API[5][6],只不过并不是显式的暴露(即在API类型声明中是没有的),其中TypeCheckerisTypeAssignableTo()方法正是用了判断两个类型之间的兼容关系的:

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
2
3
4
5
6
7
8
// 使用vue自带的UnwrapRef泛型可以直接解套ref/computedRef类型,获取里面的数据类型
// import('vue')可直接在type alias声明中导入模块,避免需要对AST节点或源码直接插入import节点
// sourceFile就是变量所在源码文件的AST根节点
const aliasType = sourceFile.addTypeAlias({
name: 'AliasTypeName',
type: `import('vue').UnwrapRef<typeof 变量标识符名称>`,
});
// 这里就获取到了具体变量解套Ref后的类型

一些技巧

类型信息的输出

在使用IDETS代码的时候,当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;

而在编译上下文中就可以直接在源码中注入这个工具泛型,然后使用类型别名的手段即可得到一个解套后的类型节点。


  1. https://ts-morph.com/ ↩︎

  2. https://ts-morph.com/navigation/compiler-nodes ↩︎

  3. https://ts-morph.com/setup/file-system#custom-file-system ↩︎

  4. https://microsoft.github.io/language-server-protocol/ ↩︎

  5. https://github.com/microsoft/TypeScript/issues/9879 ↩︎

  6. https://github.com/microsoft/TypeScript/issues/11728#issuecomment-257023378 ↩︎

  7. https://www.reddit.com/r/typescript/comments/qbbvah/compiler_api_how_to_print_out_fully_reduced/ ↩︎

  8. https://stackoverflow.com/questions/57683303/how-can-i-see-the-full-expanded-contract-of-a-typescript-type/57683652#57683652 ↩︎