ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

JavaScript作用域与作用域链:从变量访问规则到闭包实战

JavaScript作用域与作用域链:从变量访问规则到闭包实战 1. 作用域是什么一套严格的变量访问规则JavaScript 入门通关手册做到第 12 篇前面我们已经把数据类型、运算符、条件语句、函数这些积木都过了一遍但你有没有想过一个问题为什么函数里面定义的变量函数外面访问不到为什么一个变量明明没在当前代码块里定义却也能直接用这背后的答案就是今天要聊的作用域与作用域链。作用域通俗讲就是变量和函数的有效使用范围而作用域链则是 JS 在查找变量时遵循的一条寻宝路线。很多同学语法都会一遇到嵌套函数就晕本质上是没搞懂这两个概念以及它们与 js 执行上下文之间的关系。这篇文章我会尽量把规则拆细配合能直接跑的例子把变量访问规则彻底讲明白适合学过 JS 基础语法、但一碰上作用域就发怵的同学。1.1 作用域的本质给变量划定活动范围先给作用域下个准确定义作用域是 JS 引擎在运行代码时用来确定当前这段代码能访问哪些变量、不能访问哪些变量的一套规则。你可以把它理解成一个房间的门禁系统——变量被创建在某个房间里你在这个房间里能拿到它出了门再想拿要么通过门禁授权比如函数返回值要么只能干瞪眼。为什么 JS 要设计这么一套规则最直接的原因有两个。一是防止命名冲突。团队协作时如果所有人都往全局栈里塞变量名叫data的变量究竟是谁的作用域把变量圈在不同的容器里大家各用各的互不干扰。二是让内存可以及时清洗。一个局部变量在函数执行完毕后JS 引擎就能把它标记为可回收避免内存无限制膨胀。注意一个关键点作用域是在代码编写阶段也就是 JS 引擎做词法分析时就已经确定的而不是在代码运行阶段才决定的。这句话是理解词法作用域的一把钥匙后面我会反复提到它。1.2 三类作用域全局、函数、块级在 ES6 之前JS 只有两种作用域全局作用域和函数作用域。这在当时是够用的但随着前端项目越来越复杂var声明变量污染全局的问题被不断放大。所以 ES6 引入了let和const同时带来了第三种作用域——块级作用域。全局作用域不在任何函数或花括号里声明的变量就属于全局作用域。在浏览器里用var声明的全局变量会自动挂到window对象上比如var a 1后window.a可以直接访问到1。函数作用域在函数内部使用var声明的变量只能在当前函数内访问。每个函数都是一个独立的封闭房间这样设计的好处是函数的内部实现细节不会泄漏到外界。块级作用域let和const声明的变量只在{}代码块内部有效这个代码块包括if语句块、for循环块、while语句块等。块级作用域的出现把变量的生命周期约束到了真正需要它的那几行代码内不再像var那样漏到整个函数甚至全局。下面这张表把三类作用域的核心差异列出来后面讲解都会以这张表作为参照作用域类型声明方式能否被外部访问典型使用场景全局作用域var / let / const 在顶层声明所有作用域内均可访问全局配置、常量函数作用域var 在函数内声明仅当前函数及其嵌套函数函数内部临时变量块级作用域let / const 在代码块内声明仅当前代码块内可访问for 循环计数器、if 分支内部变量理解这三层结构之后你就能回答很多入门阶段的为什么了为什么全局变量到处能用因为它处于最外层的大房间里为什么函数里的var变量循环外拿不到因为它被关在了函数这个房间里。2. 变量声明的三大法宝var、let、const 的访问规则差异理论清楚了接下来要落到实操。很多新手把var、let、const当成三种等价的写法这是个大坑。它们在作用域规则上的差异直接影响你的代码会不会悄悄undefined会不会在嵌套作用域里拿到一个早就该被回收的旧值。2.1 var 的函数作用域与变量提升先说var。var的作用域是函数级的意思是只要你在一个函数内写了var x 1那么在整个函数体内的任何位置都能访问到x。哪怕你是在if语句块里写的块外面照样能用因为if块不会给var划边界。这里面有一个非常经典的机制叫变量提升hoisting。看这段代码function test() { console.log(a); // undefined var a 10; console.log(a); // 10 }为什么第一次console.log(a)不报错因为 JS 引擎在执行这个函数之前会进行预编译把var a的声明部分提到函数作用域的顶部但赋值部分留在原地。所以上面的代码等价于function test() { var a; console.log(a); // undefined a 10; console.log(a); // 10 }这解释了怎么回事。但我要提醒一句变量提升虽然看起来方便但它非常容易掩盖真实的赋值顺序错误。我早期调试过一个 bug代码明明在很后面才赋值前面的分支里居然读取到了undefined而不是报错最后发现就是对变量提升理解不透导致的。2.2 let、const 的块级作用域与暂时性死区let和const是 ES6 之后的主力它们的行为比var严格得多。核心差异有两点。第一作用域限定在代码块里if (true) { let b 20; console.log(b); // 20 } console.log(b); // ReferenceError: b is not definedb只在if块里面有效。第二它们虽然没有传统意义上的变量提升但在声明之前的区域内访问会触发暂时性死区Temporal Dead ZoneTDZconsole.log(c); // ReferenceError: Cannot access c before initialization let c 30;这个设计是故意的它把变量未初始化就使用从静默 undefined变成了显式报错这比var的模糊行为健康得多。很多同学第一次看到这个报错会慌实际上这是 JS 在友好地提醒你别乱顺序。const和let的语法行为基本一致唯一的区别是const声明时必须赋值并且之后不能重新指向另一个值。注意不能重新指向说的是引用不变不是对象内部不可修改。如果你用const存了一个对象修改对象的属性是合法操作。3. 作用域链变量查找的寻宝路线图作用域解决了变量在哪有效的问题但真正干活的时候JS 引擎不可避免地会遇到这种情况我在函数 A 里写了一句console.log(name)函数 A 里没定义name怎么办引擎不会直接报错而是会顺着一条链路往外层作用域找这条链路就是作用域链。3.1 从内到外的查找顺序与规则作用域链的本质可以概括成一句话当前作用域优先找不到就往外层找一直找到全局作用域为止。它是 JS 引擎在解析标识符变量名、函数名时按照代码的嵌套层级一级一级向外构建出来的路标。举个例子var city 北京; function setCity() { var city 上海; function printCity() { console.log(city); } printCity(); } setCity(); // 输出什么答案是上海。printCity内部没有声明city所以引擎沿着作用域链先看printCity自己的作用域没有再看外层setCity的作用域找到了city上海就直接使用不再往外层全局找。这个就近原则很多人会忽略以为全局变量一定会被读到其实局部变量一旦存在它就像遮阳伞一样挡住了外层同名的变量。规则里还有一个重要细节如果找到全局作用域都找不到这个变量引擎在读取操作时会抛出ReferenceError: xxx is not defined。但如果你给一个不存在的变量赋值比如在非严格模式下newVar 1JS 会自动在全局创建这个变量这是内网项目里常见的隐式全局变量污染来源后面我会专门讲怎么避坑。3.2 词法作用域代码写在哪就属于哪个作用域作用域链建立的一个重要依据是词法作用域也叫静态作用域。意思是一个函数属于哪个作用域是由它在代码里书写的位置决定的而不是由它被调用的位置决定的。这句话是理解嵌套函数、闭包的命门我单独拿出来讲。看一个经典对比var x 全局; function outer() { var x 外层; function inner() { console.log(x); } return inner; } function run() { var x run函数; var fn outer(); fn(); // 输出什么 } run();inner是在outer函数内部书写的所以它的作用域链在代码定义那一刻就确定了inner 自身作用域 - outer 作用域 - 全局作用域。即使在run函数里通过返回值调用innerrun里定义的xrun函数也不会出现在inner的作用域链上最终输出外层。这里我当初学的时候也犯过迷糊总以为在哪里调用就跟哪里有关实际上完全不是。你只要记住看代码别看调用栈。这个特性有个直接推论如果一个函数被定义在全局那么不管它被传到哪里调用它的作用域链起点永远是全局。4. 执行上下文让作用域链真正跑起来的引擎机制作用域链是静态的规则清单但规则什么时候生效答案是在函数被调用的时候。JS 引擎每调用一个函数就会为这次调用创建一个执行上下文execution context并在上下文里真正搭建出一条可用的作用域链。很多同学把作用域链和执行上下文混为一谈其实两者的关系是作用域链是设计图执行上下文是施工队。4.1 函数调用时引擎究竟做了什么当一个函数被调用引擎会做三件事创建变量对象Variable Object函数里叫 Activation Object、构建作用域链、确定this的指向。整个过程可以分成两个阶段创建阶段扫描当前函数的参数、内部函数声明、var声明的变量把它们作为属性挂到变量对象上。注意此时变量还没有赋值所以值是undefined。执行阶段逐行执行代码遇到赋值语句时把值填充到变量对象里同时根据作用域链查找各种标识符。看一个最小例子function greet(name) { var message 你好 name; return message; } greet(Tom);调用greet(Tom)时引擎先创建一个新的执行上下文并推入调用栈创建变量对象时放入name Tom、message undefined执行阶段读到第一行赋值语句message才变成真正的字符串。整个greet函数执行完毕上下文出栈局部变量随之进入可回收状态。这里我需要强调一个容易踩坑的点函数声明function foo(){}在变量对象创建阶段就会被完整挂载所以你在 function 声明语句之前调用它也不会报错而var声明只挂载声明不挂载赋值let和const在这个阶段会挂载但处于未初始化状态这就是前面说的暂时性死区。4.2 变量对象与作用域链的构建细节作用域链的构建可以拆成一条公式当前执行上下文的作用域链 [当前变量对象] [父级执行上下文的作用域链]注意这里的父级不是调用者而是词法上的父级即代码书写时所在的上一级作用域。比如在全局作用域里定义函数a再在a里定义函数b那么b的执行上下文作用域链就是b 的变量对象 - a 的变量对象 - 全局对象。为什么要把父级的作用域链拷贝进来而不是每次都去全局对象里找因为这样可以保留嵌套函数对外层变量的引用能力——哪怕外层函数已经执行完毕、上下文已经销毁内层函数的作用域链里仍然存着外层变量对象的引用。这就是闭包得以存在的底层原因也是面试高频题闭包是什么的标准答案起点。我建议你用浏览器 DevTools 的 Sources 面板实际看一次作用域链在函数内部打断点右侧 Scope 面板会展开Local、Closure、Global三个层级Local是当前变量对象Closure就是闭包捕获的外层变量Global是全局对象。亲眼见过一次比背十遍定义都管用。5. 闭包作用域链最典型的实战应用聊作用域链绕不开闭包。闭包在面试里被问烂了但很多人只是听了个概念真要写代码却不知道它解决什么问题。其实闭包的源头就是把作用域链延长了函数 A 返回函数 BB 的作用域链里包含了 A 的变量对象所以 A 虽然执行完了它对变量的记忆还被 B 保留着。5.1 闭包的基本写法与应用场景最常见的形式叫计数器工厂function createCounter() { var count 0; return function () { count; return count; }; } var counter createCounter(); console.log(counter()); // 1 console.log(counter()); // 2关键机制createCounter执行完毕后理论上count应该被回收但由于返回的函数仍然引用着createCounter的变量对象count被保留下来每次调用counter()都在原值基础上自增。这个模式在真实开发里非常实用。比如你要给页面上的每个按钮绑定独立点击次数直接一个函数返回闭包每个按钮都有自己独立的count又比如模块化开发中你想隐藏一些内部变量只暴露有限的 API也可以用闭包实现私有变量。不过要注意闭包确实会阻止垃圾回收机制立即回收被引用的变量在循环中批量创建闭包时要谨慎避免内存占用持续上升。5.2 循环与闭包爱恨纠缠的经典坑下面这段代码是无数前端人跌倒过的地方for (var i 0; i 3; i) { setTimeout(function () { console.log(i); }, 100); }直觉上你以为输出 0、1、2但实际输出 3、3、3。原因正好用得上今天的作用域链知识var i是函数作用域这里是全局或者说循环体所在的作用域整个循环结束后i已经变成 3三个setTimeout回调函数都是异步执行的它们执行时循环早已结束去作用域链里查找i拿到的自然是最终的 3。解决方案有两个。第一个把var换成let因为let是块级作用域每次循环都会创建一个独立的i绑定三个回调分别捕获了三个不同的ifor (let i 0; i 3; i) { setTimeout(function () { console.log(i); // 0 1 2 }, 100); }第二个利用 IIFE 包裹生成一个独立的函数作用域去锁住当时的ifor (var i 0; i 3; i) { (function (index) { setTimeout(function () { console.log(index); }, 100); })(i); }我个人推荐优先用let代码更简洁语义也更符合直觉。但理解第二种写法很重要因为它能帮你从根源上理解每个函数上下文中的变量对象是独立的这一核心概念。6. 常见易错题与排查技巧实录作为入门通关手册的最后一篇硬核内容我整理了几道我面试候选人时必问的题目以及我平时排查作用域问题时的固定套路。这些内容基本不在教科书里系统出现但踩过一次坑比看十遍文档都长记性。6.1 五道高频易错题与拆解题目一函数内部 var 声明的变量外部能访问吗function fn() { var a 1; } console.log(a); // ReferenceError答案不能。var作用域限于函数内函数外访问就是未定义。这是最基础的函数作用域考察点。题目二嵌套函数同名变量读取哪一个var name A; function outer() { var name B; function inner() { var name C; console.log(name); } inner(); } outer(); // C答案C。作用域链查找从内向外同级存在就用同级绝不会越过它去外层拿更外面的同名变量。这就是就近原则。题目三if 块里 var 和 let 的差异if (true) { var x 1; let y 2; } console.log(x); // 1 console.log(y); // ReferenceError答案x是函数作用域let是块级作用域。这道题能很快测出你是否真的理解块级作用域。题目四连续两次声明同名函数体会发生什么function a() { return first; } function a() { return second; } console.log(a());答案second。函数声明在提升阶段后写的会覆盖先写的。这也是为什么项目规范建议少用函数声明多用const fn function(){}来避免意外覆盖。题目五函数参数与局部变量同名谁优先function test(param) { var param inner; return param; } console.log(test(outer)); // inner答案inner。参数和var声明在变量对象里是同一个位置赋值顺序上是先参数后 var 赋值最终变量值以代码执行为准。我建议你把这些题目丢进控制台跑一遍观察输出而不是光看答案。眼见为实作用域这种机制亲手验证最能建立记忆。6.2 排查技巧三步定位“变量不是你想的那个”实际开发中我们遇到更多的情况是变量没报错但值不对。排查思路我总结成一个三步走第一步确认变量在当前函数内是否有声明。用 DevTools 在可疑处打断点看 Scope 面板里的 Local 区域。如果根本没有这个变量那引擎一定会往外层找。第二步沿着作用域链逐层查看。看 Scope 面板里的 Closure 和 Global 区域找到实际命中的那个变量对比代码里写的和实际命中的是否为同一个。第三步检查是否有隐式全局变量。如果你在函数里写了total 100而忘记加var或let那么这个total会被挂到全局后面的代码素不知道的话很容易被污染。建议开启 ESLint 的no-implicit-globals规则从工具层面拦住这类坑。我个人的排查习惯是先在控制台输入变量名看返回的引用是不是预期所在层级。比如你在内层函数里想用外层变量结果输出的是全局同名变量问题一定出在某个中间层作用域里多了一个同名变量声明顺着作用域链一层层检查很快就能揪出来。6.3 几条值得长期遵守的编码规则经过多年踩坑我沉淀了几条跟作用域强相关的编码习惯分享给你作为实操时的安全腰带能不声明全局变量就不声明。全局变量越多作用域链被污染的几率越大代码越难调试。优先使用const声明所有变量只有当变量确实需要被重新赋值时才用letvar在现代项目里应该彻底弃用。用let写循环计数器用const写常量对象和配置让作用域边界跟着数据语义走。公共工具函数建议包一层 IIFE 或者使用 ES Module 的文件作用域避免内部辅助变量泄漏到全局。给变量起名时尽量避免在外层作用域已经存在同名变量的情况下继续使用同名标识符哪怕语法上没问题也会让读代码的人包括三个月后的你困惑。这些规则单看每一条都很朴素组合起来能省掉大量卧槽为什么这里拿错了值的排查时间。最后再分享一个小技巧遇到作用域相关的 bug不要直接改代码先在控制台用debugger或者打断点的方式把当前 Lexical Environment 打印出来看一遍。我在实际项目中靠这个办法解决过好几个隐蔽的变量污染问题原理上也不复杂就是让引擎告诉你它实际看到了什么。作用域链这东西理解透了就是一层窗户纸但理解不透它就是你项目里最玄学的 bug 来源。希望这篇手册能把你的窗户纸捅破。
返回列表