eslint-plugin-unicorn prefer-dispose 机制解析:如何安全地把 try/finally 资源释放转成 using 声明
【免费下载链接】eslint-plugin-unicornMore than 300 powerful ESLint rules项目地址: https://gitcode.com/GitHub_Trending/es/eslint-plugin-unicorn
eslint-plugin-unicorn 的prefer-dispose是面向 Explicit Resource Management(显式资源管理,using/await using声明)的 ESLint 规则:它识别"资源声明紧邻try、finally只为释放它"的写法,以编辑器建议的形式引导你改写成using声明。规则从不自动修复——因为不借助类型信息,无法确认对象真的实现了Symbol.dispose。
30 秒速览
- 解决什么问题:手写
try/finally释放资源容易漏写、多个资源释放顺序写错,且finally里释放器自己抛异常时会静默覆盖原始错误;using由运行时按逆序自动释放,并在释放器抛异常时用SuppressedError包装、保留原始错误。 - 触发条件:资源声明紧邻且连续地位于
try之前,finally的每条语句都是零参数close()/dispose()/destroy()/end()或[Symbol.dispose]()调用,且释放顺序符合 LIFO 逆序。 - 输出形式:
error级别报告(messageIdprefer-dispose/error)+ 编辑器建议(hasSuggestions: true),无自动修复(meta 里没有fixable)。 - 默认启用:否。
recommended与unopinionated配置均未启用,源码注释写明计划面向 Node.js 24 时再开。 - 最小适用场景:运行时原生支持
using(近期版本 Node.js / Chromium),或项目已用 TypeScript / Babel 转译该语法。 - 一句话本质:报告先于确认——无类型信息时只给启发式建议;开启 type-aware lint 后,会先确认类型可释放才开口。
为什么需要它
传统写法你肯定见过:
const file = open(); try { read(file); } finally { file.close(); }它有三个真实痛点:
- 容易漏:改造
try主体时忘了finally,资源直接泄漏; - 顺序错:多个资源要手动保证"后开的先关",声明顺序和释放顺序一错,依赖关系就崩了;
- 错误被吞:
file.close()自己抛异常时,try里的原始异常被释放异常直接覆盖,排障看不到根因。
Explicit Resource Management 一次性解决这三点:声明在块退出时自动释放;多资源按声明逆序(LIFO)释放;释放器抛异常时经SuppressedError包装,原始错误保留。该提案已达 TC39 Stage 4,近期版本 Node.js 与 Chromium 原生支持,更老的环境用 TypeScript 或 Babel 转译。
规则的职责因此很聚焦:找出所有能被using无行为变化替换的"纯释放"try/finally。难点全在"无行为变化"这几个字上,下面所有判定都围绕它。
判定边界:它认什么、不认什么
规则入口是TryStatement节点访问(rules/prefer-dispose.js),所有判定收敛为一个问题:这个try/finally能否被using等价替换?
会标记
✅ 单资源、finally仅一条零参数释放调用、声明紧邻其上:
const foo = open(); try { use(foo); } finally { foo.close(); }✅ 多资源,finally按声明逆序释放(与using的 LIFO 一致),依据 rules/prefer-dispose.js:
const foo = openFoo(); const bar = openBar(); try { use(foo, bar); } finally { bar.destroy(); foo.close(); }✅ async 函数内await释放,会生成await using,依据 rules/prefer-dispose.js:
async function main() { const foo = open(); try { await use(foo); } finally { await foo.close(); } }✅ 同步与异步释放混用,同时生成using和await using(测试用例 test/prefer-dispose.js):
try { use(foo, bar); } finally { await bar[Symbol.asyncDispose](); foo.close(); }✅let声明、catch并存、[Symbol.dispose]()写法、可选链foo?.close()均被识别,依据 rules/prefer-dispose.js:
let foo = open(); try { use(foo); } catch (error) { handle(error); } finally { foo?.close(); }不会标记
❌var声明:函数作用域会改变using的块级作用域语义,依据 rules/prefer-dispose.js:
var foo = open(); try { use(foo); } finally { foo.close(); }❌ 解构声明、无初始化器、export声明,依据 rules/prefer-dispose.js:
const {foo} = open(); export const bar = open(); let baz;❌ 释放调用带参数、计算字符串键,依据 rules/prefer-dispose.js 与 rules/prefer-dispose.js:
} finally { foo.close(true); foo['close'](); }❌finally混入额外语句、同一资源重复释放,依据 rules/prefer-dispose.js:
} finally { foo.close(); cleanup(); foo.close(); }❌ 声明与try之间夹了其他语句(不连续),依据 rules/prefer-dispose.js:
const foo = open(); doSomethingElse(); try { use(foo); }❌ 多资源按声明顺序(而非逆序)释放,建议会改变可观察的清理顺序,依据 rules/prefer-dispose.js:
} finally { foo.close(); bar.destroy(); }❌ 非 async 上下文(含类静态块)中的异步释放,依据 rules/prefer-dispose.js:
function nonAsync() { try { use(foo); } finally { foo[Symbol.asyncDispose](); } }❌ 资源在try之后被重新赋值或继续使用,依据 rules/prefer-dispose.js:
try { use(foo); } finally { foo.close(); } log(foo);内部机制拆解
规则按执行时序分六步,每一步要么缩小候选集,要么直接产出结论。
1. 入口闸门:TryStatement 与 finally 是否存在
输入:AST 访问到的try语句节点。处理:create注册context.on('TryStatement', …)(rules/prefer-dispose.js),findResources先检查finalizer,没有finally立即返回undefined(rules/prefer-dispose.js)。输出:不通过则该节点完全沉默。
为什么放第一步:规则只关心"释放可以被替换",没有finally的try/catch没有可迁移的释放语义,不值得再走后面的解析。
2. 把 finally 解析成"纯释放语句流"
输入:finalizer.body的全部语句。处理:逐条过parseDisposalStatement(rules/prefer-dispose.js),一条语句必须依次满足:是表达式语句;可被await(AwaitExpression)和/或可选链(ChainExpression,用于foo?.close())包裹;是零参数调用(argumentsLength: 0);调用对象是标识符;方法名命中白名单,或是Symbol.dispose/Symbol.asyncDispose计算写法:
const DISPOSAL_METHOD_NAMES = new Set(['close', 'dispose', 'destroy', 'end']);同层还有两条硬校验:finally出现任何一条非释放语句(含空finally)就整体放弃(rules/prefer-dispose.js);同一资源名出现两次也放弃(rules/prefer-dispose.js)。输出:按finally出现顺序排列的disposals列表。
为什么用白名单而不是"任意方法调用":建议的目标是把这些调用换成Symbol.dispose,方法名只是"这句是释放意图"的线索。若接受任意调用,foo.save()这类清理副作用会被误认成释放,生成错误建议。
一个细节:foo[Symbol.asyncDispose]()即使没写await也会被标记isAwaited = true(rules/prefer-dispose.js),因为它返回 Promise,using语义要求等待其完成。
3. 资源名解析回声明
输入:disposals。处理:resolveResource对每个释放名做作用域查找并校验(rules/prefer-dispose.js):
- 作用域内恰好一处定义(
variable.defs.length === 1,L130); - 定义是带初始化器的
VariableDeclarator,且声明名为标识符(排除解构)(L135-L142); - 关键字是
const或let,排除var(L148-L150); - 声明语句只有一个声明符,排除
const a = 1, b = 2;(L150); - 不是
export声明(L151); - 声明与
try位于同一块级作用域(L152)。
输出:含declaration、identifier、variable的resources列表。
这里有个坑:definition.kind由 espree 填充,但在@typescript-eslint/parser下是undefined,源码回退到definition.parent.kind(rules/prefer-dispose.js)。没有这个回退,规则在 TS parser 下会把所有声明误判掉。
4. 可转换性校验:连续性、LIFO、try 之后
输入:resources。处理:areResourcesConvertible顺序执行四项检查(rules/prefer-dispose.js):
- 连续性:资源声明必须占据紧邻
try之前的一整段连续位置,中间不允许任何其他语句(L171-L177); - LIFO:
finally的释放顺序必须等于声明顺序的逆序,否则建议会改变可观察的清理顺序,依赖资源可能被提前破坏(L166-L177); - try 之后:资源的任何写引用(初始化除外)或任何位于
try结束位置之后的引用 → 拒绝(L184-L189)。using是块级作用域,try一结束资源即释放; - async 上下文:只要有一个资源是异步释放,
try必须位于 async 函数内或模块顶层;isInAsyncContext沿父链上溯,遇StaticBlock直接返回false(静态块不允许await),遇Program则看sourceType === 'module'(rules/prefer-dispose.js)。
输出:布尔值。任一失败 →findResources返回undefined→ 完全不报告。
5. 类型感知确认:资源真的可释放吗
输入:resources+parserServices。处理:有类型信息(parserServices.program为真)时,areAllResourcesDisposable对每个资源调用getTypeAtLocation(identifier),检查属性列表里是否含Symbol.dispose(异步资源检查Symbol.asyncDispose)(rules/prefer-dispose.js)。源码直接匹配 TypeScript 内部转义名,而不调用typeChecker.symbolToString()——后者是已知会引发崩溃的调用之一:
const name = String(symbol.escapedName); return name.startsWith('__@dispose') || (isAwaited && name.startsWith('__@asyncDispose'));TypeScript 把 well-known symbol 成员存成__@dispose@9这类名字,字符串前缀匹配就够。getProperties()对联合类型解析为公共属性,所以Disposable | {close(): void}这种"有一个成员不可释放"的联合会被正确拦截。
输出:类型检查抛异常时(TypeScript 6 深处对跨模块 symbol 计算Cannot read properties of undefined (reading 'includes')的崩溃),catch兜底返回false,即"视为不可释放"(rules/prefer-dispose.js)。效果:type-aware 模式下,无法确认可释放的资源完全不报告(L196)。
为什么这一步必须存在:没有类型信息时,.close()只是一个名字,而using只认Symbol.dispose;仅有.close()的对象在运行时直接TypeError。宁可漏报,不可误导。
6. 产出报告、跳过建议检查与重写
输入:通过全部校验的resources。处理:消息里的keyword取await using(任一资源isAwaited)或using(L346);报告节点是TryStatement本身(L347)。附suggest之前还有两道检查(rules/prefer-dispose.js):
- 注释检查:
try块与catch处理器会被逐字保留,但finally块及}与finally之间的区域会被删除。存在这些位置的注释 → 只报告、不给建议;声明内部的注释因修复只替换关键字 token 而得以保留,建议照给; - 遮蔽检查:没有
catch时修复会解包try块合并作用域,若try块内声明了与资源同名的绑定,会与using foo冲突 → 只报告、不给建议(hasShadowingConflict,rules/prefer-dispose.js)。
重写(fixTryStatement,rules/prefer-dispose.js):
- 把每个声明的关键字 token 替换为
using/await using,并在第一个声明前打开一个新的{块(L269-L276); - 有
catch:保留try { … } catch (…) { … },仅删finally,再闭合新块(L294-L306); - 无
catch:把try块"解包"进新using块(L308-L330); - 保留文本整体右移一级缩进,但遇到多行模板字面量时放弃重缩进,避免改变其内容(L288-L292);换行符跟随文件原样(CRLF 场景由
getLinebreak处理)。
输出:带或不带suggest的problem。
边界情况与保守策略
源码里所有提前退出路径分三类,行为各不相同。
A. 完全不报告(findResources返回undefined,create静默 early-return,rules/prefer-dispose.js):
| # | 路径(行号) | 静默理由 |
|---|---|---|
| 1 | 无finally(L202) | 没有可替换的释放语义 |
| 2 | finally含非释放语句 / 空finally(L206-L219) | using只能表达纯释放 |
| 3 | 同一资源名出现两次(L221-L224) | using无法表达重复释放,语义不等价 |
| 4 | try不在BlockStatement/Program直接子级(L226-L229) | 新块打开位置会改变语句结构 |
| 5 | 变量不存在 / 作用域内多处定义(L130) | 无法确定改写哪条声明 |
| 6 | 无初始化器 / 解构 / 非标识符名(L135-L142) | 无法直接改写为using声明 |
| 7 | var/ 多声明符 /export/ 不在同块(L148-L155) | 作用域或模块语义会变 |
| 8 | 声明不连续 / 释放顺序非 LIFO(L171-L177) | 会改变可观察的清理顺序 |
| 9 | 资源在try后被重赋值或使用(L184-L189) | using的块级释放会早于原语义 |
| 10 | 异步释放但不在 async 上下文(L191-L193) | await using在函数式非 async 上下文是语法错误 |
| 11 | type-aware 下类型不含Symbol.dispose(L196) | 转换后运行时会抛TypeError |
B. 报告但不给建议(返回不带suggest的problem,rules/prefer-dispose.js):
| # | 路径 | 不给建议的理由 |
|---|---|---|
| 1 | finally内或}与finally之间有注释(L353-L358) | 修复会删除该区域,注释丢失;声明内的注释不受影响,建议照给 |
| 2 | 无catch且try块内声明同名绑定(L359,hasShadowingConflictL250-L259) | 解包合并作用域后与using声明冲突 |
C. fallback-catch 兜底:areAllResourcesDisposable整个类型检查包在try/catch里(L109、L122-L124)。TypeScript 6 内部崩溃时规则把资源当作"不可释放"处理,type-aware 模式下即不报告——宁可漏报,不让整次 lint 运行崩溃。源码注释明确:已测试的 TypeScript 版本没有已知输入会触发此崩溃,该分支是纯防御(L120-L121)。
最后一条全局保守策略:规则 meta 里没有任何fixable,只有hasSuggestions: true(rules/prefer-dispose.js)——即使所有条件都确认通过,最终决定也交给你在编辑器里"采纳建议"这一动作。误报的代价是运行时TypeError,这个成本规则不替你承担。
配置与启用
包的 peer dependency 要求 ESLint>=10.4。最小 flat config:
import unicorn from 'eslint-plugin-unicorn'; export default [ { plugins: {unicorn}, rules: {'unicorn/prefer-dispose': 'error'}, }, ];TypeScript 项目强烈建议同时开 type-aware,让规则验证Symbol.dispose:
import unicorn from 'eslint-plugin-unicorn'; import tsParser from '@typescript-eslint/parser'; export default [ { files: ['**/*.ts'], languageOptions: { parser: tsParser, parserOptions: {projectService: true}, }, plugins: {unicorn}, rules: {'unicorn/prefer-dispose': 'error'}, }, ];当前默认状态:recommended: false(rules/prefer-dispose.js),recommended与unopinionated均未启用。源码注释给出了未来启用条件:
Enable in the
recommendedconfig once we target Node.js 24, whereusing/await usingis supported natively.(rules/prefer-dispose.js)
也就是说,等项目目标运行时升到 Node.js 24,规则会自动进入recommended;在那之前,只能显式开启。⚠️ 没开 type-aware 就显式启用时,规则退化为纯方法名启发式,务必自己核对类型。
协作关系
- no-using-resource-escape(
recommended+unopinionated默认启用):一推一防。prefer-dispose负责把try/finally推向using;no-using-resource-escape负责拦住using声明的资源经return/export/ 函数捕获逃逸出作用域——资源在所属作用域退出时即释放,逃逸出去的引用拿到的是已释放对象。文档见 docs/rules/no-using-resource-escape.md。 - prefer-top-level-await:本规则在 TS parser 下的
kind回退处理(rules/prefer-dispose.js)注释中明确注明参考了该规则,两者共享"不假设definition.kind一定有值"的解析姿势。
实践清单
- □ 运行时是 Node.js 24+,或项目已用 TS/Babel 转译
using→ 显式开启'unicorn/prefer-dispose': 'error';否则别开,建议目标语法你的运行时会直接报错 - □ TypeScript 项目 → 必须搭配 type-aware lint(
projectService/project);无类型信息时规则只认方法名,仅有.close()的对象会被误建议,采纳后运行时TypeError - □ 资源需要条件释放,或
finally还承担日志、指标等职责 → 不采纳建议,保留手写try/finally,using表达不了这些 - □ 原
finally按声明顺序释放且顺序不可调换 → 保持原样;规则对这种 LIFO 不符的写法本来也不报告 - □ 建议输出拿不准 → 先核对资源类型是否声明了
Disposable/AsyncDisposable(或联合类型里每个成员都声明),存疑就拒绝该建议
速查表
| 规则名 | 类型 | 默认配置 | 是否自动修复 | 关键源码位置 | 测试文件位置 |
|---|---|---|---|---|---|
unicorn/prefer-dispose | suggestion | recommended/unopinionated均关闭;计划面向 Node.js 24 时启用(L385-L386) | 否,仅编辑器建议(L388) | 解析 L22-L76、校验 L163-L197、重写 L261-L331 | test/prefer-dispose.js(约 668 行,快照落 test/snapshots/prefer-dispose.js.snapshot);type-aware 用例夹具 test/fixtures/prefer-dispose/tsconfig.json |
【免费下载链接】eslint-plugin-unicornMore than 300 powerful ESLint rules项目地址: https://gitcode.com/GitHub_Trending/es/eslint-plugin-unicorn
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考