前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >【源码角度】7分钟带你搞懂ESLint核心原理!

【源码角度】7分钟带你搞懂ESLint核心原理!

作者头像
HoMeTown
发布2022-10-26 08:36:17
1.2K0
发布2022-10-26 08:36:17
举报
文章被收录于专栏:秃头开发头秃了

前言

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来阻止逐层读取

底层实现:

代码语言:javascript
复制
// 加载配置在目录中
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, 配置】。

代码语言:javascript
复制
/** 加载扩展 */
_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方法,把配置对象的顺序进行翻转&把所有的配置对象合并为一个对象。

代码语言:javascript
复制
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

代码语言:javascript
复制
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

parserplugin 是以第三方模块的形式加载进来的,所以如果要自定义,需要先发布在使用,约定包名为eslint-plugin-xxx,配置中可以把xxx的前缀省略。

代码语言:javascript
复制
_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对于整个配置读取以及配置加载的流程以及原理,这里简单用一个代码总结一下都做了啥

代码语言:javascript
复制
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方法主要就做一些判断,然后根据条件分流到其他的方法进行处理:

代码语言:javascript
复制
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, 举个🌰

代码语言:javascript
复制
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的时候触发响应的处理函数。

代码语言:javascript
复制
// 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-disabledeslint-disabled-line禁用lint,需要注意的是,他是在lint完AST、get problem之后,对所有的问题进行一次过滤。

取出:

代码语言:javascript
复制
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 的过滤了。

代码语言:javascript
复制
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这个方法

代码语言:javascript
复制
 /**
 * 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的流程大概就是这样,下面再做一个总结

代码语言:javascript
复制
读取各种配置(eslintrc, package.json.eslintConfig)
...
加载插件(获取到插件的规则配置)
...
读取parser配置,解析AST
...
深度优先遍历AST收集节点
...
注册所有配置里的节点监听函数
...
遍历收集到的AST节点,触发节点监听函数,获取lint纹理
...
根据注释中的禁用命令进行过滤
...
修复

完结~

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2022-05-28,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 秃头开发头秃了 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 前言
  • 必要性?
  • 工作模式
    • 配置读取
      • 配置加载
        • extends
        • parser & plugin
      • 前半部分总结
        • 对你的代码进行校验 verify
          • processor
          • AST对象
          • ruleRules
          • disabled
        • Fix
          • 全文总结
          领券
          问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档