前言
ESLint
,众所周知,他的主要工作是校验我们写的代码,然后规范我们的代码,今天聊聊ESLint
是怎么工作的。
必要性?
一个项目一般情况下都是多人协同开发的(除了我自己做的那个门户)【手动狗头】,那就意味着大家的代码风格肯定多多少少都存在一定的差异,如果大家都随心而欲,没有约束的进行编码,后期维护的成本也就越来越大,如果再加上某些同事提桶,那就是事故,因此,ESLint
还是十分有必要的,在我们书写代码的时候,对基本写法进行一个约束,然后必要的时候弹出提示,而且一些小的问题还可以帮我们修复,何乐而不为?
打开官网,映入眼帘的便是:Find and fix Problems in your JavaScript Code
,光看这个就很nice。
再往下看:
- Find Problems
- Fix Automatically
- Customize
这也就是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() } 复制代码
配置加载
extends
是一些扩展的配置文件,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; } 复制代码
结论:
- parser、processor、noInlineConfig、reportUnusedDisableDirectives,后面的配置会覆盖前面的配置。
- env、globals、parserOptions、settings会进行合并操作,但是在
mergeWithoutOverwrite
函数中的合并中是进行并集。 - rules 是后面的配置优先级高于前面的。
parser & plugin
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(); 复制代码
对你的代码进行校验 verify
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
processor是一个预处理器,用于处理特定后缀的文件,包含两个方法preprocess
& postprocess
。
- preprocess 的参数为源码or文件名,返回一个数组,每一项为需要被校验的代码块或者文件
- postprocess 主要是对校验完文件之后的问题(error,wraning)进行统一处理
AST对象
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" } 复制代码
ruleRules
前面聊得那些其实都是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; } }); 复制代码
disabled
大家都知道我们可以利用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; } 复制代码
Fix
接下来就是修复了,主要用到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纹理 ... 根据注释中的禁用命令进行过滤 ... 修复 复制代码
完结~