快捷搜索:  汽车  科技

babel 插件的功能(基于babel手写)

babel 插件的功能(基于babel手写)babel 也是一个转译器,可以把 es next、typescript、flow 等语法转成目标环境支持的 js。typescript compiler 是一个 转译器,负责把 typescript 的语法转成 es2015、es5、es3 的目标 javascript,并且过程中会做类型检查。市面上关于 typescript 的教程文很多了,但是没有一篇去从编译原理的角度分析它的实现的。本文不会讲 typescript 的基础,而是会实现一个 typescript type checker,帮你理解类型检查究竟做了什么。理解了类型检查的实现思路,再去学 typescript,或许就没那么难了。思路分析typescript compiler 与 babel

前言

typescript 给 javascript 扩展了类型的语法和语义,让我们可以给变量、函数等定义类型,然后编译期间检查,这样能够提前发现类型不匹配的错误,还能够在开发时提示可用的属性方法。

而且,typescript 并不像当年的 coffeescript 一样改变了语法,它是 javascript 的一个超集,只做了类型的扩展。

这些优点使得 typescript 迅速的火了起来。现在前端面试如果你不会 typescript,那么可能很难拿到 offer。

市面上关于 typescript 的教程文很多了,但是没有一篇去从编译原理的角度分析它的实现的。本文不会讲 typescript 的基础,而是会实现一个 typescript type checker,帮你理解类型检查究竟做了什么。理解了类型检查的实现思路,再去学 typescript,或许就没那么难了。

babel 插件的功能(基于babel手写)(1)

思路分析

typescript compiler 与 babel

typescript compiler 是一个 转译器,负责把 typescript 的语法转成 es2015、es5、es3 的目标 javascript,并且过程中会做类型检查。

babel 也是一个转译器,可以把 es next、typescript、flow 等语法转成目标环境支持的 js。

babel 也可以编译 typescript?对的,babel 7 以后就可以编译 typescript 代码,这还是 typescript 团队和 babel 团队合作一年的成果。

我们知道,babel 编译流程分为 3 个步骤:parse、transform、generate。

babel 插件的功能(基于babel手写)(2)

parse 阶段负责编译源码成 AST,transform 阶段对 AST 进行增删改,generate 阶段打印 AST 成目标代码并生成 sorucemap。

babel 可以编译 typescript 代码只是能够 parse,并不会做类型检查,我们完全可以基于 babel parse 出的 AST 来实现一下类型检查。

类型检查要做什么

我们经常用 tsc 来做类型检查,有没有想过,类型检查具体做了什么?

什么是类型

类型代表了变量存储的内容,也就是规定了这块内容占据多大的内存空间,可以对它做什么操作。比如 number 和 boolean 就会分配不同字节数的内存,Date 和 string 可以调用的方法也不同。这就是类型的作用。它代表了一种可能性,你可以在这块内存放多少内容,可能对它进行什么操作。

动态类型是指类型是在运行时确定的,而静态类型是指编译期间就知道了变量的类型信息,有了类型信息自然就知道了对它而言什么操作是合法的,什么操作是不合法的,什么变量能够赋值给他。

静态类型会在代码中保留类型信息,这个类型信息可能是显式声明的,也可能是自动推导出来的。想做一个大的项目,没有静态类型来约束和提前检查代码的话,太容易出 bug 了,会很难维护。这也是随着前端项目逐渐变得复杂,出现了 typescript 以及 typescript 越来越火的原因。

如何检查类型

我们知道了什么是类型,为什么要做静态的类型检查,那么怎么检查呢?

检查类型就是检查变量的内容,而理解代码的话需要把代码 parse 成 AST,所以类型检查也就变成了对 AST 结构的检查。

比如一个变量声明为了 number,那么给它赋值的是一个 string 就是有类型错误。

再复杂一点,如果类型有泛型,也就是有类型参数,那么需要传入具体的参数来确定类型,确定了类型之后再去和实际的 AST 对比。

typescript 还支持高级类型,也就是类型可以做各种运算,这种就需要传入类型参数求出具体的类型再去和 AST 对比。

我们来写代码实现一下:

代码实现

实现简单类型的类型检查

赋值语句的类型检查

比如这样一段代码,声明的值是一个 string,但是赋值为了 number,明显是有类型错误的,我们怎么检查出它的错误的。

letname:string; name=111;

首先我们使用 babel 把这段代码 parse 成 AST:

constparser=require('@babel/parser'); constsourceCode=` letname:string; name=111; `; constast=parser.parse(sourceCode { plugins:['typescript'] });

使用 babel parser 来 parse,启用 typescript 语法插件。

可以使用 astexplerer.net 来查看它的 AST:

babel 插件的功能(基于babel手写)(3)

实现类型检查

我们需要检查的是这个赋值语句 AssignmentExpression,左右两边的类型是否匹配。

右边是一个数字字面量 NumericLiteral,很容易拿到类型,而左边则是一个引用,要从作用域中拿到它声明的类型,之后才能做类型对比。

babel 提供了 scope 的 api 可以用于查找作用域中的类型声明(binding),并且还可以通过 getTypeAnnotation 获得声明时的类型

AssignmentExpression(path state){ constleftBinding=path.scope.getBinding(path.get('left')); constleftType=leftBinding.path.get('id').getTypeAnnotation();//左边的值声明的类型 }

这个返回的类型是 TSTypeAnnotation 的一个对象,我们需要做下处理,转为类型字符串

babel 插件的功能(基于babel手写)(4)

封装一个方法,传入类型对象,返回 number、string 等类型字符串

functionresolveType(targetType){ consttsTypeAnnotationMap={ 'TSStringKeyword':'string' } switch(targetType.type){ case'TSTypeAnnotation': returntsTypeAnnotationMap[targetType.typeAnnotation.type]; case'NumberTypeAnnotation': return'number'; } }

这样我们拿到了左右两边的类型,接下来就简单了,对比下就知道了类型是否匹配:

AssignmentExpression(path state){ constrightType=resolveType(path.get('right').getTypeAnnotation()); constleftBinding=path.scope.getBinding(path.get('left')); constleftType=resolveType(leftBinding.path.get('id').getTypeAnnotation()); if(leftType!==rightType){ //error:类型不匹配 } }

错误打印优化

报错信息怎么打印呢?可以使用 @babel/code-frame,它支持打印某一片段的高亮代码。

path.get('right').buildCodeFrameError(`${rightType}cannotassignto${leftType}` Error)

效果如下:

babel 插件的功能(基于babel手写)(5)

这个错误堆栈也太丑了,我们把它去掉,设置 Error.stackTraceLimit 为 0 就行了

Error.stackTraceLimit=0; path.get('right').buildCodeFrameError(`${rightType}cannotassignto${leftType}` Error));

但是这里改了之后还要改回来,也就是:

consttmp=Error.stackTraceLimit; Error.stackTraceLimit=0; console.log(path.get('right').buildCodeFrameError(`${rightType}cannotassignto${leftType}` Error)); Error.stackTraceLimit=tmp;

再来跑一下:

babel 插件的功能(基于babel手写)(6)

好看多了!

错误收集

还有一个问题,现在是遇到类型错误就报错,但我们希望是在遇到类型错误时收集起来,最后统一报错。

怎么实现呢?错误放在哪?

babel 插件中可以拿到 file 对象,有 set 和 get 方法用来存取一些全局的信息。可以在插件调用前后,也就是 pre 和 post 阶段拿到 file 对象(这些在掘金小册《babel 插件通关秘籍》中会细讲)。

所以我们可以这样做:

pre(file){ file.set('errors' []); } visitor:{ AssignmentExpression(path state){ consterrors=state.file.get('errors'); constrightType=resolveType(path.get('right').getTypeAnnotation()); constleftBinding=path.scope.getBinding(path.get('left')); constleftType=resolveType(leftBinding.path.get('id').getTypeAnnotation()); if(leftType!==rightType){ consttmp=Error.stackTraceLimit; Error.stackTraceLimit=0; errors.push(path.get('right').buildCodeFrameError(`${rightType}cannotassignto${leftType}` Error)); Error.stackTraceLimit=tmp; } } } post(file){ console.log(file.get('errors')); }

这样就可以做到过程中收集错误,最后统一打印:

babel 插件的功能(基于babel手写)(7)

这样,我们就实现了简单的赋值语句的类型检查。

函数调用的类型检查

赋值语句的检查比较简单,我们来进阶一下,实现函数调用参数的类型检查

functionadd(a:number b:number):number{ returna b; } add(1 '2');

这里我们要检查的就是函数调用语句 CallExpression 的参数和它声明的是否一致。

babel 插件的功能(基于babel手写)(8)

CallExpression 有 callee 和 arguments 两部分,我们需要根据 callee 从作用域中查找函数声明,然后再把 arguments 的类型和函数声明语句的 params 的类型进行逐一对比,这样就实现了函数调用参数的类型检查。

pre(file){ file.set('errors' []); } visitor:{ CallExpression(path state){ consterrors=state.file.get('errors'); //调用参数的类型 constargumentsTypes=path.get('arguments').map(item=>{ returnresolveType(item.getTypeAnnotation()); }); constcalleeName=path.get('callee').toString(); //根据callee查找函数声明 constfunctionDeclarePath=path.scope.getBinding(calleeName).path; //拿到声明时参数的类型 constdeclareParamsTypes=functionDeclarePath.get('params').map(item=>{ returnresolveType(item.getTypeAnnotation()); }) argumentsTypes.forEach((item index)=>{ if(item!==declareParamsTypes[index]){ //类型不一致,报错 } }); } } post(file){ console.log(file.get('errors')); }

运行一下,效果如下:

babel 插件的功能(基于babel手写)(9)

我们实现了函数调用参数的类型检查!实际上思路还是挺清晰的,检查别的 AST 也是类似的思路。

实现带泛型的类型检查

泛型是什么,其实就是类型参数,使得类型可以根据传入的参数动态确定,类型定义更加灵活。

比如这样一段代码:

functionadd<T>(a:T b:T){ returna b; } add<number>(1 '2');

怎么做类型检查呢?

这还是函数调用语句的类型检查,我们上面实现过了,区别不过是多了个参数,那么我们取出类型参数来传过去就行了。

CallExpression(path state){ constrealTypes=path.node.typeParameters.Params.map(item=>{//先拿到类型参数的值,也就是真实类型 returnresolveType(item); }); constargumentsTypes=path.get('arguments').map(item=>{ returnresolveType(item.getTypeAnnotation()); }); constcalleeName=path.get('callee').toString(); constfunctionDeclarePath=path.scope.getBinding(calleeName).path; constrealTypeMap={}; functionDeclarePath.node.typeParameters.params.map((item index)=>{ realTypeMap[item.name]=realTypes[index]; }); constdeclareParamsTypes=functionDeclarePath.get('params').map(item=>{ returnresolveType(item.getTypeAnnotation() realTypeMap); })//把类型参数的值赋值给函数声明语句的泛型参数 argumentsTypes.forEach((item index)=>{//做类型检查的时候取具体的类型来对比 if(item!==declareParamsTypes[index]){ //报错,类型不一致 } }); }

多了一步确定泛型参数的具体类型的过程。

执行看下效果:

babel 插件的功能(基于babel手写)(10)

我们成功支持了带泛型的函数调用语句的类型检查!

实现带高级类型的函数调用语句的类型检查

typescript 支持高级类型,也就是支持对类型参数做各种运算然后返回最终类型

typeRes<Param>=Paramextends1?number:string; functionadd<T>(a:T b:T){ returna b; } add<Res<1>>(1 '2');

比如这段代码中,Res 就是一个高级类型,对传入的类型参数 Param 进行处理之后返回新类型。

这个函数调用语句的类型检查,比泛型参数传具体的类型又复杂了一些,需要先求出具体的类型,然后再传入参数,之后再去对比参数的类型。

那么这个 Res 的高级类型怎么求值呢?

我们来看一下这个 Res 类型的 AST:

babel 插件的功能(基于babel手写)(11)

它有类型参数部分(typeParameters),和具体的类型计算逻辑部分(typeAnnotation),右边的 Param extends 1 ? number : string; 是一个 condition 语句,有 Params 和 1 分别对应 checkType、extendsType,number 和 string 则分别对应 trueType、falseType。

我们只需要对传入的 Param 判断下是否是 1,就可以求出具体的类型是 trueType 还是 falseType。

具体类型传参的逻辑和上面一样,就不赘述了,我们看一下根据类型参数来值的逻辑:

functiontypeEval(node params){ letcheckType; if(node.checkType.type==='TSTypeReference'){ checkType=params[node.checkType.typeName.name];//如果参数是泛型,则从传入的参数取值 }else{ checkType=resolveType(node.checkType);//否则直接取字面量参数 } constextendsType=resolveType(node.extendsType); if(checkType===extendsType||checkTypeinstanceofextendsType){//如果extends逻辑成立 returnresolveType(node.trueType); }else{ returnresolveType(node.falseType); } }

这样,我们就可以求出这个 Res 的高级类型当传入 Params 为 1 时求出的最终类型。

有了最终类型之后,就和直接传入具体类型的函数调用的类型检查一样了。(上面我们实现过)

执行一下,效果如下:

babel 插件的功能(基于babel手写)(12)

完整代码如下(有些长,可以先跳过往后看):

const{declare}=require('@babel/helper-plugin-utils'); functiontypeEval(node params){ letcheckType; if(node.checkType.type==='TSTypeReference'){ checkType=params[node.checkType.typeName.name]; }else{ checkType=resolveType(node.checkType); } constextendsType=resolveType(node.extendsType); if(checkType===extendsType||checkTypeinstanceofextendsType){ returnresolveType(node.trueType); }else{ returnresolveType(node.falseType); } } functionresolveType(targetType referenceTypesMap={} scope){ consttsTypeAnnotationMap={ TSStringKeyword:'string' TSNumberKeyword:'number' } switch(targetType.type){ case'TSTypeAnnotation': if(targetType.typeAnnotation.type==='TSTypeReference'){ returnreferenceTypesMap[targetType.typeAnnotation.typeName.name] } returntsTypeAnnotationMap[targetType.typeAnnotation.type]; case'NumberTypeAnnotation': return'number'; case'StringTypeAnnotation': return'string'; case'TSNumberKeyword': return'number'; case'TSTypeReference': consttypeAlias=scope.getData(targetType.typeName.name); constparamTypes=targetType.typeParameters.params.map(item=>{ returnresolveType(item); }); constparams=typeAlias.paramNames.reduce((obj name index)=>{ obj[name]=paramTypes[index]; returnobj; } {}); returntypeEval(typeAlias.body params); case'TSLiteralType': returntargetType.literal.value; } } functionnoStackTraceWrapper(cb){ consttmp=Error.stackTraceLimit; Error.stackTraceLimit=0; cb&&cb(Error); Error.stackTraceLimit=tmp; } constnoFuncAssignLint=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=>{ returnitem.name; }) body:path.getTypeAnnotation() }); path.scope.setData(path.get('params')) } CallExpression(path state){ consterrors=state.file.get('errors'); constrealTypes=path.node.typeParameters.params.map(item=>{ returnresolveType(item {} path.scope); }); constargumentsTypes=path.get('arguments').map(item=>{ returnresolveType(item.getTypeAnnotation()); }); constcalleeName=path.get('callee').toString(); constfunctionDeclarePath=path.scope.getBinding(calleeName).path; constrealTypeMap={}; functionDeclarePath.node.typeParameters.params.map((item index)=>{ realTypeMap[item.name]=realTypes[index]; }); constdeclareParamsTypes=functionDeclarePath.get('params').map(item=>{ returnresolveType(item.getTypeAnnotation() realTypeMap); }) argumentsTypes.forEach((item index)=>{ if(item!==declareParamsTypes[index]){ noStackTraceWrapper(Error=>{ errors.push(path.get('arguments.' index).buildCodeFrameError(`${item}cannotassignto${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 的。

(关于 babel 插件和 api 的部分,如果看不懂,可以在我即将上线的小册《babel 插件通关秘籍》中来详细了解。掌握了 babel,也就掌握了静态分析的能力,linter、type checker 这些顺带也能更深入的掌握。)

猜您喜欢: