我们知道,babel 能够解析 typescript 语法,那么能不能基于 babel 实现类型检查呢?
我们经常用 tsc 来做类型检查,有没有想过,类型检查具体做了什么?
这一节,我们来学习一下类型、类型检查、怎么实现 ts 的类型检查。
什么是类型
类型代表了变量存储的内容,也就是规定了这块内容占据多大的内存空间,可以对它做什么操作。比如 number 和 boolean 就会分配不同字节数的内存,Date 和 String 可以调用的方法也不同。这就是类型的作用。它代表了一种可能性,你可以在这块内存放多少内容,可能对它进行什么操作。
类型分为动态类型
和静态类型
。
动态类型
是指类型是在运行时才确定的,而静态类型
是指编译期间就知道了变量的类型信息。有了类型信息自然就知道了对它而言什么操作是合法的,什么操作是不合法的,什么变量能够赋值给他。
静态类型
会在代码中保留类型信息,这个类型信息可能是显式声明的,也可能是自动推导出来的。想做一个大的项目,没有静态类型
来约束和提前检查代码的话,太容易出 bug 了,会很难维护。这也是随着前端项目逐渐变得复杂,出现了 typescript 以及 typescript 越来越火的原因。
typescript 就是给 javascript 增加了静态类型
的语法和相应的语义。
如何检查类型
我们知道了什么是类型以及为什么要做静态的类型检查,那么怎么检查呢?
检查类型就是检查变量的内容,而理解代码的话需要把代码 parse 成 AST,所以类型检查也就变成了对 AST 结构的检查。
比如一个变量声明为了 number,那么给它赋值的是一个 string 就是有类型错误。
再复杂一点,如果类型有泛型,也就是有类型参数,那么需要传入具体的参数来确定类型,确定了类型之后再去和实际的 AST 对比。
typescript 还支持高级类型,也就是类型可以做各种运算,这种就需要传入类型参数求出具体的类型再去和 AST 对比。
我们来写代码实现一下:
代码实现
实现简单类型的类型检查
赋值语句的类型检查
比如这样一段代码,声明的值是一个 string,但是赋值为了 number,明显是有类型错误的,我们怎么检查出它的错误的。
let name: string;
name = 111;
首先我们使用 babel 把这段代码 parse 成 AST:
const parser = require('@babel/parser');
const sourceCode = `
let name: string;
name = 111;
`;
const ast = parser.parse(sourceCode, {
plugins: ['typescript']
});
使用 babel parser 来 parse,启用 typescript 语法插件。
可以使用 astexplerer.net 来查看它的 AST:
实现类型检查
我们需要检查的是这个赋值语句 AssignmentExpression,左右两边的类型是否匹配。
右边是一个数字字面量 NumericLiteral,很容易拿到类型,而左边则是一个引用,要从作用域中拿到它声明的类型,之后才能做类型对比。
babel 提供了 scope 的 api 可以用于查找作用域中的类型声明(binding),并且还可以通过 path.getTypeAnnotation 获得声明时的类型。
AssignmentExpression(path, state) {
const leftBinding = path.scope.getBinding(path.get('left'));
const leftType = leftBinding.path.get('id').getTypeAnnotation();// 左边的值声明的类型
}
这个返回的类型是 TSTypeAnnotation 的一个对象,我们需要做下处理,转为类型字符串,也就是 string、number 这种。
封装一个方法,传入类型对象,返回 number、string 等类型字符串
function resolveType(targetType) {
const tsTypeAnnotationMap = {
'TSStringKeyword': 'string'
}
switch (targetType.type) {
case 'TSTypeAnnotation':
return tsTypeAnnotationMap[targetType.typeAnnotation.type];
case 'NumberTypeAnnotation':
return 'number';
}
}
这样我们拿到了左右两边的类型,接下来就简单了,对比下就知道了类型是否匹配:
AssignmentExpression(path, state) {
const rightType = resolveType(path.get('right').getTypeAnnotation());
const leftBinding = path.scope.getBinding(path.get('left'));
const leftType = resolveType(leftBinding.path.get('id').getTypeAnnotation());
if (leftType !== rightType ) {
// error: 类型不匹配
}
}
错误打印优化
报错信息怎么打印呢?可以使用 @babel/code-frame,它支持打印某一片段的高亮代码。
path.get('right').buildCodeFrameError(`${rightType} can not assign to ${leftType}`, Error)
效果如下:
这个错误堆栈也太丑了,我们把它去掉,设置 Error.stackTraceLimit 为 0 就行了
Error.stackTraceLimit = 0;
path.get('right').buildCodeFrameError(`${rightType} can not assign to ${leftType}`, Error));
但是这里改了之后还要改回来,也就是:
const tmp = Error.stackTraceLimit;
Error.stackTraceLimit = 0;
console.log(path.get('right').buildCodeFrameError(`${rightType} can not assign to ${leftType}`, Error));
Error.stackTraceLimit = tmp;
再来跑一下:
好看多了!
错误收集
还有一个问题,现在是遇到类型错误就报错,但我们希望是在遇到类型错误时收集起来,最后统一报错。
怎么实现呢?错误放在哪?
babel 插件中可以拿到 file 对象,有 set 和 get 方法用来存取一些全局的信息。可以在插件调用前后,也就是 pre 和 post 阶段拿到 file 对象。
所以我们可以这样做:
pre(file) {
file.set('errors', []);
},
visitor: {
AssignmentExpression(path, state) {
const errors = state.file.get('errors');
const rightType = resolveType(path.get('right').getTypeAnnotation());
const leftBinding = path.scope.getBinding(path.get('left'));
const leftType = resolveType(leftBinding.path.get('id').getTypeAnnotation());
if (leftType !== rightType ) {
const tmp = Error.stackTraceLimit;
Error.stackTraceLimit = 0;
errors.push(path.get('right').buildCodeFrameError(`${rightType} can not assign to ${leftType}`, Error));
Error.stackTraceLimit = tmp;
}
}
},
post(file) {
console.log(file.get('errors'));
}
这样就可以做到过程中收集错误,最后统一打印:
这样,我们就实现了简单的赋值语句的类型检查。
函数调用的类型检查
赋值语句的检查比较简单,我们来进阶一下,实现函数调用参数的类型检查
function add(a: number, b: number): number{
return a + b;
}
add(1, '2');
这里我们要检查的就是函数调用语句 CallExpression 的参数和它声明的是否一致。
CallExpression 有 callee 和 arguments 两部分,我们需要根据 callee 从作用域中查找函数声明,然后再把 arguments 的类型和函数声明语句的 params 的类型进行逐一对比,这样就实现了函数调用参数的类型检查。
pre(file) {
file.set('errors', []);
},
visitor: {
CallExpression(path, state) {
const errors = state.file.get('errors');
// 调用参数的类型
const argumentsTypes = path.get('arguments').map(item => {
return resolveType(item.getTypeAnnotation());
});
const calleeName = path.get('callee').toString();
// 根据 callee 查找函数声明
const functionDeclarePath = path.scope.getBinding(calleeName).path;
// 拿到声明时参数的类型
const declareParamsTypes = functionDeclarePath.get('params').map(item => {
return resolveType(item.getTypeAnnotation());
})
argumentsTypes.forEach((item, index) => {
if (item !== declareParamsTypes[index]) {
// 类型不一致,报错
}
});
}
},
post(file) {
console.log(file.get('errors'));
}
运行一下,效果如下:
我们实现了函数调用参数的类型检查!实际上思路还是挺清晰的,检查别的 AST 也是类似的思路。
实现带泛型的类型检查
泛型是什么,其实就是类型参数,使得类型可以根据传入的参数动态确定,类型定义更加灵活。
比如这样一段代码:
function add<T>(a: T, b: T) {
return a + b;
}
add<number>(1, '2');
怎么做类型检查呢?
这还是函数调用语句的类型检查,我们上面实现过了,区别不过是多了个参数,那么我们取出类型参数来传过去就行了。
CallExpression(path, state) {
// 先拿到类型参数的值,也就是真实类型
const realTypes = path.node.typeParameters.params.map(item => {
return resolveType(item);
});
//实参的类型
const argumentsTypes = path.get('arguments').map(item => {
return resolveType(item.getTypeAnnotation());
});
const calleeName = path.get('callee').toString();
// 根据函数名查找函数声明
const functionDeclarePath = path.scope.getBinding(calleeName).path;
const realTypeMap = {};
// 把类型参数的值赋值给函数声明语句的泛型参数
functionDeclarePath.node.typeParameters.params.map((item, index) => {
realTypeMap[item.name] = realTypes[index];
});
const declareParamsTypes = functionDeclarePath.get('params').map(item => {
return resolveType(item.getTypeAnnotation(), realTypeMap);
})
// 做类型检查的时候取具体的类型来对比
argumentsTypes.forEach((item, index) => {
if (item !== declareParamsTypes[index]) {
// 报错,类型不一致
}
});
}
多了一步确定泛型参数的具体类型的过程。
执行看下效果:
我们成功支持了带泛型的函数调用语句的类型检查!
实现带高级类型的函数调用语句的类型检查
typescript 支持高级类型,也就是支持对类型参数做各种运算然后返回最终类型
type Res<Param> = Param extends 1 ? number : string;
function add<T>(a: T, b: T) {
return a + b;
}
add<Res<1>>(1, '2');
比如这段代码中,Res 就是一个高级类型,对传入的类型参数 Param 进行处理之后返回新类型。
这个函数调用语句的类型检查,比泛型参数传具体的类型又复杂了一些,需要先求出具体的类型,然后再传入参数,之后再去对比参数的类型。
那么这个 Res 的高级类型怎么求值呢?
我们来看一下这个 Res 类型的 AST:
它有类型参数部分(typeParameters),和具体的类型计算逻辑部分(typeAnnotation),右边的 Param extends 1 ? number : string;
是一个 condition 语句,有 Params 和 1 分别对应 checkType、extendsType,number 和 string 则分别对应 trueType、falseType。
我们只需要对传入的 Param 判断下是否是 1,就可以求出具体的类型是 trueType 还是 falseType。
具体类型传参的逻辑和上面一样,就不赘述了,我们看一下根据类型参数求值的逻辑:
function typeEval(node, params) {
let checkType;
// 如果参数是泛型,则从传入的参数取值
if(node.checkType.type === 'TSTypeReference') {
checkType = params[node.checkType.typeName.name];
} else {// 否则直接取字面量参数
checkType = resolveType(node.checkType);
}
const extendsType = resolveType(node.extendsType);
if (checkType === extendsType || checkType instanceof extendsType) { // 如果 extends 逻辑成立
return resolveType(node.trueType);
} else {
return resolveType(node.falseType);
}
}
这样,我们就可以求出这个 Res 的高级类型当传入 Params 为 1 时求出的最终类型。
有了最终类型之后,就和直接传入具体类型的函数调用的类型检查一样了。(上面我们实现过)
执行一下,效果如下:
完整代码如下(有些长,可以先跳过往后看):
const { declare } = require('@babel/helper-plugin-utils');
// 解析高级类型的值,传入泛型参数的值
function typeEval(node, params) {
let checkType;
if(node.checkType.type === 'TSTypeReference') {
checkType = params[node.checkType.typeName.name];
} else {
checkType = resolveType(node.checkType);
}
const extendsType = resolveType(node.extendsType);
// 如果 condition 表达式 的 check 部分为 true,则返回 trueType,否则返回 falseType
if (checkType === extendsType || checkType instanceof extendsType) {
return resolveType(node.trueType);
} else {
return resolveType(node.falseType);
}
}
function resolveType(targetType, referenceTypesMap = {}, scope) {
const tsTypeAnnotationMap = {
TSStringKeyword: 'string',
TSNumberKeyword: 'number'
}
switch (targetType.type) {
case 'TSTypeAnnotation':
if (targetType.typeAnnotation.type === 'TSTypeReference') {
return referenceTypesMap[targetType.typeAnnotation.typeName.name]
}
return tsTypeAnnotationMap[targetType.typeAnnotation.type];
case 'NumberTypeAnnotation':
return 'number';
case 'StringTypeAnnotation':
return 'string';
case 'TSNumberKeyword':
return 'number';
case 'TSTypeReference':
const typeAlias = scope.getData(targetType.typeName.name);
const paramTypes = targetType.typeParameters.params.map(item => {
return resolveType(item);
});
const params = typeAlias.paramNames.reduce((obj, name, index) => {
obj[name] = paramTypes[index];
return obj;
},{});
return typeEval(typeAlias.body, params);
case 'TSLiteralType':
return targetType.literal.value;
}
}
function noStackTraceWrapper(cb) {
const tmp = Error.stackTraceLimit;
Error.stackTraceLimit = 0;
cb && cb(Error);
Error.stackTraceLimit = tmp;
}
const noFuncAssignLint = declare((api, options, dirname) => {
api.assertVersion(7);
return {
pre(file) {
file.set('errors', []);
},
visitor: {
TSTypeAliasDeclaration(path) {
path.scope.setData(path.get('id').toString(), {
paramNames: path.node.typeParameters.params.map(item => {
return item.name;
}),
body: path.getTypeAnnotation()
});
path.scope.setData(path.get('params'))
},
CallExpression(path, state) {
const errors = state.file.get('errors');
// 泛型参数
const realTypes = path.node.typeParameters.params.map(item => {
return resolveType(item, {}, path.scope);
});
// 实参类型
const argumentsTypes = path.get('arguments').map(item => {
return resolveType(item.getTypeAnnotation());
});
const calleeName = path.get('callee').toString();
// 根据函数名查找到函数声明
const functionDeclarePath = path.scope.getBinding(calleeName).path;
const realTypeMap = {};
functionDeclarePath.node.typeParameters.params.map((item, index) => {
realTypeMap[item.name] = realTypes[index];
});
// 把泛型参数传递给具体的泛型
const declareParamsTypes = functionDeclarePath.get('params').map(item => {
return resolveType(item.getTypeAnnotation(), realTypeMap);
})
// 声明类型和具体的类型的对比(类型检查)
argumentsTypes.forEach((item, index) => {
if (item !== declareParamsTypes[index]) {
noStackTraceWrapper(Error => {
errors.push(path.get('arguments.' + index ).buildCodeFrameError(`${item} can not assign to ${declareParamsTypes[index]}`,Error));
});
}
});
}
},
post(file) {
console.log(file.get('errors'));
}
}
});
module.exports = noFuncAssignLint;
就这样,我们实现了 typescript 高级类型!
总结
类型代表了变量的内容和能对它进行的操作,静态类型让检查可以在编译期间做,随着前端项目越来越重,越来越需要 typescript 这类静态类型语言。
类型检查就是做 AST 的对比,判断声明的和实际的是否一致:
- 简单类型就直接对比,相当于 if else
- 带泛型的要先把类型参数传递过去才能确定类型,之后对比,相当于函数调用包裹 if else
- 带高级类型的泛型的类型检查,多了一个对类型求值的过程,相当于多级函数调用之后再判断 if else
实现一个完整的 typescript type cheker 还是很复杂的,不然 typescript checker 部分的代码也不至于好几万行了。但是思路其实没有那么难,按照我们文中的思路来,是可以实现一个完整的 type checker 的。
这一节主要是用到了 path.getTypeAnnotation 的 api 来获取声明的类型,然后进行 AST 的检查,希望能够帮助你理解 type checker 的实现原理。
(当然,文中只是实现了独立的一个个类型的检查,tsc 会递归地做多个文件的全文的类型检查,但是具体的每一部分都是类似的思路。)
(代码在这里,建议 git clone 下来通过 node 跑一下)