Skip to content

面向 lint 编程

2023年6月13日  25分钟

何为 linter

Linter 泛指能够检测代码问题的工具,这些问题可能包括:

ESLint --init

常见的 linter:

比较典型的,prettier 不是 linter,是 formatter。

Linter 中常见概念解释

很多 lint 工具的设计都是类似的,以大家最熟悉的 ESLint 为例。下图是我们编写 Lint 工具插件常用的网站 astexplorer,图中的一些标注对应了 ESLint 中的一些常见概念。

astexplorer

Parser

像 ESLint, Stylelint 这类 Lint 编程语言的 Linter,一般都需要通过 parser 将源码解析成 ast,然后在规则中遍历 ast 节点发现代码问题。同一个编程语言可以有很多不同的 parser,例如 js 有 espress, @babel/eslint-parser。不同的的编程语言那更是不同的 parser 了。

本文不对 ast 做过多的叙述,因为我也只是停留在会用的层面,没有系统学习过怎么写一个 ast Parser,就不班门弄斧了。

我们可以通过 parser 选项来设置 ESLint 的 parser。多数情况对于 js 以外的语言我们会用 overrides 来覆盖特定后缀名对应的文件使用别的 parser,例如;

/** @type {import('eslint').Linter.Config} */
module.exports = {
  overrides: [
    // https://github.com/tjx666/eslint-config/blob/main/packages/basic/index.js#L59
    {
      files: ['*.{json,jsonc}'],
      parser: 'jsonc-eslint-parser',
      // json 特定的规则
      rules: {},
    },
    // https://github.com/tjx666/eslint-config/blob/main/packages/vue/index.js#L11
    {
      files: ['*.vue'],
      parser: 'vue-eslint-parser',
      env: {
        'vue/setup-compiler-macros': true,
      },
      parserOptions: {
        parser: '@typescript-eslint/parser',
      },
      rules: {},
    },
  ],
};

常见的 parser:

这里就不得不夸一下日本老哥 ota-meshi,它开发了很多 eslint 插件和 parser,而且回复 issue 贼快。他写的插件例如 eslint-plugin-vue 大多都有 playground, 做实验或者 reproduce 都很方便。

configuration

自称 opinionated 的 prettier 仅有 20 个左右的选项,而 eslint 提供了丰富的选项,每个规则还可以有自己的 options。这也是有选人选择使用 ESLint 来格式化代码而不是 prettier 的原因。

ESLint 自身配置

eslint define config

共享配置

可以将一个 eslint 配置以 npm 包的形式共享,在 extends 中使用。这里列举一些当前比较流行的配置包:

使用形式:

module.exports = {
  // 包名为 @yutengjing/eslint-config-react
  extends: '@yutengjing/eslint-config-react',
  rules: {},
};

如果包名就是:

Plugin

plugin 可以提供 rules 和 configs。例如我们看 @typescript-eslint/eslint-plugin/src/index.ts:

export = {
  rules,
  configs: {
    all,
    base,
    recommended,
    'eslint-recommended': eslintRecommended,
    'recommended-requiring-type-checking': recommendedRequiringTypeChecking,
    strict,
  },
};

而每个 config 其实就和我们平时配置 .eslintrc 导出的对象类型是一样的,例如 plugin:@typescript-eslint/base:

export = {
  parser: '@typescript-eslint/parser',
  parserOptions: { sourceType: 'module' },
  plugins: ['@typescript-eslint'],
};

我们自己的配置优先级最高:

// .eslintrc.js
module.exports = {
  root: true,
  // 插件提供的配置以 plugin: 开头
  extends: ['plugin:@typescript-eslint/base'],
  // 将会覆盖 plugin:@typescript-eslint/base 的 sourceType: 'module'
  parserOptions: { sourceType: 'script' },
};

Rule

ESLint 有很多内置的规则,你也可以通过 eslint 插件增加更多的规则。

module.exports = {
  plugins: ['unicorn'],
  rules: {
    // 内置的规则没有 scope
    'no-undef': 2,
    // 外部插件都有 scope
    // eslint-plugin-unicorn 的规则都以 unicorn 这个 scope 开头
    'unicorn/filename-case': ['error'],
  },
};

配置文件中 rules 对象类型:

type Severity = 0 | 1 | 2;
type StringSeverity = 'off' | 'warn' | 'error';

type RuleLevel = Severity | StringSeverity;
// 例如 'unicorn/xxx': ['error', option1, option2, ...option999]
type RuleLevelAndOptions<Options extends any[] = any[]> = Prepend<Partial<Options>, RuleLevel>;

type RuleEntry<Options extends any[] = any[]> = RuleLevel | RuleLevelAndOptions<Options>;

interface RulesRecord {
  [rule: string]: RuleEntry;
}

documentation

ESLint 规则众多,我们怎样查看一个规则对应的文档呢?Google 确实是一种方法,如果是在 VSCode 中,我们可以直接通过 quick fix 快速打开规则对应的文档。

documentation

一般来讲在编写 ESLint 插件时我们都会把所有的规则的文档都平铺到一个文件夹:

documentation folder

这样方便统一设置规则的文档:

const fs = require('node:fs');
const path = require('node:path');

function getDocumentationUrl(filename) {
  const ruleName = path.basename(filename, '.js');
  return `https://github.com/sindresorhus/eslint-plugin-unicorn/blob/main/docs/rules/${ruleName}.md`;
}

function loadRule(ruleId) {
  const rule = require(`../rules/${ruleId}`);

  return {
    meta: {
      docs: {
        ...rule.meta.docs,
        // 统一设置规则的文档
        url: getDocumentationUrl(ruleId),
      },
    },
    create: rule.create,
  };
}

function loadRules() {
  return Object.fromEntries(
    fs
      .readdirSync(path.resolve(__dirname, '../rules'), { withFileTypes: true })
      .filter((file) => file.name !== 'index.js' && file.isFile())
      .map((file) => {
        const ruleId = path.basename(file.name, '.js');
        return [ruleId, loadRule(ruleId)];
      }),
  );
}

Fix

eslint 可以使用 --fix 参数来对代码进行自动修复。这就有一些默认的约定:

这里举个例子,eslint-unicorn 有个规则叫 unicorn/prefer-at

array[array.length - 1];
// 会被自动修复为
array.at(-1);

但是这其实是不安全的,例如下面的代码,NodeList 在目前所有主流浏览器都没有实现 at 方法:

// issue: https://github.com/sindresorhus/eslint-plugin-unicorn/issues/2098
parent?.childNodes[parent.childNodes.length - 1];

使用 IDE 手动修复的例子:

editor suggestion

Disable

ESLint 的错误提示并不总是准确的,例如不支持自动修复的规则:unicorn/prefer-dom-node-text-content 。这个规则希望我们使用 textContent 而不是 innerText,但这俩属性其实是有区别的,innerText 属性会考虑子元素的样式,例如 br 标签换行。所以某些需要保留样式的情况你就要禁用这个规则。

this.$refs.cell.innerText = content ?? '';

如果一个 ESLint 规则每次 ESLint 报错都是需要禁用的情况,那其实它对我们没啥用,建议直接关了。例如:no-new。多数情况我们使用 new Constructor() 没有对它赋值其实是故意的,例如:

new Vue({ el: '#app', render: (h) => h(App) })

建议使用 eslint-plugin-eslint-comments 规范 disable 注释使用,例如检测没有用的 eslint disable 注释(有点像 @ts-expect-error),以及不允许禁用所有规则,必须声明禁用的规则是哪一条。

我们可以借助 VSCode 的 quick fix 快速的 disable,利用好工具能事半功倍。

ESLint 基本工作原理

简单介绍 ESLint 在 Lint 一个文件时的流程:

  1. 根据命令行参数,.eslintrc 配置文件,以及 IDE 的 ESLint 插件设置解析出这个文件对应的配置。
  2. 遍历所有这个文件开启的规则,每个规则对应一个 js 对象。
    1. 读取规则的元信息 (meta),包括文档链接,问题类型,是否支持 IDE 手动修复,选项的 JSON Schema 等
    2. 调用规则模块的 create 方法,并将这个文件的上下文对象 context 作为第一个参数。这个 context 包含源码,配置,parserServices 等其它等信息。parserServices 包含其它 parser 提供的用于遍历 ast 的 visitor 函数,例如 vue 文件可以使用使用 eslint-vue-parser 提供的 context.parserServices.defineTemplateBodyVisitor 来遍历 vue sfc template 中的节点。
    3. create 方法可以返回一个 NodeListener 用来遍历所有的 ast 节点
  3. 在规则的 create 方法内,通过遍历节点发现代码满足某个条件后,你可以通过 context.report(descriptor: Rule.ReportDescriptor) 同步抛出一个问题的描述,其中包括
    1. message: 问题的描述信息
    2. node: 问题对应的代码节点
    3. fix: —fix 参数自动修复逻辑,类型是 Fix | IterableIterator<Fix> | Fix[],fix 函数也可以是一个生成器函数
    4. suggest:IDE 手动修复逻辑
  4. ESLint 收集到所有的规则 report 的 descriptors 后,默认情况会把 warningerror 级别的问题输出到控制台(开启 --quiet参数就只会输出 error 级别)。如果有 error 级别的问题,程序的退出码为 0 以外的数。当然如果是 VSCode ESLint 插件,会根据 lint 结果提供错误提示,文档链接,自动修复,手动修复等

Lint Staged

为了统一团队代码风格,以及检测出潜在的问题,我们可以在使用 git 提交代码进行 lint 检查。每次对所有代码进行 Lint 是不现实的,例如我现在维护的一个公司项目 30 多万行代码,每次都 lint 那就太慢了。而且如果每次全量 lint 会有另一个问题:某次调整 eslint 规则,新增了一个 error 级别的规则,全量 lint 那要修复的文件可能有上百个,错误上千。

lint-taged 可以帮助我们只 lint 我们此次修改了的代码,每次只 lint 我们改动了的代码,可以让我们渐进式的处理代码中的 lint 问题。

Github Hooks

本质上是保存在 .git/hooks 的一堆钩子脚本。

我们一般会使用 pre-commit 这个 git hook 触发 Lint Staged。

为啥么不用 husky 而用 simple-git-hooks

简单即是美。忘了从哪个版本开始 husky 开始强制用户必须存在 .husky 文件夹,这我不能忍,我就想设置一个钩子一行代码还要占用本来就很长的一级目录。貌似也是因为这个原因,尤雨溪在开发 vue3 的时候就把 husky 换成了 yorkie,不过最新代码已经换成 simple-git-hook

为啥么我的 lint-staged 不会被触发?

在维护公司基建过程中,发现部分同事本地不会触发 pre-commit 钩子。原因是我们那个项目是由 husky 迁移到 simple-git-hooks 的,使用 husky 的项目都会把 git 钩子目录设置为 .husky 目录。如果我们在仓库中运行命令 git config --list,我们可以看到有这么一行:

core.hookspath=.husky

解决方法就是将 hooks 目录设置为默认的文件夹:

git config core.hooksPath .git/hooks/

为啥么每次修改配置 git hook 都需要执行 simple-git-hooks

因为需要将 package.json 中最新的 git hooks 配置写入到 .git/hooks

Lint Changed

目前开源界对于 Lint 触发时机有两大主流做法:

  1. 本地 git 提交时触发 lint staged
  2. 不在本地做,直接在 ci 上做 lint

我目前主要维护的公司项目两个都会走,本地好办,直接上 lint-staged,但是 ci 上 lint-staged 很难用,放一张图你就明白了:

lint changed

总结下问题就是:

下面是我优化后的效果:

lint changed fixed

通过自己实现了一个脚本读取 lint-staged 配置文件来实现 lint changed,优化代码:

import { createRequire } from 'node:module';

import boxen from 'boxen';
import consola from 'consola';
import type { ExecaError } from 'execa';
import { execa } from 'execa';
import micromatch from 'micromatch';
import c from 'picocolors';

import { execaWithOutput } from './utils';

const changeTarget = process.env.CHANGE_TARGET || 'master';

const require = createRequire(import.meta.url);
const lintStagedConfig = require('../lint-staged.config') as Record<
  string,
  (files: string[]) => string
>;

async function getChangedFiles() {
  const { stdout } = await execa('git', [
    'diff',
    '--name-only',
    // 排除删除了的文件
    '--diff-filter=d',
    changeTarget,
    'HEAD',
  ]);
  return stdout.trim().split(/\r?\n/);
}

const changedFiles = await getChangedFiles();

const lintTasks = Object.entries(lintStagedConfig).map(async ([pattern, taskCreator]) => {
  const expandedPattern = `**/${pattern}`;
  const matchedFiles = micromatch(changedFiles, expandedPattern, {});
  const command = taskCreator(matchedFiles).trim();

  if (command === (globalThis as any).__lintStagedSkipMessage__) {
    consola.info(c.cyan(`skip lint for pattern: ${c.green(pattern)}`));
    return;
  }

  const doubleQuoteIndex = command.indexOf('"');
  const [exe, ...args] = command.slice(0, doubleQuoteIndex).trim().split(/\s+/);
  const pathList = command
    .slice(doubleQuoteIndex)
    .split(/(?<=")\s+(?=")/)
    // 去除引号
    .map((pathWithQuote) => pathWithQuote.slice(1, -1));
  const filesTooMany = pathList.length > 10;
  const pathListStr = filesTooMany ? `<...${pathList.length}files>` : pathList.join(' ');
  const commandStr = `${[exe, ...args, pathListStr].join(' ')}`;
  console.log(c.dim(`$ ${commandStr}\n`));
  try {
    await execaWithOutput(exe, [...args, ...pathList], {
      outputCommand: false,
    });
  } catch (_error) {
    const error = _error as unknown as ExecaError;
    let { message, command, exitCode } = error;
    if (filesTooMany) {
      message = message.replace(command, c.red(commandStr));
    }
    consola.error(message);

    const fixCommand = `pnpm lint:fix ${changeTarget}`;
    // 第二行需要留一个空格来实现换行
    const fixMessage = `${c.red('Lint 失败,请尝试在本地运行下面的修复命令!')}\n
${c.magenta(fixCommand)}`;
    console.log(
      boxen(fixMessage, {
        padding: 1,
        margin: 1,
        align: 'center',
        borderColor: 'yellow',
        borderStyle: 'round',
      }),
    );

    process.exit(exitCode);
  }
});

// 只 lint 不修复,也就是只读不写不会有并发问题
await Promise.all(lintTasks);

consola.success('Lint 通过');

预计不就后我会开源一个 npm 包用来放一些我编写的非常实用的前端工程化脚本。

VSCode 中 Lint 工具的使用

必备插件

推荐配置项

{
  // 保存文件时触发
  "editor.codeActionsOnSave": {
    // 自动导入缺失的模块
    "source.addMissingImports": false,
    // 各种 lint 的自动修复
    "source.fixAll.eslint": true,
    "source.fixAll.stylelint": true,
    "source.fixAll.markdownlint": true
  },
  // 保存文件时自动格式化
  "editor.formatOnSave": true,
  // 使用系统环境的 node 而不是 VSCode 自带的 node,统一团队成员 eslint node 版本
  "eslint.runtime": "node",
  // 开启后在 source control 面板的菜单项就有下面截图中 `Commit Staged(No Verify)`,可以跳过 pre-commit 时的 lint verify
  "git.allowNoVerifyCommit": true,
  // 指定各种编程语言使用的格式化器
  "[javascript]": {
    "editor.defaultFormatter": "esbenp.prettier-vscode"
  }
  // 简化写法目前优先级没用户级别设置优先级高,可能导致保存时不会触发 prettier
  // 具体查看:https://github.com/microsoft/vscode/issues/168411
  // "[javascript][javascriptreact][typescript][typescriptreact][vue][json][jsonc][html][css][less][markdown][xml][yaml][svg]": {
  //     "editor.defaultFormatter": "esbenp.prettier-vscode"
  // }
}

commit no verify

并发 lint 导致的问题

多个 lint 工具同时写就有并发问题,例如 eslint 行号报错对不上

最近的一些趋势

rust 化

一些 rust 编写的 lint 工具:

搞不好一条龙服务的 bun 也会加入 lint 战场。

ESLint 的作者也在 ESLint 的重写目标中指出部分组件会使用性能更好的 rust 语言实现:Complete rewrite of ESLint

esm 化

自从 sindresorhus 点燃了 ESM 大迁移的革命星火,三大前端工具目前还是没一个支持使用 ESM 格式的配置文件:

常见问题

为啥 eslint 可以检查代码格式还需要 prettier

也不一定需要,像 antfu 就不用 prettier,参考:Why I don’t use Prettier

他的主要观点包括:

  1. 它是 Opinionated,没啥配置项,一些 prettier 内置的代码风格我不认同,改不了。资本家说过:要么忍要么滚,他选择了…
  2. 从 eslint 的规则中剔除掉和 prettier 冲突的规则很麻烦

我是倾向于用的:

  1. Opinionated 挺好,大家都不用吵了,不用因为争论一行最多 80 个字符还是 100 而伤了和气
  2. 开箱即用,支持很多语言格式,达到同样的效果,配置 eslint 也需要很大的工作量,专业的事情交给专业的人做
  3. 我不觉得 eslint 的规则中剔除掉和 prettier 冲突的规则很麻烦,安装个 npm 包 eslint-config-prettier 我觉得不算麻烦

如何发现有用的 eslint rules

code review

日常给同事 code review 发现的很多代码风格问题都可以用 eslint 来约束。例如我最近发现有同事在 ts 代码里面隐式声明 any 变量:

let message;
if (xxx) {
  message = func();
}

像这个其实可以通过规则来实现:

module.exports = {
  overrides: [
    {
      files: ['*.{ts,tsx,vue}'],
      rules: {
        'no-restricted-syntax': [
          error,
          // 禁止隐式声明类型为 any 的变量
          // tsconfig 的 "noImplicitAny": true 不会处理这种情况
          // https://github.com/microsoft/TypeScript/issues/30899#issuecomment-1583132446
          {
            selector:
              "VariableDeclaration[kind = 'let'] > VariableDeclarator[init = null]:not([id.typeAnnotation])",
            message: 'Provide a type annotation.',
          },
        ],
      },
    },
  ],
};

再比如很多同事喜欢使用逻辑运算符替代条件判断:

boolVar && func();

我们可以使用规则:

{
'no-unused-expressions': [
            warn,
            {
                allowShortCircuit: false,
                allowTernary: false,
                allowTaggedTemplates: false,
            },
        ]
}

如果没有现成的插件,有能力的可以自己写一些 lint 规则来,例如我就写了一些:

有时间把这些都开源出来。

日常踩坑

之前有写过下面的 vue 代码:

const count = ref(0);
if (count) {
  // xxx
}

踩坑之后我就开启了 vue/no-ref-as-operand 规则。

关注一些 Lint 生态的大佬

  1. https://github.com/ota-meshi日本老哥,写了很多 ESLint 和 Stylelint 生态的东西
  2. https://github.com/ljharb 爱彼迎的老哥,tc39 成员,维护 airbnb 的那套 config 还有 eslint-plugin-import 等
  3. https://github.com/JounQin 柳家忍,一直很眼熟,最近才认出它的中文名
  4. https://github.com/fisker 应该也是国人,主要在项目 eslint-plugin-unicorn 很眼熟它,貌似在 sindresorhus 很多项目都能看到他。不知道中文名是哪位

多参与社区交流

例如最近太狼吐槽 AFFiNE 的 react 代码我就在下面评论了一个很有用的 eslint 规则: https://twitter.com/Brooooook_lyn/status/1666637274757595141

订阅一些仓库

你可以订阅 https://github.com/vuejs/eslint-plugin-vue 仓库的 release,以便知道最近新增了哪些 ESLint 规则。最近不是 vue 3.3 支持了泛型吗,预计可能最近就会新增一些和泛型相关的 lint 规则。

最近在看 pnpm 的 changelog https://github.com/pnpm/pnpm/pull/6617 时也发现一个很有用的 eslint 规则:no-await-in-loop

最简单快速的方法 - playground

ota-meshi 老哥开发的很多 eslint 插件都有 playground,你可以在 playground 里面把所有的规则都打开,然后写上你需要被检测出错误的代码,这样根据错误提示你就知道应该用什么规则了:

eslint playground

规则那么多,配置起来好麻烦

我刚入行的时候看到别人 eslint 配置长长列表也觉得头皮发麻,非常劝退。

首先 ESLint 提供了开箱即用的 eslint 初始化器:eslint --init。如果你还是嫌麻烦,可以使用别人封装好的配置,在 github 或者 npm 上搜索 eslint config 就可以搜到一堆。

为什么我不用 eslint loader

  1. 拖慢编译速度
  2. 已经有 IDE 提示了,我也不会一直盯着控制台看
  3. 代码风格问题不应该阻止你查看页面效果

怎样 debug 某个规则

在 debug eslint 配置的时候,应该简化复现环境,有一些有用的选项:

其实最简单粗暴的还是搞个最小的 reproduce 直接 debug 你怀疑有问题的那个插件的源码。

怎样测试性能

设置环境变量 TIMING=1

 TIMING=1 eslint plopfile.js
Rule                             | Time (ms) | Relative
:--------------------------------|----------:|--------:
compat/compat                    |    41.726 |    48.6%
json-schema-validator/no-invalid |    17.630 |    20.5%
import/order                     |     4.049 |     4.7%
unused-imports/no-unused-imports |     1.671 |     1.9%
n/no-deprecated-api              |     0.937 |     1.1%
vue/component-tags-order         |     0.846 |     1.0%
spaced-comment                   |     0.471 |     0.5%
node/prefer-promises/dns         |     0.408 |     0.5%
no-redeclare                     |     0.405 |     0.5%
no-unmodified-loop-condition     |     0.345 |     0.4%

怎样 lint 被忽略的文件

https://github.com/tjx666/vscode-fe-helper#other-useful-frontend-tools-commands

不一定要 eslint

tsconfig 有很多选项例如:strictnoImplicitAny 都是很有助于检测代码问题的。

很多 eslint 插件或者规则在使用了 ts 语言就没有了意义了,例如插件:https://github.com/ota-meshi/eslint-plugin-css

需要再提一下的是有些规则我更倾向于使用 eslint 而不是 ts 来做校验,例如 noUnusedLocalsnoUnusedParameters,因为 eslint 规则有选项的存在因而更加灵活。

相关工具