☰
JavaScript全局变量与局部变量:从作用域到闭包一次讲透
2026/10/14 1:22:44 网站建设 项目流程

前两天帮一个新同学排查一个连续点击按钮却始终只返回最初数值的问题,折腾了十几分钟,最后发现罪魁祸首就是一个不起眼的变量声明:函数里顺手写了和全局变量同样的名字,又没有加let,结果所有操作都在改全局值。全局变量和局部变量,听起来是 JavaScript 最基础的知识点,可它们引发的 bug 和面试题,在我这几年带人的经历里一直居高不下。

这篇文章的目标很明确:把全局变量、局部变量,以及它们背后的作用域、变量提升、块级作用域、闭包这一整条链路讲透。不绕概念,每个部分都给可直接运行的示例和我在真实项目中踩过的坑,最后还会附上一份高频问题排查手册。无论你是刚接触 JavaScript 的初学者,还是想系统梳理基础的前端开发者,都可以把这篇当作一份实用手册来读。

1. 先看两个场景:作用域问题长什么样

1.1 场景一:函数内外同名变量,结果为什么会“打架”

先看一段看起来完全没问题的代码:

let number = 10; function changeNumber() { number = 20; } changeNumber(); console.log(number); // 20

这段代码输出20。函数里没有重新声明,只是给number赋值,所以它改的就是全局变量。

再看另一段,只差一个let:

let number = 10; function changeNumber() { let number = 20; } changeNumber(); console.log(number); // 10

这次输出10。函数内部的let number创建了一条全新的局部变量,它和外层的全局变量只是同名,没有任何关系。函数内部操作的是自己的局部变量,全局那个根本没动。

真实项目里这个问题会变得非常隐蔽。我之前在一个数据管理类的页面上遇到过:全局有一份userList,某个函数里的循环中写了userList = transform(userList),本意是处理局部临时数据,结果每一次操作都在改全局列表,导致页面其他模块同步刷新,接口被反复调用。问题根源就是作用域边界没分清。

1.2 场景二:for 循环里的定时器,为什么全是最后一个值

这是几乎所有前端都见过的经典场景:

for (var i = 0; i < 5; i++) { setTimeout(function () { console.log(i); }, 100); }

输出结果是5 5 5 5 5,而不是0 1 2 3 4。

原因在于:定时器里的回调函数并不会在循环过程中立即执行,等 100 毫秒后循环早就结束了,此时它访问的i还是同一个变量。var没有块级作用域,循环里的i其实属于外层函数或全局作用域,循环结束后它的值就是5,所以五个回调拿到的都是5。

如果把var改成let:

for (let i = 0; i < 5; i++) { setTimeout(function () { console.log(i); }, 100); }

输出就变成了0 1 2 3 4。原因是每次循环的{}都形成了一个独立的块级作用域,定时器回调捕获的是“当次迭代”中的那个i,而不是同一个共享变量。

这个例子把“作用域边界”和“闭包捕获”两个问题揉在了一起,所以值得在文章最前面拿出来。

2. 作用域到底怎么划分:全局变量与局部变量的边界

2.1 变量查找的本质是什么

作用域可以理解成一套“变量可见规则”。某个名字在哪些代码片段里能用,并不是运行时随机决定的,而是由代码书写的位置决定的。JavaScript 采用的是词法作用域,也就是说,函数嵌套在哪里,它就拥有哪一层的作用域链,和你从哪个位置调用它没有关系。

用一个生活类比:公司里每个工位抽屉里的东西,只有坐这个工位的人能拿,这是局部变量;公共区的柜子,整层楼的人都能拿,这是全局变量。如果某人在自己的抽屉里放了一个和公共区同名的收纳盒,他取到的永远是抽屉里的那个,而不是公共区的。这个“就近原则”,就是作用域查找的基本逻辑。

2.2 三种作用域层级:全局、函数、块

JavaScript 里常见的作用域可以粗略分为三类:

作用域创建位置可见范围生命周期
全局作用域脚本顶层或模块最外层整个程序都能访问页面或程序运行期间
函数作用域function内部函数体内以及内部嵌套函数函数调用期间
块级作用域{}内部,由let/const创建当前代码块代码块执行期间

全局作用域是最大的一层。在浏览器里,用var在顶层声明的变量会挂到window上,let和const则不会。全局变量的生命周期最长,从页面加载到页面关闭,它都一直存在。

函数作用域就是每个function自己圈出来的范围。函数参数也属于局部变量,在函数体内部生效。函数内用var声明的变量,无论写在函数体的哪个位置,整个函数体都能看到,这就是后面要说的变量提升。

块级作用域是 ES6 引入let和const之后才真正被广泛使用的。if、for、while这些语句的大括号,配合let/const,会产生独立的变量边界。这个特性让很多原本只能在函数级别控制的可变性,收缩到了更精确的代码块内。

2.3 嵌套作用域与查找链

看一个嵌套函数示例:

const globalVar = 1; function outer() { const outerVar = 2; function inner() { const innerVar = 3; return globalVar + outerVar + innerVar; } return inner(); } outer(); // 6

inner函数内部访问globalVar时,自己的作用域里没有,就到outer函数作用域里找,outer里也没有,就继续往外到全局作用域。每层作用域像一层抽屉,由内向外逐层翻找,找到第一个匹配的名字就停止。

这里有一个非常关键的结论:JavaScript 的作用域链是在函数定义时确定的,不是在调用时确定的。同一个函数,无论你从哪个地方调用它,它能看到的变量范围都不会改变。理解这一点,后面再看闭包就会顺畅很多。

3. var、let、const 怎么选:三个声明关键字的差异

3.1 变量提升:var 的“预声明”机制

先看这段代码:

console.log(message); // undefined var message = 'hello';

它不会报错,输出的是undefined。原因要把它拆开看:

var message; console.log(message); message = 'hello';

var的声明会被提升到当前函数作用域的顶部,但赋值操作留在原处,所以读取到的是undefined。这就是“变量提升”。

这里要特别说明:“提升”只是一个便于理解的比喻,并不是真的把代码移动了。更准确地说,是 JavaScript 引擎在编译阶段先扫描并登记了变量声明,真正执行到那一行时才做赋值。

这个机制会带来很多让人意外的情况,比如:

function test() { if (false) { var temp = 1; } console.log(temp); // undefined }

if条件为false,按理说temp不应该存在。但var temp被提升到了函数顶部,所以console.log(temp)不会报错,只会输出undefined。

let和const也有提升,但它们在初始化之前处于“暂时性死区”。同样在声明前访问:

console.log(value); // ReferenceError: Cannot access 'value' before initialization let value = 1;

结果直接抛 ReferenceError。这个行为比var更早暴露问题,也避免了“读取到一个幽灵undefined”之后继续错误执行。

3.2 块级作用域:let / const 如何让边界更安全

最简单的验证方式:

if (true) { let blockValue = 1; const blockConst = 2; } console.log(blockValue); // ReferenceError: blockValue is not defined console.log(blockConst); // ReferenceError: blockConst is not defined

let和const只在当前代码块内部有效。离开{},这个变量就彻底消失了。var则没有这个边界:

if (true) { var leakValue = 1; } console.log(leakValue); // 1

除块级作用域之外,重复声明也是两者的重要差异。var允许重复声明,后声明的会覆盖前面的:

var name = 'a'; var name = 'b'; console.log(name); // b

let和const在同一个作用域内不允许重复声明:

let age = 20; let age = 30; // SyntaxError: Identifier 'age' has already been declared

const还有一个特点:声明时必须初始化,而且变量绑定不能被重新赋值。

const pi = 3.14; pi = 3.14159; // TypeError: Assignment to constant variable.

不过const限制的是“绑定”本身,对象和数组内部的属性或元素仍然可以修改:

const config = { theme: 'dark' }; config.theme = 'light'; // 正常

把几个关键差异汇总成一张表:

声明方式变量提升块级作用域重复声明挂在全局对象上
var有,初始化为 undefined无允许是
let有,存在暂时性死区有不允许否
const有,存在暂时性死区有不允许否

3.3 用 var 在全局声明,为什么会影响 window

在全局作用域用var声明的变量,会成为全局对象的属性。在浏览器里,全局对象就是window:

var a = 1; window.a; // 1 let b = 2; window.b; // undefined

这个差异并不只是概念层面的。如果项目里有人写了一个var name,可能会直接覆盖浏览器自带的window.name;如果第三方脚本也定义了同名变量,后加载的脚本就可能影响前一个脚本的行为。let和const不会挂到window上,相当于把“模块内部变量”和“全局对象属性”这两类东西区分开,从根上减少了很多冲突。

我个人的选择原则很简单:默认用const,确定需要重新赋值的场景才用let,基本不再主动写var。在遇到老代码里的var时,先理解它的提升和共享行为,再决定是直接改掉还是保守处理。

4. 全局变量能不能用:污染、隔离与模块化方案

4.1 全局变量被滥用的三个典型问题

很多初学者会问:全局变量确实方便,为什么项目里都建议少用?

第一个问题是命名冲突。多人协作时,A 模块定义了list,B 模块也定义了list,后执行的代码会把前面的覆盖掉,数据结果完全不可控。这就像公共厨房的台面上摆满了一模一样的调料瓶,你根本分不清哪瓶是盐哪瓶是糖。

第二个问题是隐式耦合。模块 X 直接读写某个全局变量,模块 Y 也在读写同一个变量,排错时很难说清楚是哪一个模块在什么时机改坏了数据。更麻烦的是,这种依赖关系不会在代码里显式体现出来,排查问题只能靠猜。

第三个问题是测试困难。全局变量会跨用例保留状态,前一个测试产生的数据可能影响后一个测试的结果。单元测试要求用例之间尽量隔离,全局变量恰恰制造了共享状态。

4.2 隔离方案:命名空间、IIFE 与模块化

推荐方案一层层递进。

早期没有模块系统时,通常用“命名空间对象”来收敛全局变量:

const App = { version: '1.0.0', config: { theme: 'dark' } };

把多个变量收进一个全局对象里,散落在全局的名字就少了很多。但这种方式只是降低了污染数量,并没有真正隔离内部状态。

更近一步的是 IIFE,立即执行函数表达式:

const Counter = (function () { let count = 0; return { increment() { count += 1; return count; }, reset() { count = 0; } }; })(); console.log(Counter.increment()); // 1 console.log(Counter.count); // undefined

外部无法直接访问count,只能通过暴露出来的方法操作。这就是闭包隔离状态的经典应用,也是全局变量与局部变量知识在工程化中最直观的落地。

现在最推荐的是 ES Module。模块内部声明的变量天然是模块私有的,只有通过export显式导出的部分才能被外部访问,其他内容对全局没有任何污染。模块内的顶层变量虽然名字叫“全局”,但它只在模块作用域内可见,不会挂在window上。

4.3 哪些情况下刻意使用全局变量反而合理

我并不是“所有全局变量都该消灭”的绝对派。实际项目里经常有一些只读配置或平台级常量,用const声明在模块顶层,本质上就是模块内部的全局变量,安全且清晰。

真正挂在window上的全局变量,建议限制在几类场景:需要被传统脚本调用的入口方法、第三方 SDK 暴露的全局对象、以及调试用的开关。即使要用,也应该收敛到唯一的命名空间下,比如:

window.MyProject = window.MyProject || {}; window.MyProject.debugEnabled = false;

而不是零散地声明几十个window.xxx。这样至少能减少命名冲突,也为后续迁移到模块化留了余地。

5. 控制台实操:三步看清作用域链与闭包

5.1 准备一段可观察的测试代码

理论讲再多,都不如亲手在调试器里看一次。在浏览器开发者工具中新建一个脚本,粘贴这段代码:

const base = 100; function createAdder() { let step = 1; return function add(value) { return base + step + value; }; } const addOne = createAdder(); debugger; console.log(addOne(5));

打开开发者工具的 Sources 面板,刷新页面,代码会停在debugger那一行。

5.2 利用 Scope 面板观察三类作用域

在调试器的 Scope 面板中,通常能看到 Local、Closure、Global 三类信息:

  • Local 是当前函数内部作用域;
  • Closure 是外层函数因为闭包而被保留的变量环境;
  • Global 是整个程序都能访问的全局作用域。

单步执行进入add函数内部,你会看到value在 Local 中,step在 Closure 中,base在 Global 中。尽管createAdder已经执行完了,它的step仍然存在,这正是因为返回的add函数持有对createAdder词法环境的引用。

建议顺手做一个小改动:把let step = 1改成var step = 1,再观察一次。结果并没有本质变化,闭包捕获的依然是整个外层环境,而不是某个具体变量值。这一点能帮助你理解为什么很多人说“闭包捕获的是变量,而不是值”。

5.3 再测试块级作用域在调试器里的表现

用let写一个循环:

for (let i = 0; i < 3; i++) { setTimeout(() => console.log(i), 0); }

在调试器里逐步查看每一轮循环的作用域,你会发现每一轮都有一个独立的 Block 级作用域,里面保存着当次迭代的i。这就是块级作用域在引擎层面的真实形态。换成var之后再观察,只有一个共享的函数级环境,循环结束后i统一变成3。

这种观察方式比单纯背结论有效得多,建议面试或复习前自己动手跑一遍,印象会非常深刻。

6. 高频问题排查手册:面试和日常开发都逃不过

6.1 var 循环加 setTimeout 输出最后一个值

先说最经典的解法。

替代方案一:把var改成let,这也是现在最推荐的写法:

for (let i = 0; i < 5; i++) { setTimeout(() => console.log(i), 100); }

替代方案二:用 IIFE 包一层,把i作为参数传进去:

for (var i = 0; i < 5; i++) { (function (index) { setTimeout(function () { console.log(index); }, 100); })(i); }

每次循环时 IIFE 都会立刻执行,index被固定在当次调用的参数里,闭包捕获到的是各自独立的index,所以输出正确。

替代方案三:用forEach代替for循环,因为forEach每次迭代本身就是一次新函数调用:

[0, 1, 2, 3, 4].forEach(function (i) { setTimeout(function () { console.log(i); }, 100); });

实际开发中遇到老代码时,可能暂时没法全局替换var,可以用 IIFE 或forEach做局部修复。

6.2 函数内漏掉声明,为什么全局变量会被悄悄改

function demo() { result = 10; // 漏了 let 或 const } demo(); console.log(result); // 10(非严格模式)

在没有开启严格模式的普通脚本里,给未声明的变量赋值,JavaScript 会自动创建出一个全局变量。这个行为非常危险:一个函数内部的临时赋值,凭空污染了全局作用域。

解决方案是开启严格模式。在脚本顶部写:

'use strict';

或者直接使用 ES Module 的写法。严格模式下,给未声明变量赋值会立即抛出 ReferenceError,错误在发生的那一刻就暴露出来,而不是等页面数据莫名出错后再回头排查。这是我建议所有项目都默认开启严格模式的主要原因。

6.3 同名遮蔽与暂时性死区导致的取值错误

有一种情况很多人面试时会栽跟头:

const name = 'global'; function print() { console.log(name); const name = 'local'; } print();

这段代码会抛 ReferenceError,输出不了undefined,也输出不了global。原因是函数内部已经存在局部name的声明,它形成了对全局name的遮蔽;但const声明在初始化之前又处于暂时性死区,所以console.log(name)既访问不到外层全局变量,也不能读取到内层还没初始化的变量。

如果把const改成var:

function print() { console.log(name); // undefined var name = 'local'; }

输出undefined,因为var name提升到函数顶部,只是还没有赋值。如果完全没有局部声明:

function print() { console.log(name); // global }

输出global。一个例子,串起了变量提升、暂时性死区、作用域遮蔽三个核心概念,值得反复体会。

6.4 高频问题排查速查表

现象常见原因处理建议
循环里的 setTimeout 全部输出同一个值var没有块级作用域,回调共享同一个变量改用let,或使用 IIFE /forEach
函数内赋值后全局数据被修改函数内绕过了局部声明,操作了外层变量明确局部声明,或显式设计方案修改外部状态
函数内读取变量报 ReferenceError局部遮蔽或暂时性死区检查声明位置和初始化顺序
变量名冲突,后加载代码覆盖前加载代码全局变量过多,缺乏隔离使用模块化方案,减少挂载到window
重复声明变量不报错,结果出乎意料使用了var严格模式下改用let/const

7. 我沉淀下来的五个编码习惯

聊了这么多,最后分享几个我自己在实际项目中反复验证过的习惯,算不上什么高深理论,但能有效减少作用域相关的低级错误。

第一,默认const。只有当你明确知道这个变量在后面需要被重新赋值时,才改成let。var基本上可以退出日常书写了,除非在维护老代码。

第二,开启严格模式。不管写脚本还是项目代码,都默认带上。它能帮你拦截“隐式全局变量”这类坏行为,成本几乎为零。

第三,全局命名空间保持唯一。如果确实需要暴露少量全局入口,用window.项目标识这样的方式收敛,而不是散落几十个独立变量。

第四,函数内不要随手修改变量而不做声明。如果确定要修改外部状态,把意图显式写清楚,最好通过参数传入、返回值带出,避免隐式依赖。

第五,用 lint 工具或编辑器的提示把规范自动化。人的记忆力不可靠,但工具会坚持检查。团队里统一配置后,作用域相关的问题大部分在写代码阶段就会被标记出来。

如果你现在正被一段旧代码里的作用域问题折磨到怀疑人生,先别急着改业务逻辑,按这篇文章的思路把变量的声明位置、声明方式、嵌套层级逐个过一遍,大概率几分钟就能定位到真正的问题。JavaScript 全局变量和局部变量这个知识点,说到底是关于“边界”的学问——弄清楚变量在哪里存在、在哪里可见,很多莫名其妙的 bug 都会迎刃而解。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询