直接开门见山。不管你是刚入门还是写了几年业务代码,作用域这个概念永远绕不开——变量怎么找、函数怎么调、闭包怎么回事,底层全是作用域在管。网上讲作用域的资料非常多,但要么只讲定义不展开,要么一上来就扔一堆抽象术语。这篇我按自己的复习思路,从执行上下文、词法作用域、块级作用域、闭包到原型链,把作用域整个链路串起来讲清楚,适合基础不牢想查漏补缺的人,也适合准备面试想系统捋一遍的人。
1. 作用域到底是什么:先从执行上下文说起
1.1 全局作用域和函数作用域:最基础的两个地盘
作用域说白了就是一套规则,决定一个变量能在哪里被访问、在哪里访问不到。JS最基础的作用域有两层:全局作用域和函数作用域。在函数外部声明的变量进入全局作用域,整个程序的任何地方都能访问;在函数内部声明的变量则属于函数作用域,出了函数就查不到。
var globalName = 'global'; function foo() { var innerName = 'inner'; console.log(globalName); // global,可以访问 } console.log(innerName); // ReferenceError: innerName is not defined这段代码非常简单,但正好把全局作用域和函数作用域的边界画了出来。值得留意的是,函数作用域对函数内部声明变量这件事做了严格的“保护”,所以你在函数里随便声明的变量不会污染全局。这个设计在早期JS里很重要——团队协作时代,全局变量一旦多了就会互相覆盖,函数作用域相当于每个函数的私有空间。
1.2 词法作用域:JS选的是编译期就能确定的那条路
JS采用的是词法作用域,也就是静态作用域。所谓“词法”,是指作用域的划分发生在代码编译阶段,而非调用阶段。换句话说,一个函数能访问哪些变量,在你写代码的时候就由它在源码中的位置决定了,跟函数从哪里被调用没有关系。
这里有个经典例子,几乎每个讲作用域的文章都会提:
var value = 1; function foo() { console.log(value); } function bar() { var value = 2; foo(); } bar(); // 输出 1,不是 2foo是在全局作用域中定义的,所以它的父级作用域就是全局作用域。bar内的局部变量value对foo来说完全不可见,哪怕foo是在bar内部被调用的。这就是词法作用域和动态作用域最大的区别——动态作用域看调用栈,词法作用域看定义位置。
搞清楚这一点,很多“为什么结果是这样”的疑问就解开了一半。后面讲作用域链的本质时,还会再联系到这个特性上。
1.3 执行上下文:作用域在运行时的落地方案
作用域是规则,执行上下文是规则在运行时的实体。每进入一个函数,JS引擎都会为它创建一个执行上下文——这里面装着变量环境、词法环境、this绑定等信息。可以把执行上下文理解成“根据作用域规则生成的一个实际运行容器”,变量查找时就是在这个容器关联的作用域链上逐个去找。
全局代码会创建一个全局执行上下文,每次调用函数则创建一个新的函数执行上下文。这些执行上下文会被压入调用栈,栈顶的执行上下文是当前正在运行的。当函数执行完毕,它的上下文从栈中弹出,如果没有外部引用,相关的内存空间就可以被回收了。
function outer() { var a = 1; function inner() { var b = 2; console.log(a + b); } return inner; } var fn = outer(); fn(); // 3上面这段代码里,outer执行时会创建自己的上下文,inner被声明在outer内部,所以inner的词法环境指向outer。当outer返回inner并执行完毕后,outer的上下文虽然从调用栈弹出了,但inner依然拿着对outer作用域的引用,所以变量a依然能存活。这个机制直接导向了闭包。
2. var、let与const:三种声明方式背后的作用域差异
2.1 var的提升与函数作用域特性:一个必须正视的坑
var声明的变量属于函数作用域,并且有变量提升。变量提升的意思是,在代码执行之前,JS引擎会先把var声明的变量“提升”到所在作用域的顶部,并初始化为undefined。
console.log(name); // undefined,不会报错 var name = 'zhangsan';对于习惯了现代语言的开发者来说,这个行为确实反直觉。更麻烦的是var对块级代码几乎“视而不见”——if、for、while块内的var声明,实际上属于外层函数或全局作用域。
for (var i = 0; i < 3; i++) { // 循环体 } console.log(i); // 3,i 被泄露到了全局这个行为直接导致了前端圈流传已久的“for循环+var+异步回调”问题。后面有一节专门讲这个面试高频题,这里先记住结论:var只有函数作用域和全局作用域,没有块级作用域。
2.2 let与const引入的块级作用域:JS终于补齐的短板
ES6引入的let和const带来了真正的块级作用域。所谓块,简单理解就是一对花括号{}包起来的区域——if块、for块、while块,甚至单独的{}都算。let和const只在当前块内有效,块结束变量就不可访问。
if (true) { let inner = 1; } console.log(inner); // ReferenceErrorconst和let的作用域规则相同,区别只在const声明时必须赋值且不能重新绑定。这里还有一个容易忽略的点:for循环用let声明循环变量时,每次迭代都会创建一个新的绑定。正是这个特性,让经典闭包面试题的答案发生了翻天覆地的变化。
2.3 暂时性死区:let与const独有的编译期限制
与var不同,let和const声明的变量不会初始化为undefined,在声明语句执行之前访问它们会抛出ReferenceError。这段代码执行区间被称为暂时性死区(TDZ),是ES6规范里一个重要的行为约束。
{ console.log(a); // ReferenceError: Cannot access 'a' before initialization let a = 1; }从实际开发角度来说,TDZ是在逼你养成“先声明后使用”的好习惯。许多老代码升级到ES6后突然出现这类报错,原因就在于之前依赖var的“undefined宽容”掩盖了逻辑上的顺序问题。理解TDZ后,这类错误基本扫一眼就能定位。
3. 作用域链:变量查找的完整路径
3.1 作用域链的结构:从内到外的层级关系
作用域链不是一条孤立的线,而是一组作用域对象的有序集合。当前执行上下文的作用域链头部是自己的变量/词法环境,然后指向外部函数的变量环境,一层层向外,最终指向全局环境。变量查找时,JS引擎会从作用域链的最前端开始,逐一往后查,找到第一个匹配的变量就停止。
定义一个嵌套结构来看:
var globalVar = 'global'; function outer() { var outerVar = 'outer'; function inner() { var innerVar = 'inner'; console.log(innerVar); // 先从 inner 自身作用域找到 console.log(outerVar); // inner 没有,去 outer 作用域找 console.log(globalVar); // 再没有,去全局作用域找 } inner(); } outer();这段代码执行时,inner的作用域链是:inner自身环境 ->outer函数环境 -> 全局环境。链条越长,变量查找路径越长,但JS引擎对作用域链的查找优化做得不错,平时基本感知不到性能差异。真正需要关注的是链条设计是否合理,避免写出一个函数依赖外部无数层变量的糟糕代码。
3.2 变量遮蔽:内层变量与外层变量同名时的行为
当内层作用域和外层作用域存在同名变量时,内层变量会遮蔽外层变量。查找会优先命中内层,外层变量被“遮住”。
var name = 'global'; function test() { var name = 'local'; console.log(name); // local } test(); console.log(name); // global这种现象在代码中很常见,本身不算是错误,但容易造成阅读障碍。比如一个函数内声明的临时变量和外层全局变量重名,你很难一眼分辨当前操作的是哪一个。大的代码仓库里,这种遮蔽可能引发隐蔽的bug,所以好的做法是给变量命名时多加区分,或者严格限制全局变量的使用。
3.3 函数作用域与块级作用域在作用域链上的差异
var声明的变量只参与函数或全局作用域链,let/const声明的变量则额外参与了块级作用域的构建。这就导致同样的代码结构,换成不同声明方式后,作用域链的层次会有实质区别。
function test() { if (true) { var a = 1; let b = 2; } console.log(a); // 1,var 提升到了 test 函数的变量环境 // console.log(b); // ReferenceError,b 已随 if 块销毁 }在test的执行上下文中,a位于函数级变量环境,b位于if块级的词法环境。JS引擎查找时,会先查块级词法环境再查函数变量环境。这种设计把“临时变量只存活在需要的地方”这个理念落到了实处。
4. 闭包:作用域链连起来的高级玩法
4.1 闭包的本质:内层函数持有外层作用域的引用
闭包是JS中讨论最多也最容易混淆的概念之一。从作用域的角度看,闭包就是:一个函数在定义时捕获了外部作用域的变量,并在外部作用域执行结束后继续持有对这些变量的引用。
function createCounter() { let count = 0; return function () { count++; console.log(count); }; } const counter = createCounter(); counter(); // 1 counter(); // 2createCounter执行后,本应被回收的count因为被匿名函数引用而继续存活。匿名函数就是那个闭包,count是闭包捕获的变量。这里有个关键点:闭包捕获的是变量本身,不是变量的值。也就是说,闭包每次访问count时,拿到的都是最新值。
4.2 闭包的经典应用场景:从私有变量到防抖节流
闭包在业务代码里最常见的用途是创建私有变量。JS没有真正意义上的私有成员概念,但函数作用域+闭包可以实现类似效果:
function createPerson(name) { let _name = name; return { getName: function () { return _name; }, setName: function (newName) { _name = newName; } }; }外部只能通过返回的getName和setName访问_name,直接操作是不可能的。这种模式在封装组件状态、模块化设计里用得很多。
另一个高频场景是防抖和节流函数。防抖函数内部持有一个定时器变量timer,每次触发都会清除旧的定时器重新计时。这里的timer如果放在全局,容易被意外改写;放在闭包内部,就能保证只有当前创建的防抖实例能访问它。
function debounce(fn, delay) { let timer = null; return function (...args) { if (timer) clearTimeout(timer); timer = setTimeout(() => fn.apply(this, args), delay); }; }理解闭包的作用域本质后,再看这类工具函数,思路会清晰很多——无非就是“内层函数通过作用域链访问并维护外层函数中的状态”。
4.3 闭包与内存管理:容易被忽视的回收问题
闭包带来便利的同时,也带来了内存回收的隐患。因为闭包持有外部作用域的引用,外部作用域销毁后,闭包仍可能让其中的变量无法被垃圾回收。比如你创建了一个大的对象,然后闭包里引用了它,即使这个对象已经不再需要,只要闭包存活,对象就一直占着内存。
实际开发中常见的场景是事件监听器。绑定了闭包回调的事件,如果忘记解绑,闭包引用的数据就不会释放——
function init() { const bigData = new Array(1000000).fill(1); document.getElementById('btn').addEventListener('click', function () { console.log(bigData.length); }); }这里bigData被点击回调闭包引用,只要元素和监听器不解绑,bigData就一直在内存里。排查页面内存占用持续上涨时,优先检查这类闭包引用。
5. 作用域相关的常见问题与排查技巧实录
5.1 经典高频题:for循环中var与let为何结果不同
这道题几乎出现在所有JS面试里。先看最常见的版本:
for (var i = 0; i < 5; i++) { setTimeout(function () { console.log(i); }, 100); } // 输出:5 5 5 5 5原因不复杂:var声明的i是函数/全局作用域里的同一个变量。循环结束后i等于5,五个定时器回调等到触发时,都去作用域链上找同一个i,得到自然都是5。问题在于回调并不是在每次循环当前时刻执行,而是被推迟了。
把var改成let:
for (let i = 0; i < 5; i++) { setTimeout(function () { console.log(i); }, 100); } // 输出:0 1 2 3 4let为每次循环创建了独立的块级绑定,每个回调捕获的是自己那一轮的i值,互不干扰。这个改动背后正是块级作用域的差异,直接改变了结果。
如果面试官问你“不用let能不能实现”,常见的做法是立即执行函数表达式(IIFE)传入i:
for (var i = 0; i < 5; i++) { (function (n) { setTimeout(function () { console.log(n); }, 100); })(i); }手工创建函数作用域,把每一轮的i作为参数传进去,参数在函数内部形成独立绑定。这个技巧在ES6普及前是标配,现在遇到老代码依然能看到,值得掌握。
5.2 变量提升带来的隐性bug
变量提升除了面试,在实际代码里也会制造奇怪的bug。最典型的是在条件分支里用var声明变量,然后变量被意外地暴露到外层:
function func() { console.log(flag); // undefined,不是报错 if (true) { var flag = true; } }如果你只看代码逻辑,console.log(flag)在声明之前执行,会下意识觉得应该报错,但var的提升让flag提前存在并且是undefined。这种“不报错但结果诡异”的行为,比直接报错更难排查。养成用let/const的习惯后,这类问题基本绝迹。
5.3 排查作用域问题的三个实用方法
遇到作用域相关的诡异现象,我一般按下面三步排查,效率很高:
- 确认变量声明方式。先看这个变量是
var、let、const中的哪一种,直接决定它属于函数作用域还是块级作用域。 - 画出作用域链。在当前函数里从内到外,把每一层能访问到的同名变量列出来,找出实际命中哪一个。手写代码时作用域链不难画,哪怕画在纸上都管用。
- 用
console.log逐步验证。分别在当前作用域、外层作用域打印变量,观察是哪个值,再结合调用顺序判断是提升、覆盖还是闭包引用问题。
排查工具方面,Chrome DevTools的Sources面板里打断点后,右侧能看到当前的Scope列表,完整展示作用域链上的每一层变量,非常直观。遇到复杂的闭包引用问题,靠它比硬读代码高效得多。
6. 作用域链与原型链:两条容易搞混的链
6.1 作用域链和原型链的本质区别
很多初学者会把作用域链和原型链搅在一起,因为它们都带着“链”字,都涉及查找机制。但二者完全是两码事:
- 作用域链管的是变量的可见性,解决的是“这个变量我能不能访问”;
- 原型链管的是属性的继承,解决的是“这个对象的属性/方法从哪来”。
举个同时涉及两条链的例子:
var name = 'global'; const obj = { name: 'object', getName: function () { return this.name; }, getGlobalName: function () { return name; } }; const fn = obj.getName; console.log(fn()); // undefined(在非严格模式下实际是全局 name 的值) console.log(obj.getName()); // 'object' console.log(obj.getGlobalName()); // 'global'getName中访问的属性是this.name,查找走的是原型链/对象属性访问机制。getGlobalName中直接写name,查的就是作用域链——当前函数没有局部name,去外层全局作用域找。所以同一段函数体内,两种查找路径同时存在且互不干扰。
6.2 this绑定:调用方式决定指向,作用域链管不了它
this的绑定和作用域没有直接关系,它取决于函数的调用方式。简单调用模式下,往上看,它可能是全局对象;对象方法模式中,它指向调用对象;构造函数中指向新实例;箭头函数则不绑定this,而是继承定义位置的this。
const obj = { count: 10, getCount: function () { return this.count; }, getCountWithArrow: () => { return this.count; } }; console.log(obj.getCount()); // 10,this 指向 obj console.log(obj.getCountWithArrow()); // undefined,箭头函数继承外层作用域的 this日常开发中,this问题往往比作用域问题更难缠,因为它无法通过代码结构直接推断。我处理这类问题时会先问自己:这个函数是怎么被调用的?普通调用、方法调用还是构造调用?搞清楚调用方式,this的指向也就清楚了。
作用域这块内容复习到这里,差不多可以告一段落了。我实际写项目时发现,很多看起来莫名其妙的bug,追根溯源都是作用域理解不到位——比如变量提升造成的不报错、闭包引用导致的内存泄漏、var在循环里泄露带来的事情,搞清楚作用域机制后,写代码会主动避开这些陷阱,排查问题也有了一条清晰的思路。建议你找个周末,把这段内容配合几个小例子亲手跑一遍,比看十遍文章都管用。