ARTICLE DETAIL

资讯详情

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

JavaScript作用域详解:从变量提升到闭包,一篇理清查找规则

JavaScript作用域详解:从变量提升到闭包,一篇理清查找规则 复习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。再找globalCountinner没有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的对比是绕不开的内容这也是搜索引擎上变量作用域这个关键词背后最高频的需求。我整理了一张表把关键差异一次看全特征varletconst作用域类型函数作用域块级作用域块级作用域变量提升有提升为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、2for (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的基础体系差不多就立住了。
返回列表