通过一个 babel 插件让你彻底了解 TypeScript 到底是怎么做类型检查

一、前言

我们一般编译 TypeScript 有两种方式,一个是使用 Typescript 自带的编译器,简称 tsc,一个是使用 babel 提供的 @babel/preset-typescript 插件。

1、TypeScript Compiler

tsc 使用 Typescript 自带的编译器,他能对 TypeScript 文件进行类型检查、编译并生成对应的 d.ts 文件,比如我们运行 tsc index.ts 命令对 index.ts 文件进行编译:

// index.ts

// 编译前
type S = '1' | '2';

type functionType = (a: string, b: number) => number

const str: S = '1';

const fun: functionType = (a: string, b: number) => {
    return b;
}

// 编译后
var str = '1';
var fun = function (a, b) {
    return b;
};

复制代码

如果我们想生成对应的 .d.ts 文件,我们可以在命令中加一个参数 --declaration,在运行一下就会生成相应的类型文件:

declare type S = '1' | '2';
declare type functionType = (a: string, b: number) => number;
declare const str: S;
declare const fun: functionType;
复制代码

tsc 还有许多其他的配置,具体可以查看这里

2、babel

使用 babel 提供的 @babel/preset-typescript 插件也可以实现对 TypeScript 的编译,这里可以参考我上一篇文章还不知道 babel 配置的看这里,但是使用 babel 也有他的局限性,babel 只能帮我们去编译文件,并不能帮我们做类型检查,也就不能生成对应的类型声明文件。同时 babel 在语法上也存在一些缺点,比如不支持 const enumnamespace 合并,在开启 isTSX 配置后不支持尖括号的断言方式等。

上面主要介绍了编译 TypeScript 的两种方式,当然这并不是这篇文章的主题,既然 babel 不支持类型检查,我们就实现一个插件去做类型检查并顺带了解 TypeScript 是怎么做类型检查的。

二、AST 篇

babel 编译的关键一步就是将目标代码转换成 AST ,然后对 AST 进行增删改,那么下面我们来了解下 AST 的基本结构以及一些常用的 API。我们看个简单的例子:

let name: string;

name = 'xiling'
复制代码

我们可以使用 astexplorer.net 在线观看转换后的 AST。

image.png 这里是 name 对应的字段以及类型声明。

image.png 下面是对 name 的赋值操作以及值的类型,我们可以再去定义 function、class,现在我们能知道 AST 的大致结构如下:

{
    VariableDeclaration:{
        type:xxx,
        start:xxx,
        end:xxx,
        ...,
    },
    FunctionDeclaration:{
        type:xxx,
        start:xxx,
        end:xxx,
        id:xxx,
        params:xxx,
        ....
    },
    ClassDeclaration:{},
    ExpressionStatement:{}
}
复制代码

每条指令都会有与之对应的 type,这个 type 里包含了该指令的所有信息。那 babel 是怎么去遍历这棵树的呢?其实也很简单 babel 使用的是递归的方式去遍历,当他进入一个 type 后就会访问这个 type 伤的所有属性,如果这个属性也是一个 type 那么就会去访问他的所有属性

1、Visitors

上面介绍了 babel 是怎么去遍历 AST 的,现在我有个想法,当 babel 遍历到特定 type 的时候我想去获取这个 type 上对应的信息,我要该怎么去设计呢?此时 Visitors 就派上用场了,Visitors 说白了就是一个对象,在这个对象上我们可以配置我们想要监听的 type,比如我想监听 type 为 Identifier 的节点的信息(这里要注意了,我所说的节点不单单是最顶层的节点,比如FunctionDeclaration、ExpressionStatement,还有这些对象里面的属性节点,比如Identifier、VariableDeclarator),我可以这样配置 Visitors:

const Visitor = {
  Identifier(path, state) {
    console.log("xxx");
  },
  ...,
};
复制代码

Visitors 具体在哪里会使用稍后会具体介绍,下面我们通过一个例子来说明:

function fun(a: string, b: string){
	return a + b;
}
复制代码

我们就写一个函数,我们看看 babel 是怎么访问这个函数上的 type:

- FunctionDeclaration
  - Identifier (id)
  - Identifier (params[2])
  - BlockStatement (body)
    - ReturnStatement (body)
      - BinaryExpression (argument)
        - Identifier (left)
        - Identifier (right)
复制代码

此时如果我们监听了 Identifier,那么就能拿到对应的函数名、参数、返回值。

2、Path & State & scope

我们在 Visitor 中配置的监听器会拿到两个参数,一个是 path,一个是 state,这两个参数分别代表什么呢?

Path Path 简单来说就是两个节点之间的连接对象,我们可以试想一下一般我们会怎么样去维护一棵树呢?

image.png

节点之间都是通过 childparent 连接起来的,那么换到我们的 AST,他们就是通过 Path 连接起来的。

image.png

我们简单看下 Path 对象的结构:

path {
    node,
    parent, 
    parentPath, 
    scope,
   getSibling(key),
   find(callback),
   findParent(callback),
   get(key),
   set(key, node)
   ....,
}
复制代码

拿上面函数的例子来说,假如我们在 Visitors 中配置了 FunctionDeclaration ,那么当我们遍历到这个节点时,此时 Path 就保存了这个节点的信息并且还提供了一些方法可以获取/更改节点的数据,比如通过 path.get('params') 就能拿到函数参数的信息,当我们配置了 Identifier 时我们还可以通过 path.node.name 去获取函数名以及返回值的name。

State state 的作用就是用来传递数据,假如我想将当前节点的一些信息传递给下一个节点,那么我们就可以直接保存在 state 上,state 中有两个关键属性要知道一下,一个是 file,它里面有一些文件级别的信息,还是上面那个函数的例子,我们可以看下 file 的结构:

file: <ref *1> File {
    _map: Map(1) { 'errors' => [] },
    opts: {
      assumptions: {},
      ...,
      envName: 'development',
      cwd: '/Users/chenxuejin/Documents/MyDocument/react17',
      root: '/Users/chenxuejin/Documents/MyDocument/react17',
      rootMode: 'root',
      plugins: [Array],
      presets: [],
      ...,
    },
    declarations: {},
    path: NodePath {
      contexts: [Array],
      state: undefined,
      opts: [Object],
      _traverseFlags: 0,
      skipKeys: null,
      parentPath: null,
      container: [Object],
      listKey: undefined,
      key: 'program',
      node: [Object],
      type: 'Program',
      parent: [Object],
      hub: [Object],
      data: null,
      context: [TraversalContext],
      scope: [Scope]
    },
    ast: {
      type: 'File',
      ...,
    },
    scope: Scope {
      uid: 0,
      path: [NodePath],
      ...,
    },
    metadata: {},
    code: '\nfunction fun(a: string, b: string){\n\treturn a + b;\n}\n',
    inputMap: null,
    hub: {
     ...,
    }
  }
复制代码

另一个就是 opts 属性,它保存了这个 babel 插件的一些配置。

scope

scope 顾名思义就是保存作用域的信息,在 JavaScript 中我们分为全局作用域、函数作用域、块级作用域,作用域之间就是用过作用域连连接起来的,当我们查找一个变量时,先从当前作用域开始...八股文开始了,当然 babel 在生成 AST 时也需要去保存生成串联作用域,生成的作用域就保存在 scope字段中。scope 的大体结果如下:

scope { 
    bindings,// 当前作用域中的所有变量
    parent,// 父作用域
    path,
    getAllBindings(),// 获取所有的作用域
    getBinding(name),// 获取某个变量所在的作用域
    removeBinding(name) //删除某个变量
    ...,
}
复制代码

三、插件篇

在开发插件之前我们首先要了解插件的基本格式以及传入的参数,了解过 webpack 插件的同学可能知道 webpack 插件也是有他的开发格式的,如下所示:

class HelloPlugin{
  // 在构造函数中获取用户给该插件传入的配置
  constructor(options){
  }
  // Webpack 会调用 HelloPlugin 实例的 apply 方法给插件实例传入 compiler 对象
  apply(compiler) {
    // 在emit阶段插入钩子函数,用于特定时机处理额外的逻辑;
    compiler.hooks.emit.tap('HelloPlugin', (compilation) => {
      // 在功能流程完成后可以调用 webpack 提供的回调函数;
    });
    // 如果事件是异步的,会带两个参数,第二个参数为回调函数,在插件处理完任务时需要调用回调函数通知webpack,才会进入下一个处理流程。
    compiler.plugin('emit',function(compilation, callback) {
      // 支持处理逻辑
      // 处理完毕后执行 callback 以通知 Webpack 
      // 如果不执行 callback,运行流程将会一直卡在这不往下执行 
      callback();
    });
  }
}
复制代码

webpack 的 plugin 其实就是一个类,这个类需要一个 apply 方法,webpack 在执行这个插件时会调用这个方法并将 compiler 传给这个方法,相应的我们 babel plugin 的基本格式如下:

// api 包含了 babel 里面的所有 api,比如TSUnionType、TSTypeReference、ClassAccessorProperty等
// options 就是这个插件的配置
function(api, options, dirname) { 
    return { 
        inherits: xxxplugin, 继承某个插件
        manipulateOptions(options, parserOptions) { 
            // 用于修改 options
        }, 
        pre(file) { 
            // 在遍历前调用
        }, 
        // 创建Visitors,配置需要监听的 type
        visitor: { 
            StringLiteral(path, state) { 
                this.cache.set(path.node.value, 1); 
            },
            CallExpression(path, state){},
            FunctionDeclaration(path, state){},
            ...,
            
        }, 
        post(file) { 
            // 遍历后调用
        } 
    }; 
}
复制代码

四、类型检查插件开发

在开发类型检查插件之前我们先大概想想我们需要怎么去做,比如下面这个例子:

let s: string;

s = 1;
复制代码

很明显这是不行的,我们要做类型检查的话首先应该找到 s 被赋值的数据的类型 typeA,然后再去找到 s 的类型定义 typeB,如果 typeA 不包含在 typeB 中那么就需要提示错误,其实思想还是挺急简单的,重要的是逻辑部分,下面我们就实现一个插件对上面代码进行检查。

首先新建两个文件 index.jsplugin.jsindex.js 主要是放我们编译的主干逻辑,plugin.js就是插件的代码了。

// index.js
const { transformFromAstSync } = require('@babel/core');
const  parser = require('@babel/parser');
const typeCheckerPlugin = require('./plugin');

const sourceCode = `
let s: string;

s = 1;
`;
// 生成 ast 抽象语法书
const ast = parser.parse(sourceCode, {
    sourceType: 'module', // 指定所有的文件都为模块
    plugins: ['typescript']
})
// 遍历 AST 并注册插件
transformFromAstSync(ast, sourceCode, {
    plugins: [typeCheckerPlugin],
});
复制代码

下面来介绍 plugin.js

// plugin.js
const {declare} = require('@babel/helper-plugin-utils')
// 定义 TypeScript 数据类型和普通数据类型的映射
const tsTypeAnnotationMap = new Map([
    ['TSStringKeyword', 'string'],
    ['TSNumberKeyword', 'number'],
    ['TSBooleanKeyword', 'boolean'],
    ['TSUndefinedKeyword', 'undefined'],
    ['TSNullKeyword', 'null'],
    ['TSAnyKeyword', 'any']
])
// 定义类型转换函数,处理 TypeScript 类型和普通数据类型
function transformType(targetType) {
    switch (targetType.type) {
        case 'TSTypeAnnotation': //如果 type 为 TSTypeAnnotation 表示这是 typeScript 类型
            return tsTypeAnnotationMap.get(targetType.typeAnnotation.type);
        case 'NumberTypeAnnotation': 
            return 'number';
        case 'StringTypeAnnotation':
            return 'string';
        case 'BooleanTypeAnnotation':
            return 'boolean'
        case 'VoidTypeAnnotation':
            return 'undefined'
        case 'NullLiteralTypeAnnotation':
            return 'null'
        default:
            return '';
    }
}

const typeCheckerPlugin = declare((api, options, dirname) => {
    return {
        pre(file) {
            // 定义一个数组用来收集错误
            file.set('errors', []);
        },
        visitor: {
            AssignmentExpression(path, state) {
                // 获取错误数组
                const errors = state.file.get('errors');
                // 查找变量 s 所在的作用域
                const leftBinding = path.scope.getBinding(path.get('left'));
                // 获取作用域所在的 path
                const leftBindingPath = leftBinding.path
                // 获取变量 s 的类型
                const leftType = transformType(leftBindingPath.get('id').getTypeAnnotation());
                // 获取数值 1 的类型
                const rightType = transformType(path.get('right').getTypeAnnotation());
                console.log(path.get('right').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'));
        }
    }
})

module.exports = typeCheckerPlugin;

复制代码

我们执行 node index.js 运行看下:

image.png 这样我们就完成了插件的开发,当然上面只是一个简单的例子,下面我们还可以再去把范型加进去,我们修改下 index.jsplugin.js.

// index.js
const { transformFromAstSync } = require('@babel/core');
const  parser = require('@babel/parser');
const typeCheckerPlugin = require('./plugin');

const sourceCode = `
function fun<T>(a: T, b: T) {
    return a + b;
}
fun<number>('1', 2)
`;

const ast = parser.parse(sourceCode, {
    sourceType: 'module',
    plugins: ['typescript']
})

transformFromAstSync(ast, sourceCode, {
    plugins: [typeCheckerPlugin],
});
复制代码

我们先看下对应的 AST:

image.png

// plugin.js
const {declare} = require('@babel/helper-plugin-utils')

const tsTypeAnnotationMap = new Map([
    ['TSStringKeyword', 'string'],
    ['TSNumberKeyword', 'number'],
    ['TSBooleanKeyword', 'boolean'],
    ['TSUndefinedKeyword', 'undefined'],
    ['TSNullKeyword', 'null'],
    ['TSAnyKeyword', 'any']
])
function transformType(targetType, targetMap = {}) {
    switch (targetType.type) {
        case 'TSTypeAnnotation':
            if (targetType.typeAnnotation.type === 'TSTypeReference') {
                return targetMap[targetType.typeAnnotation.typeName.name]
            }
            return tsTypeAnnotationMap[targetType.typeAnnotation.type];
        case 'NumberTypeAnnotation': 
            return 'number';
        case 'StringTypeAnnotation':
            return 'string';
        case 'TSStringKeyword':
            return 'string';
        case 'BooleanTypeAnnotation':
            return 'boolean'
        case 'VoidTypeAnnotation':
            return 'undefined'
        case 'NullLiteralTypeAnnotation':
            return 'null'
        case 'TSNumberKeyword':
            return 'number';
        default:
            return '';
    }
}
const typeCheckerPlugin = declare((api, options, dirname) => {
    return {
        pre(file) {
            file.set('errors', []);
        },
        visitor: {
            CallExpression(path, state) {
                const errors = state.file.get('errors');
                // 获取fun传入的范型
                const paradigmTypeArr = path.node.typeParameters.params.map(item => transformType(item));

                // 获取调用fun时传入的参数的类型
                const paramsTypeArr = path.get('arguments').map(item => transformType(item.getTypeAnnotation()));
               
                // 获取fun的作用域
                const funBinding = path.scope.getBinding(path.get('callee'));

                // 获取fun作用域所在的path
                const funcPath = funBinding.path;

                // 将传入的范型与fun类型定义中的范型占位符一一对应,即 T = number
                const paradigmMap = {};
                funcPath.node.typeParameters.params.forEach((item, index) => {
                    paradigmMap[item.name] = paradigmTypeArr[index];
                });

                // 根据paradigmMap解析出函数每一个参数的类型
                const funParamsTypeArr = funcPath.get('params').map(item => {
                    return transformType(item.getTypeAnnotation(), paradigmMap);
                })

                paramsTypeArr.forEach((item, index) => {
                    if (item !== funParamsTypeArr[index]) {
                        const tmp = Error.stackTraceLimit;
                        Error.stackTraceLimit = 0;
                        errors.push(path.get('arguments.' + index ).buildCodeFrameError(`${item} can not assign to ${funParamsTypeArr[index]}`,Error));
                        Error.stackTraceLimit = tmp;
                    }
                });
            }
        },
        post(file) {
            console.log(file.get('errors'));
        }
    }
})

module.exports = typeCheckerPlugin;
复制代码

运行后成功看到报错信息: image.png

总结

其实真正要做类型检查插件还有许多情况要考虑,有兴趣的可以参考 TypeScript 源码,在这篇文章中我们讲了 TypeScript 的编译器、AST 的一些重要属性和 API、babel 插件机制,希望对大家有所帮助。

猜你喜欢

转载自juejin.im/post/7085227151275851784