ESLint no-shadow 规则完全指南:消除变量遮蔽,让作用域边界清晰可读
【免费下载链接】eslintFind and fix problems in your JavaScript code.项目地址: https://gitcode.com/GitHub_Trending/es/eslint
ESLint 的no-shadow规则(suggestion类型)用于禁止局部变量遮蔽外层作用域中已声明的变量,帮助开发者避免因同名变量嵌套而引发的代码误读与难以追踪的赋值 bug。本文以 ESLint 仓库中 no-shadow 规则文档 为主体,结合 规则实现源码 与 完整测试用例,系统讲解遮蔽(shadowing)的判定逻辑、全部配置项(builtinGlobals、hoist、allow、ignoreOnInitialization及两个 TypeScript 专用选项)的语义与取舍,读完即可在 JavaScript 与 TypeScript 项目中精确配置并运用该规则。
什么是变量遮蔽(Shadowing)
变量遮蔽是指一个局部变量与它所在外层作用域中的某个变量同名。例如:
const a = 3; function b() { const a = 10; }此时b()内部的变量a遮蔽了全局作用域中的变量a。这种写法会造成两方面问题:
- 阅读代码时产生困惑:看到
a时难以立刻判断它到底指向哪一层作用域的声明; - 外层变量无法被访问:在被遮蔽的范围内,外层同名变量完全不可达,一旦需要读取外层值就只能通过改名或绕行方式处理。
no-shadow规则的目标正是消除这类被遮蔽的变量声明,从源头阻止同名变量跨作用域嵌套。
规则详情:违规与合规示例
启用方式(规则默认关闭,可在配置中设为error或warn):
/*eslint no-shadow: "error"*/以下代码都会被判定为违规:
/*eslint no-shadow: "error"*/ const a = 3; function b() { const a = 10; // 遮蔽外层 a } const c = function () { const a = 10; // 遮蔽外层 a } function d(a) { a = 10; // 参数 a 遮蔽外层 a } d(a); if (true) { const a = 5; // 块级作用域内遮蔽外层 a } const f = wrap(function f() {}); // 函数表达式名 f 遮蔽外层 f const C = wrap(class C {}); // 类表达式名 C 遮蔽外层 C注意最后两个例子:虽然函数/类表达式与赋值给它的变量同名通常被豁免(详见下文"初始化器豁免"),但当函数/类表达式不是直接作为初始化器(而是被包在wrap(...)调用中)时,仍然会报告遮蔽。
以下代码是合规的:
/*eslint no-shadow: "error"*/ const a = 3; function b(c) { const d = 10; // 不与外层任何变量同名 if (c) { const e = 20; // 同样不冲突 } } /* * 函数与类的名称允许遮蔽变量, * 前提是该函数/类表达式作为变量的初始化器或默认值被赋值。 */ const f = function f() {}; const g = foo ? (bar || function g() {}) : baz; const { h = function h() {} } = obj; function qux(i = function i() {}) {} const C = class C {};其中最后一段对应源码中的"初始化器豁免"逻辑:lib/rules/no-shadow.js中的isFunctionNameInitializerException()会检查内层变量是否为FunctionName(且节点是FunctionExpression)或ClassName(且节点是ClassExpression),并且该函数/类表达式必须是外层变量初始化器的顶层可求值表达式(通过unwrapExpression递归展开||、&&、??与?:等逻辑/条件表达式后判断),才会放行。
Options:全部配置项详解
规则接收一个对象作为唯一选项,包含以下属性:
| 属性 | 类型 | 默认值 | 适用语言 |
|---|---|---|---|
builtinGlobals | boolean | false | JavaScript / TypeScript |
hoist | string | "functions" | JavaScript / TypeScript |
allow | string[] | [] | JavaScript / TypeScript |
ignoreOnInitialization | boolean | false | JavaScript / TypeScript |
ignoreTypeValueShadow | boolean | true | TypeScript only |
ignoreFunctionTypeParameterNameValueShadow | boolean | true | TypeScript only |
上述默认值可以从 规则源码中的defaultOptions得到印证。完整的配置示例:
{ "no-shadow": ["error", { "builtinGlobals": false, "hoist": "functions", "allow": [], "ignoreOnInitialization": false }] }builtinGlobals:是否拦截内置全局变量
builtinGlobals默认为false。设为true后,规则会禁止遮蔽Object、Array、Number等内置全局变量。
违规示例({ "builtinGlobals": true }):
/*eslint no-shadow: ["error", { "builtinGlobals": true }]*/ function foo() { const Object = 0; }从源码实现看,遮蔽检测通过astUtils.getVariableByName(scope.upper, variable.name)在外层作用域查找同名变量,而shadowed.identifiers.length > 0 || (builtinGlobals && "writeable" in shadowed)这一条件允许规则在外层变量"没有标识符节点"(即内置全局对象)时,也能在builtinGlobals开启的情况下将其识别为被遮蔽对象,并抛出noShadowGlobal消息("'{{name}}' is already a global variable.")。测试中还覆盖了.d.ts声明文件中declare const遮蔽全局变量的场景(见 tests/lib/rules/no-shadow.js)。
hoist:控制"声明前置"遮蔽的判定
hoist选项共五个取值,决定了内层变量出现在外层声明之前时是否算作遮蔽:
functions(默认)—— 报告在外层函数声明之前出现的遮蔽;all—— 报告在外层变量/函数声明之前出现的所有遮蔽;never—— 永不报告外层变量/函数声明之前出现的遮蔽;types(TypeScript 专用)—— 报告在外层类型定义之前出现的遮蔽;functions-and-types(TypeScript 专用)—— 报告在外层函数与类型定义之前出现的遮蔽。
hoist: functions(默认)
违规示例:
/*eslint no-shadow: ["error", { "hoist": "functions" }]*/ if (true) { const b = 6; // 在外层 function b 声明之前出现 → 报告 } function b() {}尽管if语句中的const b出现在外层函数声明之前,它依然是违规的——因为函数声明会被提升(hoisting),规则认为内层声明遮蔽了函数。
合规示例:
/*eslint no-shadow: ["error", { "hoist": "functions" }]*/ if (true) { const a = 3; // 在外层 const a 之前出现 → 不报告 } const a = 5;因为外层是变量声明(const不会被提升到可访问状态),内层声明并未真正遮蔽它。这一语义对应源码中的isInTdz()函数:默认hoist: "functions"时,仅当外层定义节点类型是FunctionDeclaration且内层声明位置在其之前,才判定为遮蔽(即外层声明处于"暂时性死区"(TDZ)之中时放行)。
hoist: all
违规示例:
/*eslint no-shadow: ["error", { "hoist": "all" }]*/ if (true) { const a = 3; // 在外层 const a 之前 → 报告 const b = 6; // 在外层 function b 之前 → 报告 } const a = 5; function b() {}hoist: never
合规示例:
/*eslint no-shadow: ["error", { "hoist": "never" }]*/ if (true) { const a = 3; // 都在外层声明之前 → 不报告 const b = 6; } const a = 5; function b() {}因为if语句中的const a、const b都出现在外层声明之前,规则不再报告。在源码中,isInTdz()针对hoist: "never"之外的取值才会进行 TDZ 判定,因此hoist === "never"时这类"先内后外"的同名声明不会被计入遮蔽。
hoist: types(TypeScript 专用)
违规示例:
/*eslint no-shadow: ["error", { "hoist": "types" }]*/ type Bar<Foo> = 1; type Foo = 1; // 类型参数 Foo 遮蔽了外层类型 Foohoist: functions-and-types(TypeScript 专用)
违规示例:
/*eslint no-shadow: ["error", { "hoist": "functions-and-types" }]*/ // types type Bar<Foo> = 1; type Foo = 1; // 类型遮蔽 // functions if (true) { const b = 6; } function b() {} // 函数遮蔽这两个 TypeScript 模式在源码中由isInTdz()配合TYPES_HOISTED_NODES(TSInterfaceDeclaration与TSTypeAliasDeclaration)实现:当hoist为types时,外层定义若不是这两种会被提升的类型节点,就认定内层声明处于 TDZ 中从而放行;functions-and-types则额外把FunctionDeclaration也排除在外。测试用例覆盖了type Foo<A> = 1与interface Foo<A> {}遮蔽外层类型/接口的各种组合(见 tests/lib/rules/no-shadow.js 中hoist: "types"与hoist: "functions-and-types"分组)。
allow:按标识符名豁免
allow接受一个标识符名称数组,这些名字的遮蔽行为将被放行。常见用法是豁免回调风格代码中反复出现的"resolve"、"reject"、"done"、"cb"等名称。
合规示例({ "allow": ["done"] }):
/*eslint no-shadow: ["error", { "allow": ["done"] }]*/ import async from 'async'; function foo(done) { async.map([1, 2], function (e, done) { done(null, e * 2) }, done); } foo(function (err, result) { console.log({ err, result }); });实现上,isAllowed()直接检查allow.includes(variable.name)(见 lib/rules/no-shadow.js),命中则跳过该变量的遮蔽检测。
ignoreOnInitialization:容忍初始化期回调中的遮蔽
ignoreOnInitialization默认为false。设为true时,规则不再报告在变量初始化器中发生的遮蔽,前提是被遮蔽变量尚处于"未初始化"状态。判定条件较为严格:
- 被遮蔽变量必须位于左侧(即正在被初始化);
- 遮蔽变量必须位于右侧,且声明在回调函数或IIFE(立即执行函数表达式)中。
违规示例({ "ignoreOnInitialization": true }):
/*eslint no-shadow: ["error", { "ignoreOnInitialization": true }]*/ const x = x => x; // 遮蔽变量 x 遮蔽了"已初始化"的外层 x → 仍报告因为这里的箭头函数x => x立即被赋给x,遮蔽变量会遮蔽已经初始化的外层x。
合规示例({ "ignoreOnInitialization": true }):
/*eslint no-shadow: ["error", { "ignoreOnInitialization": true }]*/ const x = foo(x => x) // 回调中的 x 遮蔽了"尚未初始化"的外层 x → 放行 const y = (y => y)() // IIFE 参数同理 → 放行其背后的设计理由是:回调函数通常会在初始化过程中被调用,当遮蔽变量实际被使用时,外层被遮蔽变量尚未完成初始化,因此不存在真正的访问冲突。源码中isInitPatternNode()会向上回溯找到包裹回调的CallExpression,并确认该调用的右值范围落在外层变量声明(VariableDeclarator.init、AssignmentPattern.right、for...in/of右侧表达式等)之内,同时要求变量作用域是函数表达式/箭头函数且其外层作用域正是被遮蔽变量所在作用域。测试还覆盖了const a = [].find(a => a)、const [a = [].find(a => true)] = dummy、function func(a = [].find(a => true)) {}、for (const a of [].find(a => true)) {}及链式map/filter/find等多种形态。
ignoreTypeValueShadow:忽略类型与值的同名遮蔽(TypeScript)
默认为true。开启后,规则忽略"类型名与变量名相同"的遮蔽。这通常是安全的:在类型位置上,如果没有typeof运算符就无法引用变量,因此类型与值同名造成混淆的风险很小。
合规示例({ "ignoreTypeValueShadow": true }):
/*eslint no-shadow: ["error", { "ignoreTypeValueShadow": true }]*/ type Foo = number; interface Bar { prop: number; } function f() { const Foo = 1; const Bar = 'test'; }源码中的isTypeValueShadow()会对比两个变量的isValueVariable属性:当内层是值变量而外层是纯类型(或类型导入)时,判定为"类型/值遮蔽"并忽略;反之如果关闭该选项(false),这类情况会照常报告。测试中以ignoreTypeValueShadow: false配置验证了类型导入与值变量同名的违规场景。
ignoreFunctionTypeParameterNameValueShadow:忽略函数类型参数的遮蔽(TypeScript)
默认为true。函数类型的每个参数都会在函数类型作用域内创建一个值变量,以便之后用typeof引用该参数类型:
type Func = (test: string) => typeof test; declare const fn: Func; const result = fn('str'); // typeof result === string这意味着函数类型参数会遮蔽父作用域中的同名值变量:
let test = 1; type TestType = typeof test; // === number type Func = (test: string) => typeof test; // 这里的 "test" 引用的是参数,而非外层变量 declare const fn: Func; const result = fn('str'); // typeof result === string如果你不在函数类型返回类型位置使用typeof运算符,就可以安全地开启此选项。开启后(默认)以下代码合规:
/*eslint no-shadow: ["error", { "ignoreFunctionTypeParameterNameValueShadow": true }]*/ const test = 1; type Func = (test: string) => typeof test;实现上,isFunctionTypeParameterNameValueShadow()检查变量定义节点是否属于ALLOWED_FUNCTION_VARIABLE_DEF_TYPES集合(包含TSFunctionType、TSMethodSignature、TSDeclareFunction、TSConstructSignatureDeclaration、TSConstructorType等类型相关的函数签名节点)。测试用例覆盖了开启/关闭两种配置下函数类型参数遮蔽外层变量的大量组合。
常见疑问:为什么枚举成员与外层变量同名会被报告?
这不是 bug,规则在按预期工作。原因在于枚举的一个容易被忽略的特性:枚举成员会在枚举自身作用域内引入一个变量,从而允许成员无需限定符即可被引用。看这个例子:
const A = 2; enum Test { A = 1, B = A, } console.log(Test.B); // 会打印什么?直观上可能会认为是2(因为外层变量A的值是2),但实际打印的是1,即Test.A的值。因为在枚举内部,B = A会被解释为B = Test.A。正是由于这种行为,枚举成员A遮蔽了外层变量声明A,因此规则会如实报告。相应的测试用例也出现在 tests/lib/rules/no-shadow.js 中(const A = 2; enum Test { A = 1, B = A }被断言为违规并给出第 2 行的noShadow错误)。此外源码中isDuplicatedEnumNameVariable()会跳过"枚举自身名字"在枚举作用域内产生的重复变量,避免误报。
与 no-redeclare 的区别
需要特别注意:遮蔽(shadowing)特指两个相同的标识符位于不同的、嵌套的作用域中;而重复声明(redeclaration)指两个相同的标识符位于同一个作用域中,后者由独立的 no-redeclare 规则 负责。例如{ let a; } let a;这种跨块声明属于遮蔽范畴(受no-shadow管辖),而同一作用域内的重复声明则由no-redeclare处理——两者的分工边界清晰,配置时不应混淆。
相关规则与实战配置建议
no-shadow有一个强相关规则 no-shadow-restricted-names:它专门禁止遮蔽undefined、NaN、Infinity、arguments、eval以及(可选)globalThis等受限全局名称,且默认为recommended(随推荐集启用)。相比之下no-shadow覆盖面更广但默认关闭。实践中的常见搭配:
{ "rules": { "no-shadow": ["error", { "builtinGlobals": true, "hoist": "functions", "allow": ["resolve", "reject", "done", "cb", "err"] }] } }配置建议:
- 新项目 / 追求强约束:直接启用
no-shadow,并将builtinGlobals设为true以一并拦截Object、Array等内置全局的遮蔽; - 存量代码渐进改造:先用默认
hoist: "functions"+allow白名单放行回调惯用名,逐步消除报告后再收紧; - TypeScript 项目:保持
ignoreTypeValueShadow与ignoreFunctionTypeParameterNameValueShadow的默认值true,通常无需改动;只有当类型位置滥用typeof导致真实混淆时,才考虑将其关闭。
小结
no-shadow是 ESLint 中约束作用域整洁度的核心规则之一:它基于 eslint-scope 的作用域树,在Program:exit时遍历所有子作用域(见 lib/rules/no-shadow.js 中的checkForShadows与栈式遍历逻辑),逐变量检测是否遮蔽外层同名声明,并输出noShadow("'{{name}}'is already declared in the upper scope on line {{shadowedLine}} column {{shadowedColumn}}.")或noShadowGlobal消息。通过builtinGlobals、hoist、allow、ignoreOnInitialization以及两个 TypeScript 专用选项的组合,团队可以精确平衡"消除遮蔽带来的可读性收益"与"回调、初始化器等惯用法带来的噪音",让作用域边界在代码审查中一目了然。
【免费下载链接】eslintFind and fix problems in your JavaScript code.项目地址: https://gitcode.com/GitHub_Trending/es/eslint
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考