eslint-plugin-unicorn 的no-unreadable-for-of-expression规则:让for…of循环头保持可读的快照测试全解析
【免费下载链接】eslint-plugin-unicornMore than 300 powerful ESLint rules项目地址: https://gitcode.com/GitHub_Trending/es/eslint-plugin-unicorn
本篇文章以 eslint-plugin-unicorn 仓库中 no-unreadable-for-of-expression 规则快照文件 为核心,深入拆解该规则("Disallow unreadable iterable expressions infor…ofandfor await…ofloop headers")的完整判定边界:什么表达式会被视为"复杂"而报错、什么表达式被判定为"简单"而放行,以及 56 条 invalid 快照用例背后的源码实现原理。读完你将掌握该规则在 eslint-plugin-unicorn(规则索引、readme 规则表)中的行为模型,并能据此为自己的项目写出同样严谨的 lint 测试用例。
快照测试由 AVA 生成,实际快照保存在同名.snap文件中;本文引用的每条错误信息与定位(含行号、列号、错误文本)均直接来源于该快照文档与对应测试文件,可逐条对照验证。
规则背景:为什么要限制for…of循环头的复杂度
在for (const item of ???)中,???位置承载的是"每次迭代要取什么"的核心语义,它是循环最容易被扫读的区域。如果这里塞入一长串调用链、嵌套表达式或惰性迭代器组合,读者必须逐层解析才能弄清循环到底在遍历什么。
本规则的核心主张非常明确(见规则文档):让for…of和for await…of循环头易于扫读,复杂的可迭代表达式应提前提取为具名变量,再放进循环头。规则文档给出的典型正反对照是:
// ❌ 复杂表达式直接写在循环头 for (const item of getItems(createArgument(seed))) { console.log(item); } // ✅ 先提取具名变量 const argument = createArgument(seed); for (const item of getItems(argument)) { console.log(item); }// ❌ for (const key of Object.keys(Object.fromEntries(entries))) { console.log(key); } // ✅ const object = Object.fromEntries(entries); for (const key of Object.keys(object)) { console.log(key); }而下面这类"简单"形式则被明确允许(快照测试文件的valid列表也逐一验证了它们不会触发任何错误):
// ✅ 纯标识符 for (const item of items) { /* … */ } // ✅ 简单调用 for (const item of getItems(argument)) { /* … */ } // ✅ 简单参数的 Object.values for (const value of Object.values(object)) { /* … */ }从规则元数据看(规则源码),该规则类型为suggestion,recommended: true,即默认包含在 ✅recommended配置中,但不提供自动修复(fixer)——因为把表达式"提出循环头"需要人工命名变量,无法机械改写。规则当前声明支持js/js语言(languages字段),同时测试中通过 TypeScript parser 覆盖了类型断言场景。
快照测试的运行机制:错误定位如何被生成与保存
在深入逐条用例前,先理解快照文件是怎么来的。规则测试通过test.snapshot({valid, invalid})组织(见测试入口),快照渲染由 test/utils/snapshot-rule-tester.js 实现:
- 使用 ESLint 的
Linter对每条用例执行规则; - 对每个 lint 错误,调用
codeFrameColumns(来自@babel/code-frame)把行号、列号、报错文本渲染成带>标记的代码帧(visualizeEslintMessage); - 渲染结果写入
test/snapshots/*.js.md与.snap文件; - 当规则行为变更需要更新基线时,运行
npm run fix:snapshots(即ava --update-snapshots,见 package.json);日常跑测试则通过npm run test:js(即ava,package.json),AVA 的测试文件匹配规则配置在 package.json 的ava.files。
因此,快照文档中的每一行> N | <code>都精确对应错误在源码中的行列位置,^号标出的是"复杂表达式"的实际范围。这使它既是行为文档,也是回归测试的权威基线——任何对规则误报/漏报的修改都必须让这些快照保持一致(或显式更新)。
判定模型:什么是"简单可迭代表达式"
快照文档展示的 56 条 invalid 用例背后,是一套精心设计的"简单/复杂"判定逻辑。规则源码(rules/no-unreadable-for-of-expression.js)用三个嵌套函数界定"简单":
isSimpleProperty:Identifier或PrivateIdentifier这样的简单属性名;isSimpleMemberExpression:非可选链、非计算属性(!node.optional && !node.computed)且属性为简单标识符,并且其object本身是简单可迭代表达式的成员访问;isSimpleArgumentExpression:作为调用实参时,Identifier/ThisExpression/Super/Literal/TemplateLiteral/ 简单成员表达式 /无参调用(callee 为标识符或简单成员表达式)都被视为简单;isSimpleCallExpression:非可选调用,callee 为标识符或简单成员表达式,且每个实参都是简单实参表达式或"元素均为简单实参的数组字面量";isSimpleIterableExpression汇总:Identifier、ThisExpression、Super、ArrayExpression、Literal、简单成员表达式、简单调用表达式。
主处理函数create监听ForOfStatement节点([rules/no-unreadable-for-of-expression.js#L101-L123]):
- 先对
node.right调用unwrapExpression剥掉括号、TS 类型断言等包装; - 若满足"简单"条件直接放行(
return); - 否则报错,错误消息为:
Move the complex iterable expression out of the \{{loopKind}}` loop header.`
其中loopKind会根据node.await自动切换为for…of或for await…of(快照 invalid(45) 正是异步变体的消息形态),报错节点为node.right(即整个可迭代表达式)。
两个重要例外:重复循环与惰性迭代器
.map()/.filter()重复循环例外:shouldSkipDuplicateLoopCase识别"直接对数组调用.map(callback)/.filter(callback)"的形态;isDuplicateLoopCase进一步要求接收者不是迭代器表达式。也就是说,for (const item of items.map(callback)) {}这类会被no-duplicate-loops单独处理的写法,本规则有意放过,避免两个规则在同一行代码上重复报错(见规则文档)。- 惰性迭代器 helper 链例外:
isLazyIteratorHelperCall来自 rules/shared/iterator-helpers.js,它把drop/filter/flatMap/map/take视为"惰性迭代器 helper"(iteratorHelperMethods),并将Iterator.from(...).map(...)、items.values().take(1)、Iterator.zip(...).filter(...)等链式调用识别为迭代器表达式。结合isIteratorExpression,以下来源都会被认定为"迭代器表达式"而走例外逻辑:全局Iterator的concat/from/zip/zipKeyed静态方法、entries/keys/values实例方法、matchAll、同步生成器函数调用(isSynchronousGeneratorFunction)、不可变 const 绑定指向的迭代器(getImmutableValue),以及 TypeScript 类型标注为Iterator/Generator/IterableIterator等(isKnownIteratorTypeExpression)。
isIteratorExpression还通过getImmutableValue做常量折叠:只有当绑定是const声明、且未被再次写入(variable.references.some(reference => reference.isWrite() && !reference.init)判定无后续写引用)时,才会顺着初始化值继续推断——属性访问、解构、可变绑定和函数返回值故意不做推断,以避免误判。
快照用例逐类拆解:56 条 invalid 覆盖了哪些形态
快照文档按测试顺序编号invalid(1)到invalid(56)。以下按表达式的 AST 形态归类讲解,每条都可在快照文件中找到对应的精确行列定位。
1. 调用实参复杂:getItems(createArgument(seed))等
- invalid(5):
for (const item of getItems(createArgument(seed))) {}— 实参是一次嵌套调用,实参解析不满足"简单实参表达式"(无参调用才简单),整条调用被判复杂。 - invalid(6):
for (const item of getItems(argument || fallback)) {}— 实参是逻辑表达式,不在允许的简单实参类型集合内。 - invalid(54):多行书写
for (\n\tconst item of getItems(createArgument(seed))\n) {}— 判定与书写格式无关,快照精确定位到第二行的表达式列。 - invalid(45):
for await (const item of getItems(createArgument(seed))) {}—异步变体同样报错,且消息中的loopKind变为for await…of。
对应修复示范(规则文档与快照共同印证):getItems(createArgument(seed))应改写为
const argument = createArgument(seed); for (const item of getItems(argument)) { /* … */ }对比valid列表:getItems()、getItems(argument)、getItems(first, object.second, "third")、getItems(\argument`)、getItems(createArgument())、getItems(`prefix-${argument}`)、object.getItems(argument)` 均不报错——可见"简单"的门槛是:实参本身简单(标识符/字面量/模板字符串/简单成员/无参调用),或实参是数组字面量且每个元素都简单。
2. 接收者带逻辑/条件/嵌套的成员调用与索引
- invalid(8):
(header || "").split(",")—逻辑表达式接收者+ 方法调用,视为复杂。 - invalid(9):
(condition ? first : second).values()—三元条件接收者+ 方法调用,视为复杂。 - invalid(13):
for (const item of condition ? first : second) {}— 整个迭代表达式是三元表达式。 - invalid(14):
for (const item of first || second) {}— 整个迭代表达式是逻辑或表达式。 - invalid(10):
items[method]()—计算属性调用(node.computed为真),isSimpleMemberExpression直接拒绝。 - invalid(11):
for (const item of items[index]) {}— 成员访问用计算索引,同样不满足"简单成员表达式"。 - invalid(12):
items?.values()—可选链调用(node.optional),被isSimpleCallExpression/isIteratorMethodCall的optional约束排除。 - invalid(7):
Object.keys({a: 1})— 实参是对象字面量,不在简单实参集合内(快照注释也写明"Object-literal arguments … stay flagged")。 - invalid(15):
new Set(items)—new表达式不属于任何简单类型。 - invalid(16):
tag\items`` —带标签的模板字符串(TaggedTemplateExpression),视为复杂。
3. 嵌套调用链:Object.keys(Object.fromEntries(entries))
- invalid(17):
Object.keys(Object.fromEntries(entries))— 外层调用的实参本身是嵌套调用,不满足"无参调用才简单"的约束。修复方式见规则文档:先const object = Object.fromEntries(entries);再循环。 - invalid(18):
Object.values(items.filter(item => item.isEnabled))— 实参是带回调的.filter()调用(有实参、含函数表达式),视为复杂。 - invalid(19)/invalid(20):
items?.map(item => item.value)与items?.map(callback)— 接收者带可选链的.map(),被排除出"重复循环例外"(该例外要求optionalMember: false)。 - invalid(21)/invalid(22):
items.filter?.(item => item.isEnabled)与items.filter?.(callback)— 可选调用(optionalCall)形态的.filter(),同样不属于例外,因而报错。 - invalid(23)/invalid(24)/invalid(25):
globalThis.Iterator.from(items).map(callback)、(globalThis).Iterator.from(items).map(callback)、(Iterator).from(items).map(callback)— 这三条验证isGlobalIteratorReference的判定:全局Iterator引用必须是未遮蔽的全局标识符,或globalThis.Iterator(且globalThis也是全局引用);括号包裹不影响判定,但一旦Iterator或globalThis被局部变量遮蔽(见valid列表中的const Iterator = {from: …}、const globalThis = {Iterator}用例)就会被视为普通对象而不再被当作迭代器,此时.map(callback)会落入"重复循环例外"放行。
4. 原生迭代器 helper 链:Iterator.*与items.values()/keys()/entries()链
这是快照中最大的一类(invalid(26)~invalid(44)),印证规则文档的核心主张:原生迭代器 helper 链在循环头中被视为复杂。
Iterator.from(items).map/filter/take/drop/flatMap(...):invalid(26)~(32) 覆盖了map(item => item.value)、map(callback)、filter(...)、take(1)、drop(1)、flatMap(callback)。Iterator.concat(first, second).map(callback)(invalid(33))、Iterator.zip(iterables).filter(callback)(invalid(34))、Iterator.zipKeyed(iterables).take(1)(invalid(35))。- 多层链:
Iterator.from(items).take(1).map(callback)(invalid(36))。 - 数组实例迭代器链:
items.values().map(...)(invalid(37)/38)、items.values().filter(...)(39)、items.values().take(1)(40)、items.values().take(1).map(...)(41)、items.keys().map(...)(42)、items.entries().filter(...)(43)、items.matchAll(pattern).map(...)(44)。
这些形态之所以报错,是因为isLazyIteratorHelperCall要求接收者"是迭代器表达式",而isIteratorExpression对上述调用返回true,于是它们不满足"重复循环例外"(例外要求接收者不是迭代器表达式),又不满足简单表达式条件,最终落入报错分支。对比valid中的for (const [name, score] of Iterator.zip([names, scores])) {}与for (const item of items.map(callback)) {}:前者是"简单实参数组"且整体被判定为可读;后者因重复循环例外被放行——两者与上述链式形态形成鲜明对照。
5. 跨语句/跨作用域推断:常量绑定与生成器
- invalid(55):
const iterator = items.values(); for (const item of iterator.map(callback)) {}— 变量iterator的初始化值items.values()是迭代器表达式,且绑定满足getImmutableValue的不可变条件(单一 const 定义、无写引用),因此顺着常量折叠识别出.map(callback)的接收者仍是迭代器,报错定位在iterator.map(callback)处。 - invalid(56):
function * generate() { yield 1; } for (const item of generate().filter(callback)) {}— 通过isSynchronousGeneratorFunction(要求node.generator && !node.async,即同步生成器;异步生成器不在此列)识别出generate()返回同步生成器,其.filter(callback)被视为迭代器链而报错。
6. TypeScript 类型断言形态
快照 invalid(46)~(53) 覆盖 TS parser 下的各种类型包装,验证unwrapExpression对as与satisfies的剥离逻辑:
- invalid(46):
getItems(createArgument(seed)) as string[]—as断言剥掉后,内部仍是复杂调用。 - invalid(47):
(Iterator as typeof Iterator).from(items).map(callback)— 对Iterator本身做as断言后仍是全局引用,链式调用报错。 - invalid(48):
(Iterator.from(items) as Iterator<string>).map(callback)— 对Iterator.from(...)的结果做as。 - invalid(49):
Iterator.from(items).take(1) as Iterator<string>— 对take(1)链结果做as。 - invalid(50):
(items[index] satisfies Iterable<string>)—satisfies包装剥掉后,内部是计算索引访问。 - invalid(51)/52:
Iterator.zip([names, getScores(seed)] as const)、Iterator.zip([[names], [scores]] as const)—as const剥掉后,实参数组内含有复杂元素。 - invalid(53):
Iterator.zip([getNames(seed) as string[], scores])— 数组元素本身带as断言,剥离后仍是不满足简单条件的嵌套调用。
对应地,valid中items as string[]、items satisfies Iterable<string>、items!、getItems() as string[]、items.map(callback) as string[]、Iterator.zip([names, scores] as const)、Iterator.zip([names as string[], scores!])均放行——差异正在于剥掉类型包装后内部的表达式是否简单。
从快照反推的测试设计要点
这份快照文档本身就是一份极佳的 lint 规则测试用例设计范本,可总结出几条可复用的经验:
- 成对覆盖正反用例:每条 invalid 形态几乎都能在
valid列表中找到"仅差一个特征"的对照用例(如items?.values()vsitems.values()、Iterator.from(...).map(...)vs 遮蔽后的Iterator.from(...).map(...)),用于精确锁定判定条件。 - 覆盖语法变体:同步/异步(
for…ofvsfor await…of)、多行书写、括号包裹、可选链的成员与调用两个维度(?.valuevs?.())、全局引用遮蔽(const Iterator = …)、生成器函数、常量绑定跨语句推断。 - 覆盖语言扩展:通过
languageOptions.parser注入 TypeScript parser(测试文件),并利用快照框架的typescript(code)辅助函数批量生成用例,覆盖as、as const、satisfies、!非空断言。 - 快照即文档:由于快照渲染了精确的行列与消息,任何改动导致报错位置或消息变化都会被 AVA 捕获,天然形成行为契约。
小结
no-unreadable-for-of-expression用"简单表达式白名单 + 惰性迭代器链识别 + 重复循环例外"三层模型,精准划定了for…of循环头的可读性边界:标识符、简单成员、数组/字符串字面量、简单(无参或简单实参)调用、简单实参的Object.keys/values/entries放行;嵌套调用、逻辑/条件表达式、计算属性、可选链、new、带标签模板、对象字面量实参,以及各类原生迭代器 helper 链(含 TypeScript 类型断言包装)一律报错。快照文档 test/snapshots/no-unreadable-for-of-expression.js.md 以 56 条 invalid 用例和精确到行列的错误定位,完整记录了这一行为契约,既是回归测试基线,也是理解与复现该规则判定逻辑的第一手资料。若你想在团队中推行"循环头只放简单表达式"的规范,直接参考规则源码与其快照测试的用例组织方式即可。
【免费下载链接】eslint-plugin-unicornMore than 300 powerful ESLint rules项目地址: https://gitcode.com/GitHub_Trending/es/eslint-plugin-unicorn
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考