复习JS作用域的时候,我最深的一个体会是:它不像那些一眼看上去就难的语法,反而像房间里的空气——平时根本没人注意,一旦你写出的代码出现"这个变量怎么访问不到""那个值怎么就绑错了"的问题,最后揪出来的根因往往都是作用域没整明白。JavaScript里的作用域跟Java、Python不太一样,它是"词法作用域"为主,还叠加了变量提升、闭包、块级绑定这些机制,牵一发动全身。如果你打算把JS基础重新过一遍、准备面试、或者正在排查一些"玄学Bug",这篇文章可以直接当复习提纲用,我把作用域的类型、查找规则、常见坑和排查方法一次性拆开讲清楚。
1. 作用域不是"大括号",而是一套查找规则
1.1 四种作用域类型,先把概念理清
很多资料一上来就说"作用域就是变量起作用的区域",这话没错,但对解决实际问题帮助不大。我更喜欢把它理解成一套"变量查找规则":当你在代码里写下一个变量名,JS引擎要去哪里找这个变量的定义,找到哪一层就算哪一层。
JS里真正需要搞懂的作用域类型,我按初学者容易接受的顺序列一下:
- 全局作用域:代码最外层,没有包在任何函数或花括号里的变量,只要一运行就存在。浏览器里全局变量会成为window的属性,Node.js里则是global对象上的属性。
- 函数作用域:每次进入一个函数,内部用var、function声明的变量就属于这个函数的作用域,外面访问不到,函数内部可以随便用。
- 块级作用域:由成对花括号{}圈起来的区域,只有let和const声明的变量才认这个边界。if、for、while里边的块都算。
- 词法作用域:这是JS最底层的规则,讲的是"函数能访问哪些变量,在写代码那一刻就定死了,不看它被谁调用、在哪里调用"。
前三个说的是作用域长什么样,词法作用域说的是作用域怎么确定。它们不是并列关系,前三个是具体的形式,词法作用域是背后那条贯穿始终的设计原则。
1.2 用生活例子理解"词法作用域"
词法作用域这个词很容易把人吓住,其实就是一句话:你在哪写代码,你的作用域就在哪。就像你在公司三楼办公,你的工位能拿到的文件,取决于你工位所处的楼层区域,而不是你今天去几楼开会。
看这段代码:
var myName = "张三"; function printName() { console.log(myName); } function run() { var myName = "李四"; printName(); // 输出什么? } run();直觉上可能有人觉得输出"李四",因为printName是在run函数里被调用的,好像"近水楼台先得月"。实际输出是"张三",因为printName函数定义在全局,它"出生"的时候能访问的就是全局的myName,跟调用位置无关。
这就是词法作用域的精髓:函数的作用域在定义阶段就固定了,网上搜"js函数"相关的基础题,十道里有六道都藏了这种陷阱。搞懂这一点,你再看闭包、柯里化、事件回调,思路都会顺很多。
2. 变量提升与暂时性死区:JS特有的时间规则
2.1 var 与函数声明的提升机制
我第一次复习到变量提升的时候,觉得这名字起得特容易误导人。它不是指变量的值被提前了,而是指"声明被提前记录了,赋值还在原地"。JS引擎在执行代码之前会先做一遍编译登记,把var声明的变量和function声明的函数在作用域里预先占一个位置。
拿实际代码说话:
console.log(a); // undefined var a = 10;按照代码从上到下的执行逻辑,console.log在var a = 10之前运行,应该报"a is not defined"才对。但实际输出undefined。因为在预编译阶段,引擎已经知道这个作用域里有一个叫a的变量,只是还没赋值,所以默认值是undefined。
再看函数声明:
sayHello(); // 正常输出 Hello function sayHello() { console.log("Hello"); }函数声明是整个被提升的,包括函数体,所以可以在定义之前调用。这里正好回应很多同学问过的"js中函数是对象吗":函数本身是对象,但函数声明和变量声明在提升时的待遇完全不同,函数声明是整体提升,变量声明只提升名字不提升赋值。
一个特别容易翻车的是函数表达式:
foo(); // TypeError: foo is not a function var foo = function() { console.log("foo"); };这里报的是TypeError而不是ReferenceError,说明foo这个变量名在预编译阶段已经存在了,只是它的值是undefined,拿undefined当函数调用,自然就报"not a function"。
2.2 试着提前使用 let / const:暂时性死区
let和const就是为了修正var的这些问题而出现的。它们也有提升,但提升后的状态很特殊,称为"暂时性死区"(Temporal Dead Zone,简称TDZ)。
最简单的验证代码:
console.log(a); // ReferenceError: Cannot access 'a' before initialization let a = 10;这里JS不会输出undefined,而是直接抛错。你可能会问:既然let也提升,为什么不能在声明前访问?因为let和const声明的变量在预编译阶段会被登记,但在"声明初始化"这一行执行之前,它们处于一个不可访问的冻结状态——有点像占了一个房间号,但房间门是锁死的,还没放钥匙进去。这段锁死的时间就是暂时性死区。
这个特性对代码质量的好处很大:它逼着你"先声明,后使用",避免依赖那种看起来还能跑、其实全凭运气的顺序。
有一个隐蔽的例子,很多人在实际项目中踩过:
function test() { console.log(typeof value); let value = 1; } test();有人会觉得typeof一个值永远安全,typeof不会报错。但在暂时性死区里,typeof访问同样会抛ReferenceError。原因是value在块作用域里确实存在,只是还没初始化,typeof一样不能访问它。这个坑不亲手动一遍,光看书很难记住。
3. 执行上下文与作用域链:变量是这样被找到的
3.1 执行上下文与函数调用栈
理解了作用域规则之后,还要知道这些作用域在实际运行过程中是怎么被创建的。讲这个必须引入两个概念:执行上下文和调用栈。
执行上下文可以理解成"代码正在运行时的环境快照"——里面记录了当前作用域里有哪些变量、函数的this指向谁、外层能访问哪些内容。JavaScript代码开始运行时会先创建一个全局执行上下文,之后每调用一个函数,就会创建一个新的函数执行上下文。这些上下文遵循"后进先出"的原则堆在调用栈里,这就是为什么递归写不好会报"Maximum call stack size exceeded"。
函数执行上下文里有一个关键成员叫作"词法环境",它是当下作用域内所有变量和函数声明的登记表。这玩意听着抽象,你可以把它想象成一张物品清单:这一层函数里声明了哪些变量,对应什么值,全都记在这张表上。而且这张表上会有一个隐藏的outer指针,指向上一层的词法环境——正是这个指针,把一层层作用域串成了"作用域链"。
3.2 作用域链查找顺序:从内向外逐层找
来看一个层层嵌套的典型例子:
var globalCount = 100; function outer() { var outerCount = 200; function inner() { var innerCount = 300; console.log(globalCount + outerCount + innerCount); } inner(); } outer();inner函数内部找globalCount时,先看inner自己的词法环境,没找到,就顺着outer指针去outer的词法环境找,找到outerCount。再找globalCount,inner没有,outer也没有,就到全局执行上下文的词法环境里找,找到100。如果在全局也找不到,就抛出ReferenceError: xxx is not defined。
这个"从内到外逐层找"的链条就是面试题常说的作用域链。注意它是"逐层向外",不是"跳级",所以在内层声明同名变量时,会遮蔽外层变量:
var value = "global"; function test() { var value = "local"; console.log(value); // local } test();3.3 作用域链不是原型链
网上搜"js原型和原型链"时,经常有人把作用域链和原型链搞混。它俩名字都带"链",但完全不是一回事。
作用域链解决的是"变量名怎么查"的问题,是词法环境之间的outer关联,链上存的是变量对象。原型链解决的是"对象属性怎么查"的问题,是对象与构造函数之间的__proto__关联,链上存的是对象和原型。
区分它们有个很直观的方法:先说"这个变量是哪个",再问"这个对象有没有这个属性",前者走作用域链,后者走原型链。面试时如果被问到,先把这句话抛出去,再展开细节,基本不会被带偏。
4. 闭包的本质:函数记住了定义它的环境
4.1 一个最朴素的闭包例子
学完作用域链之后,闭包的概念就会顺理成章地浮现出来。闭包就是:函数在定义时的词法环境被保留了下来,所以即使函数离开了原来的作用域,它依然能访问原作用域里的变量。
最经典的实现方式是在一个函数里返回另一个函数:
function createCounter() { let count = 0; return function() { count += 1; return count; }; } const counter = createCounter(); console.log(counter()); // 1 console.log(counter()); // 2 console.log(counter()); // 3createCounter执行完之后,按理说它内部的count应该被释放了,但因为返回的函数还握着count对应的那个词法环境,count就会被继续保留下来。每次调用counter(),都能访问并修改外部函数留下的count。
这就是闭包最常见的坑和用处:一个本该"运行完就销毁"的局部变量,因为内部函数的存在而得以存活。
4.2 闭包的典型用途与常见提醒
闭包在实际工程里最常见的用途是"私有变量"和"工厂函数"。JS没有真正的私有关键字,但利用闭包可以模拟出外部只能读、不能改的变量:
function createUser(name) { let loginCount = 0; return { getName: function() { return name; }, login: function() { loginCount += 1; return loginCount; } }; }外面拿到的只是返回对象上的方法,没法直接碰loginCount,只能通过login方法间接操作。这种模式在很多框架源码里非常常见。
用闭包时有一个必须记住的提醒:不要为了"炫技"而滥用闭包。每创建一个闭包,就会把外层词法环境多保留一份,如果大量创建且没有释放引用,内存占用会持续上涨。尤其在循环里创建事件回调,要特别注意引用的是不是同一个变量。
5. var、let、const 怎么选:一张表加三个坑
5.1 三种声明的全面对比
复习作用域时,var、let、const的对比是绕不开的内容,这也是搜索引擎上"变量作用域"这个关键词背后最高频的需求。我整理了一张表,把关键差异一次看全:
| 特征 | var | let | const |
|---|---|---|---|
| 作用域类型 | 函数作用域 | 块级作用域 | 块级作用域 |
| 变量提升 | 有,提升为undefined | 有,但处于暂时性死区 | 有,但处于暂时性死区 |
| 重复声明 | 允许 | 不允许 | 不允许 |
| 修改值 | 允许 | 允许 | 引用不可变,对象内容可变 |
| 挂到全局对象 | 会 | 不会 | 不会 |
const的"不可变"说的只是绑定关系不能变。你const一个对象字面量,然后往对象上添加属性是完全没问题的,真正不能做的是让这个变量重新指向另一个对象。
5.2 for循环 + setTimeout 的经典翻车现场
这个坑几乎是面试必考、实战必踩,值得单独拿出来说。看完下面这段代码,截屏存好:
for (var i = 0; i < 3; i++) { setTimeout(function() { console.log(i); }, 0); }不少人预期输出0、1、2,实际输出的是3、3、3。原因是var声明的i属于整个函数作用域,循环三遍,i最终停在了3。setTimeout里的回调在宏任务阶段才会执行,那时候循环早就跑完了,回调们访问的都是同一个i,自然都是3。
换成let就变成了0、1、2:
for (let i = 0; i < 3; i++) { setTimeout(function() { console.log(i); }, 0); }因为let每次循环都会创建一个新的块级绑定,每个回调捕获的是自己那一轮的i。这也是为什么现在写循环代码,我默认用let而不是var。
5.3 用IIFE手动造出块级作用域
在let出现之前,前辈们是怎么解决上一节那个问题的?答案是立即执行函数表达式(IIFE)。它的思路是:既然var只有函数作用域,那我就给每个i单独包一层函数,把i作为参数传进去:
for (var i = 0; i < 3; i++) { (function(n) { setTimeout(function() { console.log(n); }, 0); })(i); }每次循环迭代都会创建一个独立的函数执行上下文,参数n被固化在各自的上下文里,回调访问n时拿到的就是对应那一轮的值。这个方法现在用得少了,但在老代码里非常常见,看懂IIFE对你理解闭包和执行上下文帮助极大。
6. 作用域相关的典型问题排查与复习清单
6.1 几个高频报错的排查思路
复习完理论,最怕的是考试时会背概念、写代码时抓瞎。我把实战中高频遇到的作用域问题整理成了一个排查速查表,你可以直接对照自己的报错信息来定位:
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| xxx is not defined | 变量根本没有声明,或声明在其他作用域 | 往上追查定义位置与作用域边界 |
| Cannot access before initialization | 访问了let/const的暂时性死区 | 把访问语句放到声明之后 |
| xxx is not a function | 变量存在但值是undefined或非函数 | 检查函数表达式是否在定义前被调用 |
| 回调里拿到的全是循环最后一个值 | var在函数作用域中共享一个绑定 | 改用let或IIFE |
| 外部想访问函数内变量 | 作用域不允许 | 重构返回值,用闭包暴露访问接口 |
遇到作用域相关Bug,我有一个非常笨但极度有效的排查办法:在关键位置用console.log打印这个变量,逐层注释代码,观察它从哪一层开始变undefined,就能定位到作用域的边界线。不用一上来就猜,JS引擎的报错信息在大部分情况下已经把方向指得很明确了。
6.2 我复习时的操作心得
复习作用域这个主题,光看文档不写代码,效果很差。我的建议是开一个空白HTML文件,把本文里的代码全部自己敲一遍,再改着玩。重点做两个实验:
第一个实验:把var换成let,看for循环输出变化。这个能直观体会到块级作用域的价值。
第二个实验:写一个函数工厂,每次调用都返回一个递增计数函数,观察多个计数器互不干扰。
还有一个非常实用的经验:学会用调试工具里作用域面板看作用域链。打开浏览器开发者工具,在回调函数内部打一个断点,右侧面板会列出当前作用域里的变量和它们所属的层级。我当年就是靠这个面板彻底搞懂闭包和词法环境的,它比任何教程都直观。
说到最后再分享一个扩展建议:作用域和闭包搞明白之后,可以接着复习"执行上下文里的this指向"和"事件循环里的异步顺序"。前者是作用域链之外另一条重要线索,后者能帮你看清setTimeout回调为什么会在循环结束之后才跑。把这三块串起来,JavaScript的基础体系差不多就立住了。