阅读 285

管中窥eslint【附赠各框架超快eslint&prettier配置脚本】

虽然我是eslint的忠实拥簇者,不过最近在集成prettierr的过程中,还是觉得贼麻烦。各种配置一知半解,为啥有些又要加config又要加plugin,有些加了config就不用加plugin了…诸如此类的。所以就专门拿时间看了看eslint的背后流程~

.eslintrc

eslint当然支持很多种格式的配置文件,我习惯用.eslintrc的json形式。

rules

如果一份.eslintrc空空如也,那它当然无法工作。通过在rules里配置想要的规则,才能让eslint知道你需要哪些校验。 这也说明了,eslint本身维护了一些校验规则。所以我们才能方便的以rules名称的方式选择开启其中的部分嘛。

rules是如何工作的?

从大方向而言,大家都清楚,无非是AST分析,--fix就是替换嘛。不过真的要做AST分析,还是觉得是一项dirty work…一份js有那么多节点,而校验规则又那么多,怎么来匹配,又怎么确保效率呢?不可能每个校验规则都扫描一次所有节点吧。

其实从eslint提供的开发者开发自定义rule的文档接口中就可以大致看出一点逻辑。在eslint从外到内,再从内到外的阅读一份代码的过程中,提供了无数个钩子(包括进入退出文档、进入退出每种类型的节点…)供开发者注册。在这些钩子内,开发者可以通过eslint传入的当前node节点入参,以及全局挂载在context上的工具方法们,进行自定义校验;若校验失败,则通知eslint失败原因。

挑两个最常用的rules看看格式就理解了~

例一:no-console

代码地址:eslint/no-console.js at master · eslint/eslint · GitHub

超级简化版如下:

module.exports = {
	meta: {
        type: "suggestion",

      
        messages: {
            unexpected: "Unexpected console statement."
        }
    },
	create(context){
		function isConsole(reference) {
        const id = reference.identifier;
        return id && id.name === "console";
		}

		function report(reference) {
            const node = reference.identifier.parent;
            context.report({
                node,
                loc: node.loc,
                messageId: "unexpected"
            });
        }
		return {
				// 钩子节点
            "Program:exit"() {
            		const scope = context.getScope();
					scope.through.filter(isConsole);

                	const references = scope.through.filter(isConsole);
                references.forEach(report);
				}
        };

		}
	}
	
复制代码

关键就在create函数中return的对象。该对象的每个key都代表调用检测函数的钩子节点。可以看到,no-console是在Program:exit,也就是阅读完整份节点文档时做的校验;如果发现有identifier为console的,就通过调用context.report通知到eslint。

例二:no-extra-semi

代码地址: github.com/eslint/esli…

超级简化版如下:

module.exports = {
    meta: {
        fixable: "code",
        messages: {
            unexpected: "Unnecessary semicolon."
        }
    },

    create(context) {
        const sourceCode = context.getSourceCode();

        function report(nodeOrToken) {
            context.report({
                node: nodeOrToken,
                messageId: "unexpected",
                fix(fixer) {
return new FixTracker(fixer, context.getSourceCode())
                        .retainSurroundingTokens(nodeOrToken)
                        .remove(nodeOrToken);
                }
            });
        }

        function checkForPartOfClassBody(firstToken) {
            for (let token = firstToken;
                token.type === "Punctuator" && !astUtils.isClosingBraceToken(token);
                token = sourceCode.getTokenAfter(token)
            ) {
                if (astUtils.isSemicolonToken(token)) {
                    report(token);
                }
            }
        }

        return {
            EmptyStatement(node) {
                const parent = node.parent,
                    allowedParentTypes = [
                        "ForStatement",
                        "ForInStatement",
                        "ForOfStatement",
                        "WhileStatement",
                        "DoWhileStatement",
                        "IfStatement",
                        "LabeledStatement",
                        "WithStatement"
                    ];

                if (allowedParentTypes.indexOf(parent.type) === -1) {
                    report(node);
                }
            },

            ClassBody(node) {
checkForPartOfClassBody(sourceCode.getFirstToken(node, 1)); 
            },

            MethodDefinition(node) {
checkForPartOfClassBody(sourceCode.getTokenAfter(node));
            }
        };
    }
复制代码

如上,可以看到检测semi的时机是在EmptyStatement,ClassBody,MethodDefinition三类节点下。这里比较有趣的是看似陌生的EmptyStatment。js里无特殊意义的分号(比如return结尾)的节点类型就是EmptyStatement噢。(这一点我之前从未知道!)

不过看到这里时,心里未免会嘀咕起来,这么多node type,鬼知道什么对应什么阿。后来我找到了一个好办法——去 astexplorer 上手动写写找下对应;至于整体的AST节点类型有哪些,可以去 estree/es5.md 瞅瞅。它列举了最基础的节点类型,后续更新的es版本都会基于该版本做一些延伸。

这份rule还有一份特别之处——它支持自动fix。在调用context.report()时有传一个fix函数,以返回经矫正过的节点信息。

extends

说到rules,那大家肯定会有自己喜欢的一套rules配置,当然也会想共享一套配置。这就是extends的作用啦。extends不光可以继承rules,还可以继承.eslintrc里的所有配置项。extends即代表了”我想用别人这套.eslintrc config的意思“。

eslint-extends包名格式为eslint-config-xxx,配置时在.eslintrc下的extends中加上xxx即可使用。

Eslint里同样也有内置的一套默认推荐配置,也就是常见的eslint:recommended代码)。所以,最简单的基础错误校验配置方式,就是在.eslintrc里的extends里配上eslint:recommended就好啦。

plugins

除了eslint里内置的基础检测项,人们肯定还会有更多的自定义需求。比方如果我想自己写一套rules来集成到eslint里校验呢?那么就该plugins出场啦。

plugin的包名格式为eslint-plugin-xxx,该包的导出对象包含rules字段,其值是rules名称和对应校验函数的大对象。

示例eslint-plugin-import/index.js:

exports.rules = {
  'no-unresolved': require('./rules/no-unresolved'),
  'named': require('./rules/named'),
	...
}
复制代码

使用时,先在.eslintrc里注入plugin;再在rules指定要使用的plugin里的rule名称。

.eslintrc:

{
	"plugins": ["xxx"],
	"rules": {
		"xxx/xxx-rule": "error"
	}
}
复制代码

不过这时烦恼就出现了,总不能让用户再敲一遍rules嘛。如果有些其余的配置也必须要用户指定怎么办。

plugins中集成的配置

所以一般plugin里也会导出几套推荐配置,但是这些推荐配置默认是不会生效的噢。需要用户安装plugin:xxx/configName的指定名称去extends该config。因为这些config里,一般也会配置自己为plugin,所以用户免去了需要在eslintrc下特别指定plugin的操作。不过需要注意下plugin里导出的config有无extend其他config包,若有,也要记得安装上。

示例 eslint-plugin-import/index.js:

exports.rules = {	... }
exports.configs = {
	recommended: require('../config/recommended'),
	...
}
复制代码

使用:

原(需安装eslint-plugin-import和eslint-config-import):
{
	"plugins": ["import"],
	"extends": ["import"]
}

更方便(只需安装eslint-plugin-import):
{
	"extends": ["plugin: import/recommended"]
}
复制代码

parser & parserOptions & Processor

这三个我之前一直都傻傻分不太清。所以就拎到一起总结了。

parser

parser指定解析ast用的解析器。eslint的默认parser是内置的espree。另外还很常见的有:

  • @typescript-eslint/parser
  • vue-eslint-parser
  • babel-eslint

前两者好理解,他们的语法树肯定有些不一样,所以需要单独parse嘛。而babel-eslint也就是babel内置使用的parser,和espree比有什么不同呢?espree不也能指定es版本吗?

虽然espree也可以通过parserOptions.ecmaVersion指定es版本,但仅仅局限于写进es标准的语法,比如decorator等exprimental试验版本就不大能支持。babel-eslint能处理的语法更为全面,毕竟人家是babel呀。另外,只要指定parse: "babel-eslint"就啥配置都不用了,也不用额外写parserOptions.ecmaVersion,用起来也更为方便把。

p.s. 使用了typescript或者vue的插件或config后,就注意不要再在自己的.eslintrc内指定parser了,这样会覆盖掉它们集成的parser配置拉。

parserOptions

即给parser传的options。针对espree而言,我们常用的一般有这么几项:

  • parserOptions.sourceType: ‘script’/‘module’; 现在基本都是module了。
  • parserOptions.ecmaVersion:espree支持的es版本。
  • parserOptions.ecmaFeatures: { globalReturn’, ‘impliedStrict’, ‘jsx’} 。用jsx相关的框架时,比如React或Vue,jsx都要记得写上。

值得注意的一点是,使用其他的parser时,其parserOptions得根据其parser的文档来,比如babel-eslint的parserOptions

Processor

Processor看起来就很小众了,它的作用是:

Processors can extract JavaScript code from another kind of files, then lets ESLint lint the JavaScript code. Or processors can convert JavaScript code in preprocessing for some purpose.

也就是提供预处理某种代码的功能。

有个身边的例子,eslint-plugin-vue就针对.vue文件使用了自己写的processor来进行转换。

globals & env

globals的作用是指定全局变量,避免被误认为unused-vars。而env的作用是指定在该环境下特有的一系列全局变量,比如node的require, es6的Promise。不同的env并不互斥,所以多指定几个也没关系。一般大家常用的配置为:

{
	"env": {
		"browser": true,
    	"node": true,
    	"es6": true,
		"commonjs": true,
	}
}
复制代码

以上,粗浅的知道了eslint的工作原理,在配置时就不会再一头雾水了。至少知道config和plugin的原理,eslint是如何读取到其中的配置值的~

Prettier

Prettier是相当强大的格式化工具。Eslint虽然也能指定结尾加不加分号呀,空几格呀,但它更偏向于“语法检查”,只是同时附带了一些轻微格式化的功效。而对于整个代码的排版,很多人都是依靠代码编辑器来的。这样一个人开发还好,多人开发时,很容易因为互相没协商好编辑器格式化风格,造成风格很不一致的情况。再加上大家都很热爱”format on save”,所以ctrl+s一下,整篇代码的git blame都变成自己了…

所以用上了prettier之后,我天天都很开心。

虽然有人抱怨说prettier会乱折行,但是只要按自己的喜爱配好了,用起来还是很顺手的。

Prettier的配置很简单,最基础的就这么几项(不过想想,程序员格式化界争论来争论去不也就这么几项么)。以下是我最喜欢的.prettierrc配置:

{
  "printWidth": 100, // 每行最多字,折行时机的关键~
  "singleQuote": true, // 单引号
	"tabWidth": 2, // 几个空格
  "semi": false, // 结尾分号
  "trailingComma": "es5" // 这个存在感低一点但很实用,表示对象、数组的最后一项会补上逗号
}
复制代码

可是大家都讨厌安装重复的新东西呀。都用着eslint了,难道还要加个prettier命令么。所以prettier也提供了集成到eslint里的工具eslint-plugin-prettier,这就无比方便啦!eslint校验及fix的时候,也让prettier把自己的一份检查顺手做了~

p.s. 有一点要注意的是,为避免和eslint自带的format检验撞车,prettier在自己的基础配置中,会先把eslint所有自带的和格式检查相关的rules给关掉。所以如果既要配eslint: recommended又要配plugin:prettier/recommended的话,记得plugin:prettier/recommended要放到eslint: recommended后面噢。

p.p.s. 要注意编辑器的format on save功能也需要关闭,避免和eslint的format on save撞车。

playground:写一个自己的eslint插件把~

如果想要制定一套自己公司独有的Eslint校验规则,也不妨按着eslint开发者文档一试噢。

我们举个很简单的例子,不允许代码里的string里出现foo,需要替换成boo。那只要新建一个名为eslint-plugin-myself的包,里头有一条名为’foo-to-boo’的规则。

入口eslint-plugin-myself/index:js:

module.exports = {
	rules: {
		'foo-to-boo': require('./foo-to-boo')
	},
	configs: {
		// 把这份config名为为all
		all: {
			plugins: ['eslint-plugin-myself'],
			rules: {
				'myself/foo-to-boo': 'error'
			}
		} 
	}
}
复制代码

foo-to-boo.js

modules.exports = {
	create: function(context) {
		function validate(node){
			const val = node.raw;
			// 校验规则
			if(val.match && val.match('foo')) {
				 // 报告eslint
               context.report({
                   node,
                   message: 'no foo..',
					  // 返回修复后的节点内容	
                   fix: (fixer) => fixer.replaceTextRange(node.range, val.replace(/foo/, ()=>'bar');
                });
           }
		}
		return {
			// 校验string 如'hahhfoohh'
			'Literal': validate,
			// 校验template 如`hahhfoohh`
			'TemplateElement': validate
		}
	}
}
复制代码

使用:

{
	"extends": ['plugin: myself/all']
}
复制代码

Bonus: 30s生成eslint全配置~

其实在日常使用中, eslint配置最好要作为脚手架初始化配置的一部分。但是总面临着要维护没有eslint,或是…没有什么用的eslint的老项目的情况。所以我手撸了一个小脚本,方便一次性生成项目的eslint+prettier的全配置,包括.eslintrc、.prettierrc及依赖包。支持nodejs, React及Vue环境,也支持对常用extends的极其微小的选择权:

example.gif

使用也很方便:

npx add-eslint-script
复制代码

项目地址

默认配置地址如下,也可以此为base直接采用~

node项目默认配置

react项目默认配置

vue项目默认配置

碎碎念:关于eslint开发者

通过这次对eslint的详细背调,我才了解到其作者Nicholas C. Zakas从年轻时开始,就常年抱恙了。他患有严重的莱姆病,是一种发作原因和治疗方案都不大明确的神经性疾病。现在他已无法正常工作,亦不能正常生活了。从 I have lyme disease 可以读到他的自述和每年的健康状况发展。

看完他的故事后,我不禁陷入了沉思…阿,生活不易。在痛其体肤的情况下,他依然能坚持做完这么细致,甚至可以说是细枝末节的工作;写parser,分析一棵棵AST树…也很令人钦佩了。

Bless Eslint!!