ESLint
,众所周知,他的主要工作是校验我们写的代码,然后规范我们的代码,今天聊聊ESLint
是怎么工作的。
一个项目一般情况下都是多人协同开发的(除了我自己做的那个门户)【手动狗头】,那就意味着大家的代码风格肯定多多少少都存在一定的差异,如果大家都随心而欲,没有约束的进行编码,后期维护的成本也就越来越大,如果再加上某些同事提桶,那就是事故,因此,ESLint
还是十分有必要的,在我们书写代码的时候,对基本写法进行一个约束,然后必要的时候弹出提示,而且一些小的问题还可以帮我们修复,何乐而不为?
打开官网,映入眼帘的便是:Find and fix Problems in your JavaScript Code
,光看这个就很nice。
再往下看:
这也就是ESLint
的主要工作,找到问题
、自动修复
、客制化
。
ESLint
通过遍历AST
,然后再遍历到不同的节点或者合适的时机的时候,触发响应的函数,抛出错误。
ESLint
会从eslintrc
或者package.json.eslintConfig
中读取配置,前者的优先级会大于后者,如果同级目录下存在多个配置文件,那么这层目录只有一个配置文件会被读取,默认会进行逐层读取
配置文件,最终合并为一个。如果多个配置文件里都配置了重复的字段,那里给定目录最近的配置会生效,可以在配置文件中添加root: true
来阻止逐层读取
。
底层实现:
// 加载配置在目录中
try {
/** configArrayFactory.loadInDirectory 这个方法会依次加载配置里的extends, parser, plugin */
configArray = configArrayFactory.loadInDirectory(directoryPath);
} catch (error) {
throw error;
}
// 如果配置了root, 终端外层遍历
if(configArray.length > 0 && configArray.isRoot()) {
configArray.unshift(...baseConfigArray);
return this._cacheConfig(directoryPath, configArray)
}
// 向上查找配置文件 & 合并
const parentPath = path.dirname(directoryPath);
const parentConfigArray = parentPath && parentPath !== directoryPath ? this._loadConfigInAncestors() : baseConfigArray;
if(configArray.length > 0) {
configArray.unshift(...parentConfigArray)
} else {
configArray = parentConfigArray
}
// 需要进行加载的配置文件名称列表
const configFilenames = [
.eslintrc.cjs ,
.eslintrc.yaml ,
.eslintrc.yml ,
.eslintrc.json ,
.eslintrc ,
package.json
]
loadInDirectory(directoryPath, { basePath, name } = {}) {
const slots = internalSlotsMap.get(this);
// configFilenames中的index决定了优先级
for(const filename of configFilenames) {
const ctx = createContext();
if(fs.existsSync(ctx.filePath) && fs.statSync(ctx.filePath).isFile()) {
let configData;
try {
configData = loadConfigFile(ctx.filePath);
} catch(error) {}
if(configData) {
return new ConfigArray()
}
}
}
return new ConfigArray()
}
是一些扩展的配置文件,ESLint
允许使用插件中的配置,或者第三方模块中的配置。ESLint
会去读取配置文件中的extends,如果extends的层级比较深,先做递归处理,然后再返回自己的配置,最终得到的顺序是【extends, 配置】。
/** 加载扩展 */
_loadExtends(extendName, ctx) {
...
return this._normalizeConfigData(loadConfigFile(ctx.filePath),ctx)
}
/** 格式化校验配置数据 */
_normalizeConifgData(config, ctx) {
const validator = new ConfigValidator();
validator.validateConfigSchema(configData, ctx.name || ctx.filePath);
return this._normalizeObjectConfigData(configData, ctx);
}
*_normalizeObjectConfigData(configData, ctx) {
const { files, excludedFiles, ...configBody } = configData;
const criteria = OverrideTester.create();
const elements = this._normalizeObjectConfigDataBody(configBody, ctx);
}
*_normalizeObjectConfigDataBody({extends: extend}, ctx) {
const extendList = Array.isArray(extend) ? extend : [extend];
...
// Flatten `extends`
for (const extendName of extendList.filter(Boolean)) {
/** 递归调用加载扩展配置 */
yield* this._loadExtends(extendName, ctx);
}
yield {
// Debug information.
type: ctx.type,
name: ctx.name,
filePath: ctx.filePath,
// Config data.
criteria: null,
env,
globals,
ignorePattern,
noInlineConfig,
parser,
parserOptions,
plugins,
processor,
reportUnusedDisableDirectives,
root,
rules,
settings
};
}
虽然自由配置的顺序是在extend config之后,但是,当所有配置都加载完,使用的时候,会调用一个extractConfig
& createConfig
方法,把配置对象的顺序进行翻转&把所有的配置对象合并为一个对象。
extractConfig(filePath) {
const { cache } = internalSlotsMap.get(this);
const indices = getMatchedIndices(this, filePath);
const cacheKey = indices.join( , );
if (!cache.has(cacheKey)) {
cache.set(cacheKey, createConfig(this, indices));
}
return cache.get(cacheKey);
}
/** 把数组顺序反转过来 */
function getMatchedIndices(elements, filePath) {
const indices = []
for (let i = elements.length - 1; i >= 0; --i) {
const element = elements[i];
if (!element.criteria || (filePath && element.criteria.test(filePath))) {
indices.push(i);
}
}
return indices;
}
createConfig
function createConifg(instance, indices) {
const config = new ExtractedConfig();
const ignorePatterns = [];
// 合并元素
for(const index of indices) {
const element = instance[index];
// 获取paser & 赋值给config.parser,进行覆盖操作
if(!config.parser && element.parser) {
// 如果parser有报错直接抛出
if(element.parser.error) {
throw element.parser.error
}
config.parser = element.parser
}
// 获取processor & 赋值给config.processor,进行覆盖操作
if (!config.processor && element.processor) {
config.processor = element.processor;
}
// 获取noInlineConfig & 赋值给config.noInlineConfig,进行覆盖操作
if (config.noInlineConfig === void 0 && element.noInlineConfig !== void 0) {
config.noInlineConfig = element.noInlineConfig;
config.configNameOfNoInlineConfig = element.name;
}
// 获取reportUnusedDisableDirectives & 赋值给config.reportUnusedDisableDirectives,进行覆盖操作
if (config.reportUnusedDisableDirectives === void 0 && element.reportUnusedDisableDirectives !== void 0) {
config.reportUnusedDisableDirectives = element.reportUnusedDisableDirectives;
}
// 处理忽略
if(element.ignorePattern) {
ignorePatterns.push(element.ignorePattern);
}
// 合并操作
mergeWithoutOverwrite(config.env, element.env);
mergeWithoutOverwrite(config.globals, element.globals);
mergeWithoutOverwrite(config.parserOptions, element.parserOptions);
mergeWithoutOverwrite(config.settings, element.settings);
mergePlugins(config.plugins, element.plugins);
mergeRuleConfigs(config.rules, element.rules);
}
if (ignorePatterns.length > 0) {
config.ignores = IgnorePattern.createIgnore(ignorePatterns.reverse());
}
return config;
}
结论:
mergeWithoutOverwrite
函数中的合并中是进行并集。parser
和 plugin
是以第三方模块的形式加载进来的,所以如果要自定义,需要先发布在使用,约定包名为eslint-plugin-xxx
,配置中可以把xxx
的前缀省略。
_loadParser(nameOrPath, ctx) {
try {
const filePath = resolver.resolve(nameOrPath, relativeTo);
return new ConfigDependency({
definition: require(filePath),
...
});
} catch(error) {
// If the parser name is espree , load the espree of ESLint.
if (nameOrPath === espree ) {
debug( Fallback espree. );
return new ConfigDependency({
definition: require( espree ),
...
});
}
return new ConfigDependency({
error,
id: nameOrPath,
importerName: ctx.name,
importerPath: ctx.filePath
});
}
}
_loadPlugin(name, ctx) {
// 处理包名
const request = naming.normalizePackageName(name, eslint-plugin );
const id = naming.getShorthandName(request, eslint-plugin );
const relativeTo = path.join(ctx.pluginBasePath, __placeholder__.js );
// 检查插件池,有则复用
const plugin =
additionalPluginPool.get(request) ||
additionalPluginPool.get(id);
if (plugin) {
return new ConfigDependency(
definition: normalizePlugin(plugin),
filePath: , // It's unknown where the plugin came from.
id,
importerName: ctx.name,
importerPath: ctx.filePath
});
}
let filePath;
let error;
filePath = resolver.resolve(request, relativeTo);
if (filePath) {
try {
const startTime = Date.now();
const pluginDefinition = require(filePath);
return new ConfigDependency({...});
} catch (loadError) {
error = loadError;
}
}
}
上面聊得就是ESLint对于整个配置读取以及配置加载的流程以及原理,这里简单用一个代码总结一下都做了啥
reading:
// 是否有 eslintrc or package.json
switch:
case: eslintrc || (eslintrc && package.json)
read eslitrc
load()
break
case: package.json
read package.json
load()
break
load:
switch:
case: extends
read extends
case !extends
current end
isRoot ? all end : reading();
Eslint的源码中 verfiy方法主要就做一些判断,然后根据条件分流到其他的方法进行处理:
verify(textOrSourceCode, config, filenameOrOptions) {
const { configType } = internalSlotsMap.get(this);
if (config) {
if (configType === flat ) {
let configArray = config;
if (!Array.isArray(config) || typeof config.getConfig !== function ) {
configArray = new FlatConfigArray(config);
configArray.normalizeSync();
}
return this._distinguishSuppressedMessages(this._verifyWithFlatConfigArray(textOrSourceCode, configArray, options, true));
}
if (typeof config.extractConfig === function ) {
return this._distinguishSuppressedMessages(this._verifyWithConfigArray(textOrSourceCode, config, options));
}
}
if (options.preprocess || options.postprocess) {
return this._distinguishSuppressedMessages(this._verifyWithProcessor(textOrSourceCode, config, options));
}
return this._distinguishSuppressedMessages(this._verifyWithoutProcessors(textOrSourceCode, config, options));
}
基本是以先处理processor
,解析获取AST
和节点数组
,跑runRules
processor是一个预处理器,用于处理特定后缀的文件,包含两个方法preprocess
& postprocess
。
ESLint的解析规则是如果没有指定parser,默认使用expree,否则使用指定的parser,这里需要对AST有足够的了解,大家只需要知道AST对象,就是把你写的代码转换成一个可以可供分析的对象,也可以理解为JS的虚拟DOM, 举个🌰
var name = 'HoMeTown'
// AST
{
"type": "Program",
"start": 0,
"end": 22,
"body": [
{
"type": "VariableDeclaration",
"start": 0,
"end": 21,
"declarations": [
{
"type": "VariableDeclarator",
"start": 4,
"end": 21,
"id": {
"type": "Identifier",
"start": 4,
"end": 8,
"name": "name"
},
"init": {
"type": "Literal",
"start": 11,
"end": 21,
"value": "HoMeTown",
"raw": "'HoMeTown'"
}
}
],
"kind": "var"
}
],
"sourceType": "module"
}
前面聊得那些其实都是ESLint的一些工作机制,规则才是ESLint的核心,工作原理其实也就是通过保存AST节点,然后遍历所有配置中的rulename,通过rule的名称找到对应的rule对象(也就是具体的规则),具体的方法为给每一个AST节点添加监听函数,遍历nodeQueue
的时候触发响应的处理函数。
// ruleListeners AST节点
Object.keys(ruleListeners).forEach(selector => {
const ruleListener = timing.enabled
? timing.time(ruleId, ruleListeners[selector])
: ruleListeners[selector];
emitter.on(
selector,
addRuleErrorHandler(ruleListener)
);
});
....
// 遍历节点数组,不同的节点触发不同的坚定函数,在监听函数中调用方法,收集问题
nodeQueue.forEach(traversalInfo => {
currentNode = traversalInfo.node;
try {
if (traversalInfo.isEntering) {
eventGenerator.enterNode(currentNode);
} else {
eventGenerator.leaveNode(currentNode);
}
} catch (err) {
err.currentNode = currentNode;
throw err;
}
});
大家都知道我们可以利用eslint-disabled
、eslint-disabled-line
禁用lint,需要注意的是,他是在lint完AST、get problem之后,对所有的问题进行一次过滤。
取出:
function getDirectiveComments(ast){
const directives = [];
ast.comments.forEach(comment => {
const match = /^[#@](eslint(?:-env|-enable|-disable(?:(?:-next)?-line)?)?|exported|globals?)(?:\s|$)/u.exec(comment.trim());
if (match) {
const directiveText = match[1];
...
directives.push({ type: xxx, line: loc.start.line, column: loc.start.column + 1, ruleId });
}
}
return directives;
}
其实就是对 AST 中所有的 comments 的内容做一下正则的匹配,如果是支持的 directive,就把它收集起来,并且记录下对应的行列号。
之后就是对 problems 的过滤了。
function applyDisableDirectives(problems, disableDirectives) {
const filteredProblems = [];
const disabledRuleMap = new Map();
let nextIndex = 0;
for (const problem of problems) {
// 对每一个 probelm,都要找到当前被禁用的 rule
while (nextIndex < disableDirectives.length && compareLocations(disableDirectives[nextIndex], problem) <= 0) {
const directive = disableDirectives[nextIndex++];
switch (directive.type) {
case 'disable':
disabledRuleMap.set(directive.ruleId, directive);
break;
case 'enable':
disabledRuleMap.delete(directive.ruleId);
break;
}
}
// 如果 problem 对应的 rule 没有被禁用,则返回
if (!disabledRuleMap.has(problem.ruleId)) {
filteredProblems.push(problem);
}
}
return filteredProblems;
}
function compareLocations(itemA, itemB) {
return itemA.line - itemB.line || itemA.column - itemB.column;
}
接下来就是修复了,主要用到SourceCodeFixer
类中的applyFixes
这个方法
/**
* Try to use the 'fix' from a problem.
* @param {Message} problem The message object to apply fixes from
* @returns {boolean} Whether fix was successfully applied
*/
function attemptFix(problem) {
const fix = problem.fix;
const start = fix.range[0];
const end = fix.range[1];
// 如果重叠或为负范围,则将其视为问题
if (lastPos >= start || start > end) {
remainingMessages.push(problem);
return false;
}
// 移除非法结束符.
if ((start < 0 && end >= 0) || (start === 0 && fix.text.startsWith(BOM))) {
output = "" ;
}
// 拼接修复后的结果, output是一个全局变量
output += text.slice(Math.max(0, lastPos), Math.max(0, start));
output += fix.text;
lastPos = end;
return true;
}
ESLint的流程大概就是这样,下面再做一个总结
读取各种配置(eslintrc, package.json.eslintConfig)
...
加载插件(获取到插件的规则配置)
...
读取parser配置,解析AST
...
深度优先遍历AST收集节点
...
注册所有配置里的节点监听函数
...
遍历收集到的AST节点,触发节点监听函数,获取lint纹理
...
根据注释中的禁用命令进行过滤
...
修复
完结~