从 ESLint 开启项目格式化

前言

1.1 开篇

代码错误检查和自动格式化对于我们来说都不陌生,它往往陪伴着我们的每一行代码输出、每一次保存提交,我们享受着自动格式化带来的便利,但偶尔也有些小问题影响体验。

比如,面对同一个项目,各位项目协作者格式化方案却不一致,导致文件提交中频繁的出现代码格式的变动,微微到影响代码review;完成某条分支的代码merge,开开心心 commit,却被一堆 eslint error 阻止了脚步,确认提醒无伤大雅之后, --no-verify 解决一切;自动保存的时候,发现引号在单双之间横跳 ...

那么,是谁赋予了编辑器自动代码检查的能力,格式化的规则从何而来又如何生效,使用格式化工具会遇到哪些问题,他们如何产生又如何解决呢?下面就来介绍本文的主角:ESLint[1]。

1.2 什么是ESLint

ESLint[2] 是一个JS代码检查工具,用于发现并修正语法错误和统一代码格式。也就是说 ESLint 关注两方面的问题:

  • 代码质量:约定JS的使用方式,避免问题的产生

  • 代码格式:只影响代码的美观程度,不会产生问题

为什么我们需要代码检查呢?JS 作为一种弱类型动态语言,写法会比较随心所欲,这便很容易引入问题并增加我们发现问题的成本。通过编写合适的规则来约束代码,利用代码校验工具来发现并修复问题,能让我们的代码更健壮,工程更可靠。ESLint 就是这么一个通过各种规则对我们的代码添加约束的工具。

图片

JS 代码检查工具的发展历史中有三个里程碑式的存在:JSLint、JSHint 和 ESLint 。

JSLint 属于开山鼻祖,让广大前端工程师在如何更好的使用 JS 方面受益匪浅,其核心是使用JS实现了一个JS解析器Pratt,但它缺乏可配置性。

在强烈的可配置性的诉求下,JSHint应运而生,并快速取代了JSLint,其核心依然是 Pratt 解析器。随着前端技术爆炸式发展,JSHint 中规则检查和 Pratt 解析器强耦合的问题限制了其扩展性,难以满足快速 更新的ECMAScript 规则。

ESLint 重新出发,解耦解析器和规则检查的工作。解析器专注于源码的词法解析、语法解析,并生成符合 ESTree[3] 规范的 AST。ESLint 核心部分专注于配置生成、规则管理、上下文维护、遍历 AST、报告产出等主流程。ESLint 的规则、报告部分则通过约定接口的形式独立出来,方便自定义扩展。这良好的架构使得 ESLint 从一众 Linter 工具中脱颖而出。

基本使用

2.1 初始化

基本的下载和初始化Getting Started with ESLint[4]。--init 命令会进入一堆设置选项,包括代码运行环境、是否使用 react/vue 框架、是否使用ts、代码风格设置等,可以根据个人需求来快速初始化出不同的配置,并会提示安装相应的依赖包。这使得我们可以以非常低的成本用上社区中的优秀实践。

# 下载,安装为开发时依赖

npm install eslint --save-dev

# 初始化

npx eslint --init

图片

init 完毕之后,在项目的根目录会生成一个 eslint 的配置文件.eslintrc.{js,yml,json} 。

2.2 配置

从一个🌰入手,详细看看 .eslintrc.{js,yml,json} 中到底有啥,这里以json格式的配置为例:

{

    "env": {

        "browser": true,

        "es2021": true

    },

    "parserOptions": {

        "ecmaVersion": 12,

        "sourceType": "module",

        "ecmaFeatures": {

            "jsx": true

        }

    },

    /* 引入插件,作用类似 require,这里简写了,实际引入的是 @typescript-eslint/eslint-plugin */

    "plugins": ["@typescript-eslint"],

    "extends": [  

        /*使用eslint推荐的规则作为基础配置,可以在rules中覆盖*/

        "eslint:recommended"

    ],

    "rules": {

        "quotes": ["error", "double"],

        "prefer-const":"error",

        /* 使用@typescript-eslint/eslint-plugin插件中的规则 */

        "@typescript-eslint/consistent-type-definitions": [  

            "error",

            "interface"

        ]

    },

    "globals": {

        "$": "readonly"

    }

}

2.2.1 环境和全局变量

当访问当前源文件内未定义的变量时,no-undef[5] 规则将发出警告,可以通过定义全局变量来解决。env提供了多个环境选择字段,一个环境定义了一组预定义的全局变量。globals可以自定义单个的全局变量。

2.2.2 规则

rules字段定义需要符合的规则,官网提供了一系列的规则供选择 List of available rules[6]。上面所示的quotesprefer-const都是官网提供的规则选项。

规则的value设定可以通过string,直接设置错误等级,等级分为三类:"off" 、"warn""error";也可以通过数组的方式设置,在数组方式的设置中,第一项是错误等级,剩余项为可选参数,官网提供的每条rule都有详细的说明文档 ,向我们展示了该条 rule 的使用方式,包括 .eslintrc.{js,yml,json} 中的配置和内联配置方式,还有使用建议。

2.2.3 解析器

parserOptionsESLint 允许指定想要支持的 JavaScript 语言选项,默认支持 ECMAScript 5 语法。可以覆盖该设置,以启用对 ECMAScript 其它版本和 JSX 的支持。值得注意的是,支持JSX的解析并不代表支持React的解析,React中特定的JSX语法是无法被ESLint解析的,需要额外使用第三方插件 eslint-plugin-react 来处理,插件使用在后面讨论。

parser 字段指定一个不同的解析器,解析器的作用是将JS代码解析成 AST ,ESLint 将通过遍历该AST 来触发各个检查规则。由于 ESLint 默认的解析器ESPree[7]只支持已经形成标准的语法特性,对于处于实验阶段以及非标准的语法,如 TypeScript ,是无法正确解析的,这时就需要使用其他的解析器,生成和 ESTree 结构相兼容的 AST 。对于 TypeScript 就需要使用"parser": "``@typescript-eslint/parser``" 。

官方提供了与ESLint兼容的解析器参考官网Specifying Parser[8]。

2.2.4 插件

官方提供的规则毕竟有限,当我们想自定义规则的时候,就需要自定义一个 ESLint 的插件,然后将规则写到自定义的 ESLint 插件中,在配置文件中通过plugins字段引入 。

还是以处理TS为例,光指定解析器 @typescript-eslint/parser 只是能把 ESLint 不能识别的语法特性转化为 ESLint 能识别的,但它本身不包括规则,还需要设置 "plugins": ["@typescript-eslint/eslint-plugin"], 插件,这个声明只是完成了插件的加载,还需要在rules中使用需要的规则,才能执行对应的代码检测规则。当然,plugin不仅限于引入新的规则,其他的配置也是一样可以通过plugin引入的。

{

    // ...

    "plugins": [

        "jquery",   // eslint-plugin-jquery

        "@foo/foo", // @foo/eslint-plugin-foo

        "@bar"      // @bar/eslint-plugin

    ],

    "rules": {

        "jquery/a-rule": "error",

        "@foo/foo/some-rule": "error",

        "@bar/another-rule": "error"

    },

    "env": {

        "jquery/jquery": true,

        "@foo/foo/env-foo": true,

        "@bar/env-bar": true,

    }

    // ...

}

更多引入和使用方式参考官网 configuring-plugins[9]。

ESLint官方为了方便开发者开发插件,提供了使用Yeoman模板generator-eslint(Yeoman是一个脚手架工具,用于生成包含指定框架结构的工程化目录结构),生成的项目默认采用 Mocha 作为测试框架。

2.2.5 扩展

手动配置的工作量很大,所以一般会使用extends扩展包来预设配置,extends可以去集成各样流行的最佳实践,成本低到令人感动。

配置文件一旦被扩展,将继承另一份配置文件的所有属性,包括规则、插件、语言解析选项Extending Configuration Files[10]。

原理

3.1 关于AST

Lint 是基于静态代码进行的分析,对于 ESLint 来说,我们的输入的核心就是 rules 及其配置以及需要进行 Lint 分析的源码。需要进行 Lint 的源码则各不相同,如果说能抽象出 JS 源码的共性,再对源码进行分析就会容易很多,这个被抽象出来的代码结构就是 AST(Abstract Syntax Tree,抽象语法树)。

AST 本身并不是一个新鲜的话题,它是Babel,Webpack 等前端工具实现的基石,可能在任何涉及到编译原理的地方都会用到它。关于AST的详细内容可以参看之前曹诚的文章前端也要懂编译:AST 从入门到上手指南[11] 。

ESLint 默认使用 espree[12] 来解析我们的 JS 语句,来生成AST,可以通过AST explorer[13] 来查看一段代码被解析成AST之后的结构。

图片

3.2 rule如何生效

拿到代码的AST之后,接下来的工作就是在遍历AST的过程中去触发各个规则检查,再根据rule的规则去判断这一段代码是否符合eslint的规范。

先看一条 no-debugger 规则的源码:

module.exports = {

    meta: {

        type: "problem",

        docs: {

            description: "disallow the use of `debugger`",

            category: "Possible Errors",

            recommended: true,

            url: "https://eslint.org/docs/rules/no-debugger"

        },

        fixable: null,

        schema: [],

        messages: {

            unexpected: "Unexpected 'debugger' statement."

        }

    },



    create(context) {

        return {

            DebuggerStatement(node) {

                context.report({

                    node,

                    messageId: "unexpected"

                });

            }

        };



    }

可以看到,一条 rule 就是一个 node 模块,主要由 meta 和 create 两部分组成,

  • meta 代表了这条规则的元数据,比如类别,文档,可修复选项, schema 等,官方文档[14]对其有详细描述。

  • create 主要表达了这条 rule 具体会怎么分析处理代码。Create 返回的是一个存储了一系列回调函数的对象,这些函数将在遍历AST的时候触发。

  • create 返回的对象 key 分三类:

    • AST 选择器[15],在向下遍历AST的时候触发。选择器用于匹配 AST 节点,最常见的选择器名就是AST节点类型,通过选择器,可以获取对应选中节点内容,随后就可以针对选中的内容做一定的判断;

  • AST 选择器加:exit,如:Program:exit,在遍历完节点,向上返回的时候触发;

  • 代码路径分析[16]对应的事件名,路径分析是用来处理条件语句、循环语句中的代码,比如可以帮助我们找到不可达的代码。

回到给出的规则示例,上述代码中的DebuggerStatement 对应之前截图中的 AST 中的 debugger 的节点,上面的代码表示在匹配到 debugger 语句时,会抛出 "Unexpected 'debugger' statement." 。

rule 主要有两个来源:1. 配置文件中涉及到的 rules 2. 注释中的 rules;共同组成configuredRules 对象。

获得AST 和 rules 之后,接下来的工作就是遍历所有的rules添加监听事件,然后遍历整个AST去触发所有监听事件进行代码检查。根据源码梳理一下 rules 的处理过程:

function runRules(sourceCode, configuredRules, ruleMapper, parserOptions, parserName, settings, filename) {

    const emitter = createEmitter();

    const nodeQueue = [];

    let currentNode = sourceCode.ast;



  // 1. 遍历ast,生成 nodeQueue,每个节点会被传入两次

  // nodeQueue 形似 [ { isEntering: true, node1 } , { isEntering: true, node2 } , { isEntering: false, node2 }, { isEntering: false, node1 } ]

    Traverser.traverse(sourceCode.ast, {

        // 1.1 进入节点的时候触发

        enter(node, parent) {

            node.parent = parent;

            nodeQueue.push({ isEntering: true, node });

        },

        // 1.2 离开节点的时候触发

        leave(node) {

            nodeQueue.push({ isEntering: false, node });

        },

        visitorKeys: sourceCode.visitorKeys

    });



    const lintingProblems = [];



  // 2. 遍历所有的 rules ,并添加监听

    Object.keys(configuredRules).forEach(ruleId => {

        const rule = ruleMapper(ruleId);

        const messageIds = rule.meta && rule.meta.messages;

        let reportTranslator = null;

        // 2.1 针对每条 rule 生成上下文对象,

        const ruleContext = Object.freeze(

            Object.assign(

                Object.create(sharedTraversalContext),

                {

                    id: ruleId,

                    options: getRuleOptions(configuredRules[ruleId]),

                    report(...args) {

                        // rule 校验不通过,就会调用report方法,则给lintingProblems加入错误信息

                        if (reportTranslator === null) {...}

                        const problem = reportTranslator(...args);

                        if (problem.fix && rule.meta && !rule.meta.fixable) {

                            throw new Error("Fixable rules should export a `meta.fixable` property.");

                        }

                        

                        lintingProblems.push(problem);

                    }

                }

            )

        );

        // 2.2 本质是执行 rule.create(ruleContext)方法,拿到create返回的选择器回调函数对象

        const ruleListeners = createRuleListeners(rule, ruleContext);



        // 2.3 为单条rule对应的所有选择器挂载监听事件到emitter对象

        Object.keys(ruleListeners).forEach(selector => {

            const ruleListener = timing.enabled

                ? timing.time(ruleId, ruleListeners[selector])

                : ruleListeners[selector];



            emitter.on(

                selector,

                addRuleErrorHandler(ruleListener)

            );

        });

    });



    // 3. 根据注册完监听事件的emiter,生成事件触发对象 eventGenerator

    // 仅当顶层node为"Program"的时候才去做代码路径分析

    const eventGenerator = nodeQueue[0].node.type === "Program"

      ? new CodePathAnalyzer(new NodeEventGenerator(emitter, { visitorKeys: sourceCode.visitorKeys, fallback: Traverser.getKeys }))

      : new NodeEventGenerator(emitter, { visitorKeys: sourceCode.visitorKeys, fallback: Traverser.getKeys });



    // 4. 遍历 nodeQueue ,

    // 调用eventGenerator包含两个事件触发方法 enterNode、leaveNode去触发每个节点中包含的事件

    nodeQueue.forEach(traversalInfo => {

        currentNode = traversalInfo.node;

        if (traversalInfo.isEntering) {

            eventGenerator.enterNode(currentNode);

        } else {

            eventGenerator.leaveNode(currentNode);

        }

    });



    // 5. 返回问题集合

    return lintingProblems;

}

整体的代码主流程如上,关键步骤处都做了注释,省略了细节实现部分。ESLint的原理部分远远不止这么一点,包括具体的rule的实现原理都对我们在实现自定义规则的时候很高的参考价值,有兴趣的同学可以看看官方源码[17]。

搭配使用

4.1 VSCode

通常我们在开发中,不会频繁使用npx eslint命令执行代码检查,而是在IDE中自动提醒Eslint的错误。在VSCode中,需要安装ESLint插件。

图片

设置一下 settings.json 的保存自动格式化设置:

  "editor.codeActionsOnSave": {

    "source.fixAll.eslint": true

  },

  "editor.formatOnSave": false,

4.2 prettier

在使用 ESLint 的时候,我们往往会配合 Prettier 使用。Prettier 是一个‘有态度’的代码格式化工具,专注于代码格式自动调整,ESLint 本身就可以解决代码格式方面的问题,为什么要两者配合使用?

  • ESLint 推出 --fix 参数前,ESLint 并没有自动格式化代码的功能,而 Prettier 可以自动格式化代码。

  • 虽然 ESLint 也可以校验代码格式,但 Prettier 更擅长。

二者搭配使用,ESLint 关注代码质量,Prettier 关注代码格式。但是二者在格式化上面的功能有所交叉,所以Prettier 和 ESLint 一起使用的时候会有冲突,这需要我们进行一些配置:

  1. 用 eslint-config-prettier 来关掉 (disable) 所有和 Prettier 冲突的 ESLint 的配置,方法就是在 .eslintrc 里面将 prettier 设为最后一个 extends,需要安装 eslint-config-prettier

// .eslintrc    

{      

    "extends": ["prettier"] // prettier 一定要是最后一个,才能确保覆盖    

}
  1. (可选)然后再安装 eslint-plugin-prettier 和 prettier,将 prettier 的 rules 以插件的形式加入到 ESLint 里面。

当我们使用 Prettier + ESLint 的时候,其实格式问题两个都有参与,disable ESLint 之后,其实格式的问题已经全部由 prettier 接手了。那我们为什么还要这个 plugin?其实是因为我们期望报错的来源依旧是 ESLint ,使用这个,相当于把 Prettier 推荐的格式问题的配置以 ESLint rules 的方式写入,这样相当于可以统一代码问题的来源。

// .eslintrc    

{      

    "plugins": ["prettier"],      

    "rules": {        

        "prettier/prettier": "error"      

    }    

}

如下可以看到报错来源:

图片

将上面两个步骤合在一起就是下面的配置,也是官方的推荐配置[18]

// .eslintrc

{

  "extends": ["plugin:prettier/recommended"]

}

4.3 husky

现在我们已经能做到了在开发时检测出来错误并且方便及时修复问题,但这依赖于开发同学自觉,不通过eslint代码检测的代码依然能被提交到仓库中去。此时我们需要借助husky[19]来拦截 git 操作,在 git 操作之前再进行一次代码检测。

新版的 husky 使用有些变化,不再是直接在 package.json 中进行配置。

// package.json

{  

  "husky": {

    "hooks": {

      "pre-commit": "eslint src/** --fix"

    }

  }

}

新版用法:

npm install -D husky

# husky 初始化,创建.husky/目录并指定该目录为git hooks所在的目录

husky install 

# .husky/目录下会新增pre-commit的shell脚本

# 在进行 git commit 之前运行 npx eslint src/** 检查

npx husky add .husky/pre-commit "npx eslint src/**"

关于husky install官网推荐的是在packgae.json中添加prepare脚本,prepare脚本会在npm install(不带参数)之后自动执行。

{

  "scripts": {

    "prepare": "husky install"

  }

}

.husky 目录结构如下

图片

生成的 .husky/pre-commit 文件如下

#!/bin/sh

. "$(dirname "$0")/_/husky.sh"



npx eslint src/** --fix

4.4 lint-staged

对于单次提交而言,如果每次都检查 src 下的所有文件,可能不是必要的,特别是对于有历史包袱的老项目而言,可能无法一次性修复不符合规则的写法。所以我们需要使用lint-staged[20]工具只针对当前修改的部分进行检测。

// package.json

{

  "lint-staged": {

    "*.{js,ts}": [

      "npx eslint --fix"

    ]

  },

}

🌰中配置表示的是,对当前改动的 .js 和 .ts文件在提交时进行检测和自动修复,自动修复完成后 lint-staged默认会把改动的文件再次 add 到暂存区,如果有无法修复的错误会报错提示。

同时还需要改动一下之前的 husky 配置,修改 .husky/pre-commit,在 commit 之前运行npx lint-staged来校验提交到暂存区中的文件:

#!/bin/sh

. "$(dirname "$0")/_/husky.sh"



npx lint-staged

小结

本文介绍了ESLint的基本配置,并搭配具体的rule,介绍了rules生效的主流程,然后搭配其他的工具,使得ESLint在团队协作中更好的发挥作用。

❤️ 谢谢支持

以上便是本次分享的全部内容,希望对你有所帮助^_^

喜欢的话别忘了 分享、点赞、收藏 三连哦~。

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

前端践行者-Mr鹏帅

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值