typescript-eslint 规则间共享逻辑的工程实践:从复制粘贴到可复用工具函数
【免费下载链接】typescript-eslint:sparkles: Monorepo for all the tooling which enables ESLint to support TypeScript项目地址: https://gitcode.com/GitHub_Trending/ty/typescript-eslint
本指南以 typescript-eslint 仓库的《Sharing logic between rules》贡献规范为骨架,系统讲解 ESLint 规则开发中如何识别重复逻辑、确定共享代码的放置层级、通过参数化合并近似重复,以及如何安全地将工具函数公开导出。读完本文,你将掌握一套可直接落地到packages/eslint-plugin规则开发与 code review 中的去重决策框架,并能准确复用 type-utils 与 utils 中已提供的现成能力。
为什么共享逻辑是一条硬性规范:漂移的两份副本
在 typescript-eslint 中,绝大多数 lint 规则都建立在 TypeScript 类型系统之上,天然会写出大量"查类型、看符号、走声明"的相似代码。规范的开篇描述了一个非常典型的故障场景:
两份相同的棘手类型逻辑各自演进——一份修了 bug,另一份没有;第二份的 bug 会在几个月后浮出水面。
这正是复制粘贴式开发在规则生态中的最大风险:逻辑漂移(logic drift)。两个文件里的代码看起来一样,但修复只落在其中一处,另一处继续带着旧 bug 运行,且由于两份代码形态相同,reviewer 很难发现它们已经不一致。因此,这份规范把"规则之间复制的逻辑应进共享工具"定义为必须执行的 change request(变更请求),而不是吹毛求疵(nit)。
判断标准:什么样的重复才值得抽取
规范明确划定了边界——目标是在规则之间重复的、非平凡的辅助函数,而不是泛泛的代码重复。识别信号有三条:
- 近逐字重复:它出现在本次 diff 中,同时在
packages/eslint-plugin/src/rules下的另一个规则里以近乎逐字的形式存在; - 解释性注释相同:它携带与"孪生副本"相同的说明性注释(说明这段逻辑连注释都是从同一处复制而来);
- 回答的是任何规则都可能问的问题——例如"这个类是否继承了某个指定基类?""这个表达式的优先级是否高于
await?"。
凡是命中这三条信号之一的逻辑,都属于"可能各自漂移成 bug"的候选,应当被抽取。规范特别强调反例:规则逻辑之外的重复样板不算数——多个包入口处相同的引入块、近乎相同的 CI 任务、平行的配置文件、重复的测试脚手架,在 type-utils 仓库中都是正常现象,不会在 review 中被提出。只有两份拷贝都是"可能漂移成 bug 的逻辑"时才需要标记。
放置层级:按作用域从小到大选择落点
一份被抽取的共享逻辑应该放在哪里?规范给出了按作用域递增的三级落点(源码中均有对应的真实目录):
| 放置位置 | 适用场景 | 仓库路径 |
|---|---|---|
| 规则辅助工具 | 仅服务于规则内部实现的 helper | packages/eslint-plugin/src/util |
| 类型工具 | 一切与ts.Type相关的逻辑 | packages/type-utils/src |
| 通用工具 | 插件使用者(如编写自定义规则的外部开发者)也需要的能力 | packages/utils/src |
这三级划分与仓库的包结构完全对应:type-utils专门沉淀类型层面的工具(如 isSymbolFromDefaultLibrary.ts、getTypeName.ts),utils面向规则消费者导出ParserServices、TSESTree等公共类型与工具,而eslint-plugin/src/util则是规则内部 helper 的汇集地——从目录内容可以看出,这里已经积累了getStaticStringValue.ts、getOperatorPrecedence.ts、isAssignee.ts、isHigherPrecedenceThanAwait.ts等数十个单一职责工具。
两份几乎相同的逻辑:用参数化合并
规范强调:几乎相同(almost the same)的两份 helper 仍然要去重——差别应当变成参数。书中给出了真实案例:两个规则都实现了"这个类型、或它的任一基类型,是否被允许?",一个按遗留名称列表匹配(no-base-to-string),另一个按TypeOrValueSpecifier匹配(restrict-template-expressions)。共享版本把差异抽象成matcher回调:
export function matchesTypeOrBaseType( services: ParserServicesWithTypeInformation, matcher: (type: ts.Type) => boolean, type: ts.Type, seen = new Set<ts.Type>(), ): boolean {两个调用点各自把"匹配条件"注入:
// no-base-to-string.ts matchesTypeOrBaseType( services, type => ignoredTypeNames.includes(getTypeName(checker, type)), type, ); // restrict-template-expressions.ts matchesTypeOrBaseType( services, type => typeMatchesSomeSpecifier(type, allow, program), type, );这一案例在仓库中真实存在并可验证:matchesTypeOrBaseType定义于 packages/eslint-plugin/src/util/baseTypeUtils.ts,no-base-to-string.ts与restrict-template-expressions.ts分别位于 rules/no-base-to-string.ts 与 rules/restrict-template-expressions.ts。从中可以提炼出两条通用模式:
- 把差异参数化:当两份实现仅在选择器、谓词或配置项上不同时,将变化部分提升为函数参数(这里是
matcher回调),公共的遍历/递归骨架只保留一份; - 成对函数应合并:"两个只以成对形式被调用的函数,应该是一个函数,而不是两个导出"——若两个导出从未被独立使用,合并它们能消除多余的 API 表面积。
值得一提的是seen = new Set<ts.Type>()这个默认参数:它防止基类型遍历进入环(例如继承自自身的类型)时无限递归,是这类"沿类型图行走"工具的标准防护写法。
优先伸手拿现成工具,而不是重新发明
去重的最高境界是"根本不需要写新代码"。规范用一张决策表给出了四类高频问题与官方推荐实现,每一行都可以在仓库源码中找到对应落点:
| 要回答的问题 | 推荐使用 | 不要用(原因) |
|---|---|---|
| 这个类型来自默认库吗? | isSymbolFromDefaultLibrary、program.isSourceFileDefaultLibrary | 文件名检查(./src/gotcha.lib.d.ts这类文件能骗过它) |
| 这个类型/值匹配用户配置的目标吗? | TypeOrValueSpecifier | 直接比较名称(可能只是巧合匹配) |
| 这个计算键(computed key)的值是什么? | getStaticValue、getStaticMemberAccessValue | 只处理字面量的手写实现 |
| 这是声明文件(definition file)吗? | TypeScript 自身使用的同一个表达式 | 近似判断(approximation) |
逐行对照源码可以看到这些工具的真实实现与意图:
- 默认库判定:isSymbolFromDefaultLibrary.ts 通过
program.isSourceFileDefaultLibrary(sourceFile)判断,即委托给 TypeScript 编译器的权威结论,而不是去猜文件名。注释里提到的反例gotcha.lib.d.ts说明:靠文件名含.d.ts或含lib来判断,很容易被用户自定义的、恰好以 lib 结尾的文件绕过; - 类型/值匹配:TypeOrValueSpecifier.ts 提供了
FileSpecifier(按声明文件路径)、LibSpecifier(按内建 lib 声明)、PackageSpecifier(按包名声明)三种描述方式,并以typeMatchesSpecifier/valueMatchesSpecifier及对应的SomeSpecifier变体完成匹配,匹配会落到"声明所在文件"这一层,而非表面名称; - 静态键求值:
getStaticValue、getStaticMemberAccessValue定义于 packages/eslint-plugin/src/util/misc.ts,它们会走 AST 静态分析路径,能处理比"字面量"更宽的情况(如常量折叠)。
这张表的深层含义是:名称比较是脆弱的(可能只是巧合),只有落到声明位置、默认库来源或类型构造层面的判断才可靠。写新规则前,先翻一遍type-utils/src和eslint-plugin/src/util的目录清单,往往能省下整个 helper。
新增工具必须同时迁移调用点
规范给出了一条硬性要求:没有调用者的 helper 是未经测试、未经证明的。因此,新增共享工具时,必须在同一个 PR 内找到它替换的既有代码并完成迁移——至少迁移那些简单直接的调用点。示例 diff:
- if (functionTSNode.type) { - const returnType = checker.getTypeFromTypeNode(functionTSNode.type); + if (functionNode.returnType) { + const returnType = services.getTypeFromTypeNode( + functionNode.returnType.typeAnnotation, + );这个 diff 同时演示了三条迁移要点:
- 新代码使用
services(ParserServicesWithTypeInformation)而非裸checker,这是 typed linting 时代推荐的类型获取入口; - 类型获取从"节点直取"变为"走
returnType.typeAnnotation",与 ESTree 化后的 AST 结构保持一致; - 迁移本身就是一个内置的冒烟测试:新工具只有被真实规则使用后,类型签名、边界行为(如
type缺失的兜底)才会被验证。
实践中若一次性转换所有调用点会让 diff 膨胀,规范允许只转换简单场景、把其余部分记录为 follow-up——但"简单场景必须当下转换"是底线。
公开导出的工具必须泛化
@typescript-eslint/type-utils或@typescript-eslint/utils的导出即公共 API,一旦对外发布,它就必须处理超出"催生它的那条规则"的需求。规范的表述很具体:
一个只覆盖
Identifier和JSXIdentifier的 helper,在内部使用没问题;一旦被导出,它就需要覆盖私有属性(private properties)、计算键(computed keys)以及其余情况——或者,收窄它的参数类型,让调用者无法传入它处理不了的东西。
这里给出两条可操作的策略:
- 扩展覆盖面:补齐
PrivateIdentifier、Literal(如getStaticName中对字符串字面量的处理)等节点类型,使工具对任意合法输入都能给出合理结果——TypeOrValueSpecifier.ts 中getStaticName对Identifier | JSXIdentifier | PrivateIdentifier与字符串字面量的分派就是一个范本; - 收窄参数类型:若泛化成本过高,就把入参类型收窄到工具确实支持的节点集合,让类型系统在编译期挡住不支持的情况,而不是运行时悄悄返回错误结果。
二者择一即可,核心原则是:导出即承诺,公共 API 必须对它的参数域完整负责。
例外清单:什么时候不去重
规范的最后一节是"允许不复用"的边界,同样重要:
- 不要抽取单行函数:"抽取逻辑的长度,要长到足以容纳一个 bug 才行"——一行代码没有漂移风险,抽取反而增加间接层;
- 小型、单一用途的 fixture 与测试辅助函数保持局部:它们只服务于特定测试,跨规则复用的概率为零;
- 不要无谓搅动无关规则:如果转换所有调用点会让 diff 失控,转换简单场景后把其余记为后续任务(follow-up);
- 需要三个开关才能统一的近似重复,是两条函数:当两份逻辑的差异如此之大,以至于合并需要三个 flag 来区分行为时,它们本质是不同的事物,合并只会制造不可读的"flag 面条代码"。
总结:一套可落地的决策流程
把全文收拢成规则开发与 review 时的行动清单:
- 问:这段逻辑是否在任何规则中都可能被问起?是否近逐字地存在于第二个规则中?→ 是则抽取;
- 选:按作用域放入
eslint-plugin/src/util→type-utils/src→utils/src的正确层级; - 合并:几乎相同的两份实现,把差异参数化(回调、谓词或配置);成对使用的函数合并为一个;
- 复用:先查 type-utils/src、eslint-plugin/src/util 是否已有等价实现,避免重新发明;
- 迁移:新工具同一 PR 内迁移至少所有简单调用点,让调用点充当验证;
- 泛化:凡对外导出,补齐覆盖面或收窄参数类型;
- 刹车:单行、局部 fixture、会膨胀 diff 的迁移、需要三 flag 的合并——这四类明确不做。
对 typescript-eslint 这样由上百条规则组成的 monorepo 而言,共享逻辑不是"整洁代码洁癖",而是防止类型逻辑悄悄分叉成两份 bug 的工程防线。遵循本文的决策框架,既能让规则实现保持精简,也能让type-utils、utils这些公共包里的能力被最大化复用。
延伸阅读(仓库内相关路径)
- 规则内部工具目录:packages/eslint-plugin/src/util
- 类型工具包:packages/type-utils/src
- 通用工具包:packages/utils/src
TypeOrValueSpecifier完整 API 文档:docs/packages/type-utils/TypeOrValueSpecifier.mdx- 参数化合并的真实案例:baseTypeUtils.ts、no-base-to-string.ts、restrict-template-expressions.ts
【免费下载链接】typescript-eslint:sparkles: Monorepo for all the tooling which enables ESLint to support TypeScript项目地址: https://gitcode.com/GitHub_Trending/ty/typescript-eslint
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考