ESLint 自定义规则实战:用 AST 分析封堵团队特有的代码坏味道

ESLint 自定义规则实战:用 AST 分析封堵团队特有的代码坏味道

一、通用 Lint 规则的盲区:为什么团队特有的"坏习惯"永远被漏掉

ESLint 有数百条内置规则和社区插件。no-unused-varsno-consoleprefer-const 这些通用规则覆盖了 80% 的常见问题。但每个团队都有自己特有的"代码坏味道",这些是通用规则永远无法覆盖的。

比如:你的团队约定 API 请求必须使用封装好的 request() 方法,不能直接用 fetch()。因为 request() 内置了认证 Token 注入、错误处理和日志上报。新人入职后可能不知道这个约定,直接用 fetch() 调接口。

又比如:你的团队规定组件文件夹结构必须是 ComponentName/index.tsx + ComponentName/style.module.css + ComponentName/types.ts。如果有人在组件目录下直接创建 ComponentName.tsx,应该被 lint 拦下来。

这些不是 bug,不会导致程序崩溃。它们是"代码坏味道"------不会立即造成问题,但长期积累会导致代码库一致性崩溃。ESLint 的自定义规则可以帮你自动化这些检查。

graph LR A[源代码] --> B[ESLint Parser] B --> C[AST 抽象语法树] C --> D[自定义规则 1<br/>禁止直接使用 fetch] C --> E[自定义规则 2<br/>强制组件目录结构] C --> F[自定义规则 3<br/>禁止特定 import] D --> G{匹配?} G -->|是| H[report + message] G -->|否| I[pass] E --> J{匹配?} E --> H E --> I F --> K{匹配?} F --> H F --> I style H fill:#ff6b6b,color:#fff style I fill:#51cf66,color:#fff

本文将手把手教你用 AST 分析编写 ESLint 自定义规则,覆盖三种真实场景。

二、ESLint 规则的底层机制:AST 遍历与 Context Report

ESLint 规则的本质是AST 遍历器。ESLint 使用 espree 将源代码解析为 AST(抽象语法树),规则的每个方法会在遍历到对应节点类型时被调用。

一个 ESLint 规则的生命周期:

  1. ESLint 读取源代码文件
  2. espree 将代码解析为 AST
  3. 遍历 AST 的每个节点
  4. 对于每个节点,调用匹配的规则方法(如 CallExpressionImportDeclaration
  5. 规则方法检查节点是否符合预期
  6. 如果不符合,调用 context.report() 报告问题

理解 AST 结构是编写规则的关键。推荐使用 AST Explorer 来可视化任意代码的 AST 结构。

三、三个实战规则:从简单到复杂

规则 1:禁止直接使用 fetch()

javascript 复制代码
// eslint-local-rules/no-direct-fetch.js
module.exports = {
  meta: {
    type: 'problem',
    docs: {
      description: '禁止直接使用 fetch(),请使用封装的 request() 方法',
      recommended: true,
    },
    messages: {
      noDirectFetch: '禁止直接使用 fetch()。请使用封装的 {{replacement}} 方法以确保统一的认证和错误处理。',
    },
    schema: [
      {
        type: 'object',
        properties: {
          replacement: { type: 'string' },
        },
        additionalProperties: false,
      },
    ],
  },

  create(context) {
    const options = context.options[0] || {};
    const replacement = options.replacement || 'request';

    return {
      CallExpression(node) {
        // 检查是否是 fetch(...) 调用
        if (node.callee.type === 'Identifier' && node.callee.name === 'fetch') {
          context.report({
            node,
            messageId: 'noDirectFetch',
            data: { replacement },
          });
        }

        // 也检查 window.fetch(...) 和 global.fetch(...)
        if (node.callee.type === 'MemberExpression') {
          const obj = node.callee.object;
          const prop = node.callee.property;
          if (
            (obj.name === 'window' || obj.name === 'global') &&
            prop.name === 'fetch'
          ) {
            context.report({
              node,
              messageId: 'noDirectFetch',
              data: { replacement },
            });
          }
        }
      },
    };
  },
};

规则 2:强制组件目录结构

javascript 复制代码
// eslint-local-rules/component-directory-structure.js
const path = require('path');

module.exports = {
  meta: {
    type: 'problem',
    docs: {
      description: '组件必须放在 ComponentName/index.tsx 目录下',
      recommended: true,
    },
    messages: {
      invalidStructure: '组件文件应位于 {{componentName}}/index.tsx 目录下,而不是 {{currentPath}}。',
    },
  },

  create(context) {
    const filename = context.getFilename();
    const dir = path.dirname(filename);
    const ext = path.extname(filename);

    // 只检查 .tsx 和 .jsx 文件
    if (!['.tsx', '.jsx'].includes(ext)) return {};

    // 检查是否在 components 目录下
    if (!dir.includes('components')) return {};

    const basename = path.basename(filename, ext);
    const parentDir = path.basename(dir);

    // 如果文件名和父目录名不同,且文件不是 index.tsx
    if (basename !== 'index' && basename !== parentDir) {
      // 检查是否存在同名目录
      const componentName = basename;
      
      context.report({
        loc: { line: 1, column: 0 },
        messageId: 'invalidStructure',
        data: {
          componentName,
          currentPath: filename,
        },
      });
    }

    return {};
  },
};

规则 3:禁止特定的 import 来源

javascript 复制代码
// eslint-local-rules/no-restricted-imports-extended.js
module.exports = {
  meta: {
    type: 'problem',
    docs: {
      description: '禁止从特定模块导入(支持动态匹配)',
      recommended: true,
    },
    messages: {
      restrictedImport: '禁止从 {{source}} 导入 {{name}}。原因: {{reason}}',
    },
    schema: [
      {
        type: 'array',
        items: {
          type: 'object',
          properties: {
            module: { type: 'string' },
            names: { type: 'array', items: { type: 'string' } },
            message: { type: 'string' },
          },
          required: ['module'],
          additionalProperties: false,
        },
      },
    ],
  },

  create(context) {
    const restrictions = context.options[0] || [];

    return {
      ImportDeclaration(node) {
        const source = node.source.value;

        for (const rule of restrictions) {
          // 支持正则匹配模块名
          const pattern = new RegExp(rule.module);
          if (!pattern.test(source)) continue;

          for (const specifier of node.specifiers) {
            const name = specifier.imported?.name || specifier.local.name;

            // 如果指定了 names,只检查列出的名称
            if (rule.names && !rule.names.includes(name)) continue;

            context.report({
              node: specifier,
              messageId: 'restrictedImport',
              data: {
                source,
                name,
                reason: rule.message || '请使用团队规定的替代方案。',
              },
            });
          }
        }
      },
    };
  },
};

.eslintrc.js 中使用:

javascript 复制代码
module.exports = {
  plugins: ['local-rules'],
  rules: {
    'local-rules/no-direct-fetch': ['error', { replacement: 'request' }],
    'local-rules/component-directory-structure': 'error',
    'local-rules/no-restricted-imports-extended': ['error', [
      {
        module: 'lodash',
        message: '请使用 lodash-es 以支持 Tree Shaking。',
      },
      {
        module: 'moment',
        message: '请使用 dayjs 替代 moment。',
      },
      {
        module: 'axios',
        names: ['default'],
        message: '请使用封装的 apiClient 替代直接导入 axios。',
      },
    ]],
  },
};

四、自定义规则的维护成本

性能影响:每个自定义规则都会增加 ESLint 的运行时间。如果规则在每个文件上做了大量 I/O 操作(如读取文件系统),会严重影响 lint 速度。建议为 I/O 操作添加缓存。

AST 兼容性:ESLint 的 AST 格式稳定,但如果你使用了 TypeScript 或 JSX,需要确保 parser 配置支持相应的语法。

规则的粒度:不要为一次性的事件编写规则。好的规则应该覆盖"可重复出现的模式"。

不适用场景

  • 代码风格偏好:如"用单引号还是双引号"------用 Prettier 处理
  • 对运行时逻辑的检查:如"这个 API 应该带 debounce"------这是 Code Review 的职责
  • 复杂的跨文件分析:ESLint 单文件规则的限制让它不适合跨文件检查

五、总结

ESLint 自定义规则的核心价值是将团队的隐性约定转为显性约束。当"每个人都应该知道"变成一个报错提示时,新人不再需要靠口口相传学习规范。

落地路径:先记录团队中反复在 Code Review 中被指出的问题(持续 2 周);找出其中有固定模式的 3 个问题;用 AST Explorer 分析代码结构后编写自定义规则;最后在 CI 中强制执行(error 级别),不允许绕过。

少即是多。不要为所有事情写规则------只为那些"反复出现且能自动化检查"的模式写规则。

相关推荐
dozenyaoyida1 小时前
LSTM 自编码器原理详解:从三门结构到 STM32 端侧异常检测落地
人工智能·stm32·lstm
石榴1 小时前
我用 Codex Vibe Coding 了一个 VS Code 数据库扩展:把付费墙后的工作流写成自己的工具
人工智能
ZZZMMM.zip1 小时前
信息压缩站-长文智能摘要的HarmonyOS开发实践
人工智能·华为·harmonyos·鸿蒙·鸿蒙系统
Black蜡笔小新1 小时前
自动化AI算法训练服务器DLTM零代码私有化赋能智慧农业用AI智能视觉守护万亩良田
人工智能·算法·自动化
气泡音人声分离1 小时前
音频处理核心原理:变速与变调的本质区别,以及“变速不变调”是如何实现的
人工智能·音频剪辑·升降调·音频变速
拓向AI2 小时前
南京高性价比DeepSeek优化推广指南
大数据·人工智能
ZX0X学习中2 小时前
一句话让 AI 生成 Excel 多文件合并工具:码道 CLI 实战
人工智能·excel·ai编程·华为云码道
AI小码2 小时前
WAIC 2026前瞻:AI产业进入拼落地的下半场
人工智能·算法·ai·程序员·大模型·编程·智能体
小企鹅么么2 小时前
【AI应用开发工程师】第六章:Context Engineering
人工智能·ai