【你不知道的JavaScript】深入底层:彻底消灭变量提升(Hoisting)的玄学认知
【你不知道的JavaScript】深入底层彻底消灭变量提升Hoisting的玄学认知一、 先有鸡还是先有蛋引入两个经典谜题谜题一谜题二二、 底层解密编译时与运行时的惊天魔术重新复盘谜题一重新复盘谜题二三、 函数提升比变量地位更高的“一等公民”1. 函数声明的完整提升2. 函数表达式的惨烈翻车四、 深度进阶那些不可不知的冲突决胜规则规则一函数优先函数提升高于变量提升规则二同名函数后发制人覆盖规则规则三条件判断里的函数提升极其危险的雷区五、 原文之外的硬核补充现代 let/const 是怎么对付提升的六、 本章小结变量提升规则 var、let、const 核心特性深度对比表 核心知识点通俗大白话串联在前面三章中我们详细探讨了作用域的定义、嵌套和分类。我们已经习惯了用“逐行执行”的直觉去理解代码。然而一旦你遇到下面这种“奇怪”的代码直觉就会开始崩塌为什么变量还没声明就能直接打印且不报错为什么函数还没定义就能直接在前面调用今天我们就走进第4章——提升 (Hoisting)撕开它的面纱用编译器和引擎的底层的双重视角彻底看清它的本质。一、 先有鸡还是先有蛋引入两个经典谜题在探讨提升之前我们先来看两个原书中最经典的谜题。请你在不运行代码的前提下用你的直觉猜猜输出结果是什么谜题一console.log(a);vara2;直觉猜测因为var a 2;声明在第2行所以在第1行执行console.log( a )时它还没被声明要么抛出ReferenceError要么因为第2行赋值了而隐式输出2。实际输出undefined。谜题二a2;vara;console.log(a);直觉猜测代码逐行执行第1行给一个没声明的a赋值2第2行又var a;重新声明。结果要么抛错要么在第3行输出undefined。实际输出2。二、 底层解密编译时与运行时的惊天魔术为什么会出现上面如此反直觉的结果答案其实就在我们第一章讲过的JavaScript 引擎并不是纯粹逐行解释执行的它在运行代码之前会先经历一个编译阶段当你看到一行代码var a 2;时你的大脑会把它当作一句话。但正如我们在第一章所探讨的JavaScript 引擎会将其拆分为两个完全不同的动作第一步编译器var a;—— 在编译阶段处理负责在作用域里圈地声明。第二步引擎a 2;—— 在运行阶段处理负责真正的赋值操作。重新复盘谜题一当编译器看到谜题一的代码时它会进行静态分析编译阶段编译器发现有var a;声明于是立刻在当前作用域中创建变量a。此时所有的赋值和打印操作都会被编译器无视。执行阶段代码开始真正运行此时代码的逻辑在引擎眼里已经变成了这样vara;// 编译阶段完成的提升声明console.log(a);// 此时 a 已经存在但未赋值输出 undefineda2;// 运行阶段执行的赋值因此当引擎第一步读取a的值时它已经被安全声明了但还没来得及被赋值因此自然输出了默认的undefined。重新复盘谜题二同样地我们用这套组合拳来拆解谜题二编译阶段编译器扫描到第二行的var a;在当前作用域中把变量a注册好。执行阶段代码开始运行编译阶段的声明被“抽离”了。剩下的只有纯粹的执行语句vara;// 编译阶段完成的提升声明a2;// 运行阶段赋值操作console.log(a);// 运行阶段打印输出 2所以第一步把2成功写入了a的容器中第三步打印时自然输出2。 什么是“提升”所谓的“变量提升Hoisting”并不是引擎在运行时真的用物理手段把你的代码挪到了文件的最上方。它本质上是因为编译器先一步把所有的声明都挑出来处理完毕了随后引擎运行时这些变量早已存在于作用域中。在视觉效果上就好像所有的声明被“提升”到了各自作用域的最顶端。三、 函数提升比变量地位更高的“一等公民”不仅变量会被提升函数声明同样会被提升。并且在 JavaScript 的底层设计中函数是妥妥的“一等公民”其提升的优先级和完整性要远比变量高得多。1. 函数声明的完整提升考虑以下代码foo();functionfoo(){console.log(a);// undefinedvara2;}在这段代码里foo()的调用赫然写在声明之前但它却能够完美执行。这是因为编译器不仅提升了函数名foo更是把整个函数的身体定义体也一起在编译期完成了绑定。这段代码会被理解为下面这种形式functionfoo(){vara;console.log(a);a2;}foo();2. 函数表达式的惨烈翻车这里必须强调一个极易踩坑的硬伤只有函数声明会被提升而函数表达式绝对不会看下面这个经典的反面教材foo();// 抛出 TypeError!varfoofunctionbar(){// ...};为什么报错是 TypeError 而不是 ReferenceError我们用编译器的视角把它变形编译阶段引擎看到了var foo ...它认为这是一个普通的变量声明。于是只把foo这个变量名字提升到了最顶端。执行阶段此时真实的执行顺序变成了varfoo;// 仅仅提升了变量名foo();// 此时 foo 是 undefined调用 undefined 触发 TypeErrorfoofunctionbar(){// ...};当引擎执行到foo()时它对foo进行 RHS 查询。这时候foo的容器里装的是什么是undefined你试图去调用一个undefined把它当作函数执行作用域判定成功了找到了foo但操作非法因此立刻抛出TypeError类型错误四、 深度进阶那些不可不知的冲突决胜规则在实际复杂的工程中如果同时出现多个同名的变量声明、函数声明或者交织在一起提升后究竟谁说了算原书和 ECMAScript 规范为我们指明了三大冲突决胜规则规则一函数优先函数提升高于变量提升如果一个作用域中同时存在同名的函数声明和变量声明函数声明会先被提升变量声明会紧随其后但如果变量只是普通声明而没有赋值会被直接忽略。考虑以下代码foo();// 输出 1varfoo;functionfoo(){console.log(1);}foofunction(){console.log(2);};原理解析在编译器眼里它先提升了函数foo然后遇到了var foo;变量声明。因为此时foo已经存在于该作用域且变量声明没有赋值操作于是编译器直接将其忽略。实际执行时其底层的视觉效果相当于functionfoo(){// 函数声明优先提升console.log(1);}// var foo; // 同名变量声明被重复忽略了foo();// 输出 1foofunction(){// 运行到此处时foo 的引用被改变console.log(2);};规则二同名函数后发制人覆盖规则尽管变量声明无法覆盖同名的函数声明但后面的函数声明可以无情地覆盖前面的函数声明。考虑以下代码foo();// 输出 3functionfoo(){console.log(1);}functionfoo(){console.log(3);}原理解析在编译阶段第二个foo()函数声明会覆盖第一个foo()。所以当引擎在运行阶段去调用foo()时执行的永远是最后那个“后发制人”的版本。规则三条件判断里的函数提升极其危险的雷区原书里提到一个非常经典的案例在if等条件判断分支内部写函数声明。foo();// 现代浏览器中通常抛出 TypeError: foo is not a functionvaratrue;if(a){functionfoo(){console.log(a);}}else{functionfoo(){console.log(b);}}避坑指南虽然在早期的某些非标准环境中上面的代码可能会输出第二个函数。但现代 ECMAScript 规范明确规定块级作用域内部的函数声明其行为在不同浏览器引擎中的实现细节是不一样的。大多数现代引擎为了向前兼容会将其处理为类似var的变量提升初试为undefined。最佳实践永远不要在块级、条件判断分支内部书写函数声明如果真的有这种需求请一律使用函数表达式来代替。五、 原文之外的硬核补充现代let/const是怎么对付提升的在阅读原书时书本主要基于 ES5 的var环境讨论提升。那么作为现代前端ES6 的let和const到底有没有提升答案是在词法层面上它们会被提升但在行为层面上它们禁止了提升带来的副作用。现代 JavaScript 引入了两个全新概念来终结var带来的混乱未初始化Uninitializedvar被提升后底层会自动将其初始化为undefined。而let/const被提升后在底层作用域中会被标记为“未初始化”状态。暂时性死区TDZ - Temporal Dead Zone在块级作用域中从块的开始直到let/const声明语句所在的那一行代码之间该变量是完全不可触碰的。这片禁区就叫做暂时性死区。{console.log(myVar);// 正常输出 undefined var具有提升和初始化varmyVar1;console.log(myLet);// 报错ReferenceError: Cannot access myLet before initializationletmyLet2;// 从块开始到这一行就是 myLet 的暂时性死区TDZ}通过 TDZ现代 JavaScript 从根本上杜绝了“先使用、后声明”的恶劣代码习惯让代码的执行顺序重回安全可预测的轨道。六、 本章小结变量提升规则先有声明后有赋值。因为 JavaScript 拥有编译期所有的声明变量和函数都会在代码运行前率先被确定和注册。提升是编译器的产物。提升在视觉上表现为把声明移动到作用域顶端其实只是编译阶段优先处理了标识符的圈地注册。函数声明会完整提升函数表达式不会。调用未赋值的函数表达式会直接引发TypeError。函数拥有最高优先级。同名冲突时函数优先提升同名函数之间后面的会覆盖前面的。 var、let、const 核心特性深度对比表特性 / 维度varletconst支持的作用域类型仅支持全局作用域与函数作用域支持全局、函数以及块级作用域{}支持全局、函数以及块级作用域{}是否会泄漏到外层块会在for或if块中声明会泄漏到外部不会严格锁死在当前大括号块内部不会严格锁死在当前大括号块内部编译期是否提升是提升的同时会被初始化为undefined是在词法层面提升但标记为未初始化是在词法层面提升但标记为未初始化暂时性死区 (TDZ)不存在在声明前访问只会拿到undefined存在从块级作用域开始到声明行前访问即报错存在从块级作用域开始到声明行前访问即报错重复声明同一标识符允许禁止在同一作用域内重复声明会抛出语法错误禁止在同一作用域内重复声明会抛出语法错误必须在声明时初始化不需要默认初始值为undefined不需要不赋值时默认也是undefined必须初始化声明时必须立刻赋值否则直接报错是否允许重新赋值允许可以随时任意修改允许可以随时任意修改禁止修改原始值基本类型值不能改引用类型只要内存指针不变即可全局挂载行为声明的全局变量会挂载到window/global对象上声明的全局变量不会挂载到全局对象上声明的全局变量不会挂载到全局对象上 核心知识点通俗大白话串联为了让大家在面试或阅读时更得心应手我们用大白话把上面表格里最核心的两个底层概念串联一下词法作用域与块级作用域的进化以前用var的时候JavaScript 只有函数能关住变量。你在for(var i0;...)里写的i循环完了还会污染到外层函数。而let和const带来了现代的块级作用域只要有一对大括号{}它们就能在词法分析阶段被死死锁在里面绝对不向上泄漏。暂时性死区 (TDZ) 到底是怎么来的很多人以为let和const没有变量提升这其实是误解。它们在词法阶段同样会被提升区别在于var提升时引擎会顺手给它发一张通行证并初始化为undefined而let/const提升时引擎非常高冷只登记了名字将其标记为“未初始化”状态。从块级作用域开始到它正式被初始化的那一行代码之间这片不准触碰的禁区就是暂时性死区。欢迎关注专栏下一篇我们将迎来整个 JavaScript 闭合宇宙的终极大结局——第5章闭包Scope Closures我们将用之前所有学过的数据结构和作用域地基去彻底攻克这个无数前端人魂牵梦绕、又爱又恨的终极神功敬请期待