babel 插件和 eslint 插件对比

babel 和 eslint 都是我们项目中常用的工具,两者都是基于 AST 去扩展的,前者做代码的转换,后者做错误检查和修复。两者都能够做到分析和转换代码。所以两者有啥不同呢?

babel 插件

babel 的编译流程分为parse → transform → generate三步,可以指定插件,在遍历 AST 的时候调用 visitor,对某些节点做处理

babel 插件示例—no-function-assign-plugin

在之前的 babel 插件中,有讲过这个示例

实现思路是: 根据赋值语句去查找作用域,左边的引用是否是一个函数

  • 当我们处理赋值语句AssignmentExpression,判断 left 的引用是否是一个函数
  • 使用path.scope.getBinding,从作用域中查找binding
  • 获取到binding是否为FunctionDeclaration/FunctionExpression
module.exports = function ({}, options) {
  return {
    pre(file) {
      file.set('errors', []);
    },
    visitor: {
      AssignmentExpression(path, state) {
        const errors = state.file.get('errors');
        const assignTarget = path.get('left').toString();
        const binding = path.scope.getBinding(assignTarget);
        if (binding) {
          if (
            binding.path.isFunctionDeclaration() ||
            binding.path.isFunctionExpression()
          ) {
            const tmp = Error.stackTraceLimit;
            Error.stackTraceLimit = 0;
            errors.push(path.buildCodeFrameError('不能重复给函数赋值', Error));
            Error.stackTraceLimit = tmp;
          }
        }
      },
    },
    post(file) {
      console.log(file.get('errors'));
    },
  };
};

插件特点

  • 插件返回一个对象,vistor 属性中声明对节点的处理
  • visitor 函数可以通过 path 来对 ast 做增删改查
  • 修改之后的 ast 通过@babel/generator能够生成目标代码

eslint 插件

eslint 完全插件化的,每一个规则都是一个插件,在项目中可以配置多个规则。规则列表

eslint 也是 AST 的应用,也需要通过 parser 将源码转为 AST。Eslint 默认使用的是 espree,也可以在配置文件中配置不同的 parser

eslint 和 babel 都会使用 parser 来做转译源码,其实它们的 parser 都是基于 estree 标准实现和扩充的

parser

acorn 的实现是基于插件的,所以 espree / babel parser 也能够通过插件来扩充

eslint 插件示例(no-function-assign)

eslint 的 rule 实现包括两部分:

  • meta: 信息/文档/报错信息等
  • create: 返回一个对象,其中包含一些 Eslint 遍历 AST 时,访问节点的方法

创建 eslint 插件

基于 Yeoman generator 快速创建 Eslint Plugin 项目

npm i -g yo
npm i -g generator-eslint
// 创建一个plugin
yo eslint:plugin
// 创建一个规则
yo eslint:rule

rule

lib/rules 中存储的是当前插件的所有 rule,tests/lib/rules 中存储的是对应的测试文件 package.json 中 name 属性就是当前插件的名字全部以 eslint-plugin-xxx 来命名

no-function-assign 实现

上述的 babel 插件,是找到赋值语句再去判断需要赋值的引用是否为函数声明或者函数表达式

那其实 eslint 也是可以采用相同的思路去实现的,找到找到赋值语句再去判断需要赋值的引用是为函数,那在这其中我们需要用到context.getScope这个 API 去获取作用域里面的信息

module.exports = {
  create(context) {
    function checkIdentifierIsFunction(scope, leftName) {
      const allVariables = scope.variables;
      for (const variable of allVariables) {
        const defs = variable.defs;
        for (const def of defs) {
          if (def.name.name === leftName && def.type === 'FunctionName')
            return true;
          if (def.name.name === leftName) return false;
        }
      }
      if (scope.upper) {
        return checkIdentifierIsFunction(scope.upper, leftName);
      }
      return false;
    }
    return {
      AssignmentExpression: (node) => {
        const left = node.left;
        const scope = context.getScope();
        if (checkIdentifierIsFunction(scope, left.name)) {
          context.report({
            node: node,
            message: `${left} is a function`,
          });
        }
      },
    };
  },
};

但是上述代码随着 ES6 结构语法的出现,就不能够实现我们的效果,[foo] = bar; function foo(){},对于这个代码来说,赋值语句的左边是[foo],也就是一个 ArrayPattern 节点,根本不会存在 name 值,所以checkIdentifierIsFunction的返回将一直是 false,所以根本不会报错(上面 babel 实现的插件也会有这个问题)。

或许你会问,是不是可以再多加一层的判断对 ArrayPattern 节点做一个特殊处理,比如下面这样

return {
  AssignmentExpression: (node) => {
    const left = node.left;
    const scope = context.getScope();
    if (!left.name) {
      if (left.type === 'ArrayPattern') {
        const elements = left.elements;
        for (const element of elements) {
          if (checkIdentifierIsFunction(scope, element.name)) {
            context.report({
              node: node,
              message: `${element.name} is a function`,
            });
          }
        }
        return;
      }
    }
    if (checkIdentifierIsFunction(scope, left.name)) {
      context.report({
        node: node,
        message: `${left} is a function`,
      });
    }
  },
};

虽然这样能够兼容 ArrayPattern,奈何我们 ES6 还提供了对象的结构,那总不能针对于每一个情况都去做特殊处理吧? 为了兼容所有的情况,Eslint 目前的解决思路是,找到对应的函数声明或者函数表达式,再去获得作用域中当前节点引用是否为一个赋值语句

module.exports = {
  meta: {
    type: null, // `problem`, `suggestion`, or `layout`
    docs: {
      description: '函数不能重新赋值',
      category: 'Fill me in',
      recommended: false,
      url: null, // URL to the documentation page for this rule
    },
    fixable: null, // Or `code` or `whitespace`
    schema: [], // Add a schema if the rule has options
    messages: {
      isAFunction: "'{{name}}' 是一个函数,不能够重新赋值",
    },
  },

  create(context) {
    function checkForFunction(node) {
      context.getDeclaredVariables(node).forEach(checkVariable);
    }

    function checkVariable(variable) {
      if (variable.defs[0].type === 'FunctionName') {
        checkReference(variable.references);
      }
    }

    function checkReference(references) {
      // 如果是赋值语句这种
      getModifyingReferences(references).forEach((reference) => {
        context.report({
          node: reference.identifier,
          messageId: 'isAFunction',
          data: {
            name: reference.identifier.name,
          },
        });
      });
    }

    return {
      FunctionDeclaration: checkForFunction,
      FunctionExpression: checkForFunction,
    };
  },
};

通过context.getDeclaredVariables拿到当前 node 的 variable,再去遍历每一个 variable 的引用中是否有函数

项目中引入插件

由于是本地开发,所以采用npm link的方式,使用我们创建的 plugins

link 在我们的项目中引入该插件 npm link eslint-plugin-demo,创建对应的软链接

demo

配置如下的 .eslintrc.js 文件,编写对应的代码,执行一下 eslint

module.exports = {
  plugins: ['eslint-plugin-demo'],
  rules: {
    //0或off(关闭) 1或warn 2或error
    'demo/no-function-assign': 2,
  },
};

lint

eslint 插件示例(block-one-line)

在该示例中,会对大括号的位置进行校验,并且开始自动修复模式,能够自动修复报错。

line 我们需要处理块级语句,也就是BlockStatement,对于该节点来说,希望它和判断条件在一行并且中间保持一个空格。

⚠️ 我们之所以在 Eslint 中可以做格式校验,是因为我们可以通过 context 提供的getSourceCodeAPI 获得 AST 以及相关的 tokens

sourceCode token

token 中包含了每一个单词的位置信息,sourceCode 提供了通过 node 获取对应 token

//块级node开始的token,也就是红色部分
const firstToken = sourceCode.getFirstToken(node);
//块级node前一个token,也就是绿色部分
const beforeFirstToken = sourceCode.getTokenBefore(node);

当我们拿到两个对应 token 之后,将其位置(line/range)做一个判断,就能完成我们的校验

实现自动修复功能,只需要在context.report中定义fix函数,而fix函数也是调用 eslint 的fixer,我们采用replaceTextRange,将[beforeFirstToken.range[1], firstToken.range[0]]中间的内容替换为空格即可

module.exports = {
  create(context) {
    const sourceCode = context.getSourceCode();
    return {
      BlockStatement(node) {
        //块级node开始的token
        const firstToken = sourceCode.getFirstToken(node);
        //块级node前一个token
        const beforeFirstToken = sourceCode.getTokenBefore(node);
        if (firstToken.loc.start.line !== beforeFirstToken.loc.start.line) {
          return context.report({
            node,
            loc: firstToken.loc,
            message: '大括号不需要换行',
            fix: (fixer) => {
              return fixer.replaceTextRange(
                [beforeFirstToken.range[1], firstToken.range[0]],
                ' ',
              );
            },
          });
        }
        if (firstToken.range[0] - 1 !== beforeFirstToken.range[1]) {
          return context.report({
            node,
            loc: firstToken.loc,
            message: '大括号前需要空格',
            fix: (fixer) => {
              return fixer.replaceTextRange(
                [beforeFirstToken.range[1], firstToken.range[0]],
                ' ',
              );
            },
          });
        }
      },
    };
  },
};

eslint 实现原理

原理

  • 读取配置时,可以利用层叠配置。层叠配置能够让检测的文件最近的 eslintrc 文件的优先级最高

    your-project
    ├── .eslintrc
    ├── lib
    │ └── source.js     // 使用根目录下的eslintrc
    └─┬ tests
      ├── .eslintrc
      └── test.js       // 使用根目录下和tests/eslintrc的组合,且tests/eslintrc中的配置优先级更高

    当项目中 package.json 中有 eslintConfig 配置时,可用整个项目,但是根目录 eslintrc 优先级更高 关于更多读取配置信息,点击查看

  • 加载配置,对于 extends 来说,支持我们使用插件中的配置,所以会递归扩展配置,并且前面的配置项优先级会高于 extends 的 关于更多加载配置信息, 点击查看

eslint 插件的特点

  • 一个 plugin 是由 一个或者多个 rule 组成的
  • 每一个 rule 都是一个对象,其中包含 meta 一些元信息,create 函数返回一个对象,定义对应的访问节点
  • 对于定义的节点可以通过 context 来获取到源码中 tokens 等,来进行格式检查
  • 通过在context.report中定义 fix 函数,使用 fixer 来对某个位置的代码进行字符的增删改,可以通过 eslint 配置开启是否自动修复功能

为什么 Eslint 能够做格式检查

Eslint 之所以能够做错误检查,它的 AST 记录了源代码所有的 token,token 中有行列号信息,而且 AST 中也保存了 range,也就是当前节点的开始结束位置。并且还提供了 SourceCode 的 api 可以根据 range 去查询 token。这是它能实现格式检查的原因。

而 Babel 其实也支持 range 和 token,但是却没有提供根据 range 查询 token 的 api,这是它不能做格式检查的原因。

总结

Babel 和 Eslint 原理是差不多的,先将源代码 parse,在提供对应的访问节点。但是 Eslint 是被设计来做代码错误和格式检查与修复的,而 Babel 是被设计用来做代码分析和转换的,所以也就提供了不同的 api,支持做不同的事情

对比

参考链接