ESLint 和 Babel 虽然都基于 AST(抽象语法树)工作,但它们的设计目的、工作流和 API 设计有着本质的区别。
- ESLint 插件实战:开发一个
eslint-plugin-clean-arch,用于强制执行“整洁架构”的依赖原则(例如:禁止 UI 层直接导入 DAO 层,必须经过 Service 层)。 - Babel 插件实战:开发一个
babel-plugin-auto-try-catch,用于在编译时自动给async/await函数包裹try-catch块,并上报错误信息,避免手动写大量重复代码。
第一部分:核心差异概览
在进入代码之前,先通过表格建立核心认知:
| 特性 | ESLint 插件 | Babel 插件 |
|---|---|---|
| 核心目标 | 代码质量检查与风格统一(Linting) | 代码转换与编译(Transpiling) |
| 输出结果 | 报告错误/警告,或进行源码级的字符串替换(Fix) | 生成全新的、兼容性更好的 JavaScript 代码 |
| AST 标准 | ESTree (使用 espree 解析) | Babel AST (基于 ESTree 但有细微差异,如 Literal 分类) |
| 遍历方式 | 扁平化的选择器遍历 (Selectors) | 访问者模式 (Visitor Pattern) |
| 修改能力 | 弱。主要通过 fixer 提供文本范围替换,必须保持 AST 有效性比较难 | 强。可以随意增删改查节点,生成完全不同的代码结构 |
| 运行时机 | 开发时(IDE提示)、提交时(Husky)、CI/CD 阶段 | 构建打包阶段(Webpack/Vite/Rollup 加载器中) |
第二部分:ESLint 自定义插件实战 (深度代码)
场景描述
在大型项目中,我们需要控制模块间的依赖关系。假设项目结构如下:
src/views(UI层)src/services(业务逻辑层)src/api(数据访问层)
规则:src/views 下的文件,禁止直接 import 来自 src/api 的文件,必须通过 src/services 调用。
1. 插件入口结构
通常定义在 index.js 中。
/**
* @fileoverview eslint-plugin-clean-arch
* 强制执行项目架构分层依赖规则的 ESLint 插件
*/
'use strict';
// 导入我们即将编写的规则定义
const restrictLayerImports = require('./rules/restrict-layer-imports');
// 插件主入口
module.exports = {
// 插件元数据
meta: {
name: 'eslint-plugin-clean-arch',
version: '1.0.0'
},
// 暴露配置预设(用户可以直接 extends: ['plugin:clean-arch/recommended'])
configs: {
recommended: {
plugins: ['clean-arch'],
rules: {
'clean-arch/restrict-layer-imports': 'error'
}
}
},
// 规则定义集合
rules: {
'restrict-layer-imports': restrictLayerImports
},
// 处理器(可选,用于处理非 JS 文件,如 .vue 中的 script)
processors: {
// 这里简单示意,通常 vue-eslint-parser 已经处理了
}
};
2. 规则实现核心 (rules/restrict-layer-imports.js)
这是最核心的部分,包含了 AST 分析逻辑。
/**
* @fileoverview 禁止跨层级直接调用
*/
'use strict';
const path = require('path');
// 辅助函数:标准化路径分隔符,兼容 Windows
function normalizePath(filePath) {
return filePath.split(path.sep).join('/');
}
// 辅助函数:判断文件属于哪个层级
function getLayer(filePath) {
const normalized = normalizePath(filePath);
if (normalized.includes('/src/views/')) return 'views';
if (normalized.includes('/src/services/')) return 'services';
if (normalized.includes('/src/api/')) return 'api';
return 'other';
}
/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
meta: {
type: 'problem', // problem | suggestion | layout
docs: {
description: 'Enforce strict layer dependency rules: Views -> Services -> API',
category: 'Architecture',
recommended: true,
url: 'https://my-company.wiki/arch-rules'
},
fixable: null, // 本规则不提供自动修复,因为架构调整需要人工介入
// 定义错误消息模板
messages: {
restrictedImport: '架构违规: "{{currentLayer}}" 层禁止直接引入 "{{targetLayer}}" 层模块。请通过 Service 层中转。',
invalidPath: '无法解析的导入路径: {{importPath}}'
},
// 规则配置 Schema
schema: [
{
type: 'object',
properties: {
// 允许用户自定义层级映射
layers: {
type: 'object'
}
},
additionalProperties: false
}
]
},
/**
* create 方法返回一个对象,该对象的方法名为 AST 选择器
* ESLint 遍历 AST 时会回调这些方法
* @param {import('eslint').Rule.RuleContext} context
*/
create(context) {
// 获取当前正在被 Lint 的文件名
const currentFilename = context.getFilename();
const currentLayer = getLayer(currentFilename);
// 如果当前文件不在受控层级中,直接忽略
if (currentLayer === 'other') {
return {};
}
// 定义层级依赖约束表
// Key: 当前层级, Value: 禁止引入的层级集合
const RESTRICTED_MAP = {
'views': ['api'], // View 层禁止引入 API 层
'services': [], // Service 层可以引入 API
'api': ['views', 'services'] // API 层通常是底层的,不应反向依赖
};
/**
* 核心校验逻辑
* @param {ASTNode} node - ImportDeclaration 节点
*/
function verifyImport(node) {
// 获取 import 的路径值,例如: import x from '@/api/user' 中的 '@/api/user'
const importPath = node.source.value;
// 忽略第三方库 (通常不以 . / @ 开头,或者是 node_modules)
// 这里简单判断:如果不是相对路径也不是别名路径,认为是 npm 包
if (!importPath.startsWith('.') && !importPath.startsWith('/') && !importPath.startsWith('@')) {
return;
}
// 尝试解析导入路径对应的实际层级
// 注意:在 ESLint 规则中做完整的文件系统解析比较重,
// 通常我们会根据字符串特征判断,或者依赖 resolver
let targetLayer = 'other';
if (importPath.includes('/api/') || importPath.includes('@/api/')) {
targetLayer = 'api';
} else if (importPath.includes('/services/') || importPath.includes('@/services/')) {
targetLayer = 'services';
} else if (importPath.includes('/views/') || importPath.includes('@/views/')) {
targetLayer = 'views';
}
// 检查是否违规
const forbiddenLayers = RESTRICTED_MAP[currentLayer] || [];
if (forbiddenLayers.includes(targetLayer)) {
context.report({
node: node.source, // 错误红线标在路径字符串上
messageId: 'restrictedImport', // 使用 meta.messages 中定义的 ID
data: {
currentLayer: currentLayer,
targetLayer: targetLayer
}
});
}
}
return {
// 监听 ES6 Import 语句
// 例如: import { getUser } from '@/api/user';
ImportDeclaration(node) {
verifyImport(node);
},
// 监听动态 Import
// 例如: const user = await import('@/api/user');
ImportExpression(node) {
// 动态 import 的 source 就是调用的参数
verifyImport(node);
},
// 监听 CommonJS require (如果项目混用)
// 例如: const api = require('@/api/user');
CallExpression(node) {
if (
node.callee.name === 'require' &&
node.arguments.length > 0 &&
node.arguments[0].type === 'Literal'
) {
// 构造成类似的结构以便复用 verifyImport
const mockNode = {
source: node.arguments[0]
};
verifyImport(mockNode);
}
}
};
}
};
3. 单元测试 (tests/rules/restrict-layer-imports.test.js)
ESLint 提供了 RuleTester 工具,非常方便进行 TDD 开发。
'use strict';
const rule = require('../../rules/restrict-layer-imports');
const { RuleTester } = require('eslint');
const ruleTester = new RuleTester({
parserOptions: {
ecmaVersion: 2020,
sourceType: 'module'
}
});
// 定义测试用例
ruleTester.run('restrict-layer-imports', rule, {
// 1. 合法代码测试 (Valid)
valid: [
{
// Service 层调用 API 层 -> 合法
code: "import { getUser } from '@/api/user';",
filename: '/Users/project/src/services/userService.js'
},
{
// View 层调用 Service 层 -> 合法
code: "import { getUserService } from '@/services/userService';",
filename: '/Users/project/src/views/UserDetail.vue'
},
{
// 引入第三方库 -> 合法
code: "import axios from 'axios';",
filename: '/Users/project/src/views/UserDetail.vue'
},
{
// 相对路径引用同层级文件 -> 合法
code: "import Header from './Header';",
filename: '/Users/project/src/views/Footer.vue'
}
],
// 2. 违规代码测试 (Invalid)
invalid: [
{
// View 层直接调用 API 层 -> 报错
code: "import { getUser } from '@/api/user';",
filename: '/Users/project/src/views/UserDetail.vue',
errors: [
{
message: '架构违规: "views" 层禁止直接引入 "api" 层模块。请通过 Service 层中转。',
type: 'Literal' // 报错节点类型
}
]
},
{
// API 层反向依赖 Views 层 -> 报错
code: "import router from '@/views/router';",
filename: '/Users/project/src/api/http.js',
errors: [
{
message: '架构违规: "api" 层禁止直接引入 "views" 层模块。请通过 Service 层中转。'
}
]
},
{
// 动态 Import 也要拦截
code: "const api = import('@/api/user');",
filename: '/Users/project/src/views/Home.vue',
parserOptions: { ecmaVersion: 2020 },
errors: [{ messageId: 'restrictedImport' }]
}
]
});
第三部分:Babel 自定义插件实战 (深度代码)
场景描述
前端开发中,异步操作如果不加 try-catch,一旦报错可能导致页面白屏。手动给每个 await 加 try-catch 很繁琐且代码臃肿。
目标:编写一个 Babel 插件,自动识别 async 函数中的 await 语句,如果它没有被 try-catch 包裹,则自动包裹,并注入错误上报逻辑。
转换前:
async function fetchData() {
const res = await api.getData();
console.log(res);
}
转换后:
async function fetchData() {
try {
const res = await api.getData();
console.log(res);
} catch (e) {
console.error('Auto Captured Error:', e);
// window.reportError(e); // 可以在插件配置中传入上报函数名
}
}
1. Babel 插件基础结构
Babel 插件导出一个函数,返回一个包含 visitor 属性的对象。
// babel-plugin-auto-try-catch.js
/**
* Babel Types 库提供了用于构建、验证和转换 AST 节点的工具方法
* @param {import('@babel/core')} babel
*/
module.exports = function(babel) {
const { types: t, template } = babel;
return {
name: 'babel-plugin-auto-try-catch',
// visitor 是访问者模式的核心
visitor: {
// 我们关注 FunctionDeclaration, FunctionExpression, ArrowFunctionExpression
// 可以合并为一个选择器 'Function'
Function(path, state) {
// 1. 如果函数不是 async 的,跳过
if (!path.node.async) {
return;
}
// 2. 如果函数体已经是空的,跳过
if (path.node.body.body.length === 0) {
return;
}
// 3. 检查函数体是否已经被 try-catch 包裹
// 获取函数体的第一条语句
const firstStatement = path.node.body.body[0];
// 如果只有一条语句且是 TryStatement,说明已经处理过或用户手动写了,跳过
if (path.node.body.body.length === 1 && t.isTryStatement(firstStatement)) {
return;
}
// 4. 获取用户配置的排除项 (例如排除某些文件或函数名)
const exclude = state.opts.exclude || [];
// 获取当前处理的文件路径
const filename = state.file.opts.filename || 'unknown';
// 简单的排除逻辑示例
if (exclude.some(pattern => filename.includes(pattern))) {
return;
}
// 5. 开始执行转换
// 核心逻辑:将函数体原来的内容,塞入 try 块中
// 步骤 A: 生成 catch 子句的 error 参数节点 (identifier)
// 使用 path.scope.generateUidIdentifier 防止变量名冲突 (例如防止用户原代码里已经有个变量叫 err)
const errorParam = path.scope.generateUidIdentifier('err');
// 步骤 B: 构建 catch 块的内容
// 这里我们可以根据配置,生成 console.error 或 reportError 调用
const reporterName = state.opts.reporter || 'console.error';
// 使用 babel template 快速构建 AST 节点,比手动 t.callExpression 更直观
// %%err%% 是占位符,会被替换为上面生成的 errorParam
const catchBodyTemplate = template.statement(`
${reporterName}('Async Error:', %%err%%);
`);
const catchBlockStatement = t.blockStatement([
catchBodyTemplate({ err: errorParam })
]);
// 步骤 C: 构建 catch 子句节点
const catchClause = t.catchClause(
errorParam,
catchBlockStatement
);
// 步骤 D: 构建 try 语句节点
// path.node.body 是 BlockStatement,包含 body 属性(语句数组)
const originalBodyStatements = path.node.body.body;
const tryStatement = t.tryStatement(
t.blockStatement(originalBodyStatements), // try 块内容
catchClause, // catch 块
null // finally 块 (可选)
);
// 步骤 E: 替换原函数体
// 注意:直接替换 body 可能会导致死循环(因为新生成的节点也包含函数体),
// 但这里我们要替换的是 Function 的 body (BlockStatement) 的内容,
// 或者直接替换 body 为包含 tryStatement 的新 BlockStatement。
path.get('body').replaceWith(
t.blockStatement([tryStatement])
);
// 标记该节点已被访问,避免递归处理死循环 (Babel 默认会重新访问新插入的节点)
path.skip();
}
}
};
};
2. 增强版 Babel 插件逻辑 (处理细节)
上面的版本比较粗暴(把整个函数体包起来)。但在实际中,我们可能只想包裹包含 await 的代码段,或者如果用户已经写了部分 try-catch 该怎么办?
下面是更精细的 AST 操作逻辑:
// 进阶工具函数:检查 BlockStatement 中是否包含 await 表达式
function hasAwaitExpression(path) {
let hasAwait = false;
// 使用 path.traverse 可以在当前路径下进行子遍历
path.traverse({
AwaitExpression(childPath) {
// 必须确保 await 是属于当前函数的,而不是嵌套在内部其他 async 函数里的
const parentFunction = childPath.getFunctionParent();
if (parentFunction === path) {
hasAwait = true;
childPath.stop(); // 找到一个就停止
}
},
// 防止遍历进入内部函数的陷阱
Function(childPath) {
childPath.skip();
}
});
return hasAwait;
}
// 修改 visitor 部分
visitor: {
Function(path, state) {
if (!path.node.async) return;
// 进阶优化:如果没有 await,其实不需要包裹 try-catch (虽然 async 函数报错会返回 reject promise,但这里假设只捕获 await 异常)
if (!hasAwaitExpression(path)) {
return;
}
// 处理 React/Vue 组件方法名排除
const functionName = path.node.id ? path.node.id.name : '';
if (['render', 'setup', 'componentDidCatch'].includes(functionName)) {
return;
}
// ... 后续转换逻辑同上 ...
}
}
3. Babel 插件单元测试
Babel 插件测试通常使用 babel-plugin-tester 或直接调用 @babel/core 的 transformSync。
const babel = require('@babel/core');
const autoTryCatchPlugin = require('./babel-plugin-auto-try-catch');
const assert = require('assert');
// 辅助测试函数
function transform(code, options = {}) {
const result = babel.transformSync(code, {
plugins: [
[autoTryCatchPlugin, options] // 加载插件并传入配置
],
// 禁用 Babel 默认生成严格模式,减少干扰
sourceType: 'script',
compact: false // 格式化输出代码
});
return result.code;
}
console.log('--- 开始测试 Babel 插件 ---');
// 测试用例 1: 普通 Async 函数转换
const code1 = `
async function getData() {
const res = await api.get('/user');
return res;
}
`;
const output1 = transform(code1);
console.log('[Case 1 Output]:\n', output1);
/*
预期输出:
async function getData() {
try {
const res = await api.get('/user');
return res;
} catch (_err) {
console.error('Async Error:', _err);
}
}
*/
assert.match(output1, /try \{/, 'Case 1 Failed: try block missing');
assert.match(output1, /catch \(_err\)/, 'Case 1 Failed: catch block missing');
// 测试用例 2: 箭头函数转换
const code2 = `
const doWork = async () => {
await sleep(1000);
console.log('done');
};
`;
const output2 = transform(code2);
console.log('[Case 2 Output]:\n', output2);
assert.match(output2, /try \{/, 'Case 2 Failed');
// 测试用例 3: 已经有 Try-Catch 的函数 (应跳过)
const code3 = `
async function safe() {
try {
await risky();
} catch (e) {
handle(e);
}
}
`;
const output3 = transform(code3);
// 输出应该和输入几乎一样(除了格式化差异)
// 我们通过判断 catch 块的数量来验证没有重复插入
const catchCount = (output3.match(/catch/g) || []).length;
assert.strictEqual(catchCount, 1, 'Case 3 Failed: Should not add extra try-catch');
// 测试用例 4: 自定义 Reporter 配置
const code4 = `async function test() { await fn(); }`;
const output4 = transform(code4, { reporter: 'window.reportToSentry' });
console.log('[Case 4 Output]:\n', output4);
assert.match(output4, /window\.reportToSentry/, 'Case 4 Failed: Custom reporter not working');
console.log('--- 所有测试通过 ---');
第四部分:底层机制深度对比
这部分解释为什么代码要这么写,这对于理解 1000 行级别的复杂插件开发至关重要。
1. 遍历机制:Scope (作用域) 管理
这是 Babel 和 ESLint 插件开发中最难的部分。
-
ESLint:
context.getScope()获取当前节点的作用域。- 主要用于查找变量定义(References)。例如:
no-undef规则就是通过遍历 Scope 中的 references 列表,看是否有未定义的变量。 - ESLint 的 Scope 分析是静态只读的。你不能在 lint 过程中修改 Scope。
-
Babel:
path.scope对象非常强大。path.scope.generateUidIdentifier('name'):自动生成唯一变量名(如_name,_name2),这在转换代码注入变量时必不可少(如上面try-catch中的err)。path.scope.push({ id: ... }):可以将变量声明提升到作用域顶部。- Binding:Babel 维护了极其详细的变量绑定信息。你可以通过
path.scope.bindings['x']找到变量x的所有引用位置(referencePaths)和赋值位置(constantViolations)。这使得做“死代码消除”或“常量折叠”成为可能。
2. 状态管理 (State)
-
ESLint:
- 状态通常保存在闭包变量中,或者
create函数的局部变量中。 - 因为 ESLint 是按文件处理的,
create每次处理新文件都会重新执行,所以闭包变量是文件隔离的。 - 如果需要跨文件信息(极其少见且不推荐,因为破坏缓存),需要用到全局单例。
- 状态通常保存在闭包变量中,或者
-
Babel:
- 状态通过
state参数在 Visitor 方法间传递。 state.file包含当前文件的元数据。state.opts包含用户在.babelrc传入的插件配置。- 可以在
pre()和post()钩子中初始化和清理状态。
- 状态通过
// Babel 状态管理示例
module.exports = {
pre(state) {
this.cache = new Map(); // 初始化文件级缓存
},
visitor: {
Identifier(path, state) {
// this.cache 在这里可用
}
},
post(state) {
// 清理
}
};
3. 节点构造与替换
-
ESLint Fixer:
- 基于文本索引(Index)。
- API:
replaceText(node, 'newText'),insertTextAfter(node, ';'). - 非常脆弱。如果你删除了一个逗号,可能导致后续的代码语法错误。ESLint 会尝试多次运行 fix 直到不再有变动,但它不保证生成的代码 AST 结构正确,只保证文本替换。
-
Babel Types:
- 基于对象构建。
- API:
t.binaryExpression('+', t.numericLiteral(1), t.numericLiteral(2))。 - 非常健壮。只要符合 AST 规范,Babel Generator 就能生成合法的 JavaScript 代码(自动处理括号优先级、分号等)。
总结
- 如果你的需求是 “阻止开发者提交烂代码” 或者 “统一团队的代码风格”,请选择 ESLint 插件。它的核心是 Context 和 Report。
- 如果你的需求是 “减少重复的样板代码”,“兼容低版本浏览器” 或者 “实现一种新的语法糖”,请选择 Babel 插件。它的核心是 Path、Visitor 和 Types Builder。
以上代码展示了从零构建一个架构级 ESLint 规则和一个编译级 Babel 转换插件的完整过程,涵盖了 AST 分析、上下文判断、节点构建和单元测试等核心环节。在实际工程中,这两者往往结合使用:Babel 负责把代码变样,ESLint 负责保证变样前的源码符合规范。