ARTICLE DETAIL

资讯详情

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

JavaScript Hoisting深度解析:变量提升与函数提升的原理、陷阱与最佳实践

JavaScript Hoisting深度解析:变量提升与函数提升的原理、陷阱与最佳实践 要是说JavaScript里哪个概念最“阴”我第一个想到的就是Hoisting——变量提升与函数提升。这些年我在项目里因为踩了它的坑而加班排查的经历一只手都数不过来最典型的情况是代码不报错、运行结果完全不对调试器翻来覆去找不到原因最后发现仅仅是因为一个var声明“提前占位”带来的连锁反应。这篇文章我会把Hoisting的来龙去脉、面试里的高频陷阱以及我在真实项目里的排错过程完整地聊一遍。无论你是刚接触JavaScript的初学者还是写过大量业务代码但对“提升”一直半懂不懂的开发者应该都能从里面拿到一些可以直接上手的判断方法。1. 一个让我排查到凌晨两点的线上问题1.1 看着正常的业务代码却总是走到default分支先说一个真实发生过的场景。有一年我做老项目重构页面里有一段类似这样的代码init(); function init() { const result getFeature(); console.log(result); // 期待输出接口返回的配置实际上输出default } function getFeature() { if (typeof FEATURE ! undefined) { return FEATURE; } return default; } var FEATURE loadFeature(); // 这个配置是在接口拉取之后赋值的新手可能一眼看不出问题init()在第一行被调用getFeature()内部用typeof判断FEATURE是否存在不存在的兜底是字符串default而FEATURE确实又在文件末尾被var声明并赋值了。逻辑上好像没问题——但真实运行结果就是一直输出default。我当时的第一反应是“接口没返回数据”于是加了大量日志去打印loadFeature()的返回值发现数据一切正常又怀疑是模块加载顺序错了把文件翻来覆去调整引入顺序依然无解。折腾了将近两个小时后我才把目光放回那段被我忽略的代码顺序上代码从上到下执行时init()调用发生在var FEATURE loadFeature()之前。就因为var FEATURE被提升了但它的赋值动作并没有被提前所以当getFeature()执行时FEATURE还是undefinedtypeof判断自然走了“不存在”的分支。1.2 从“怀疑异步”到“怀疑人生”完整排查链路这个问题的坑点在于它不是报错而是静默走了错误分支。TypeError或者ReferenceError反而好办浏览器会直接告诉你哪一行出了问题但typeof FEATURE ! undefined这种保护性判断会把“已经声明但还没赋值”的变量当成“根本不存在”于是整个函数异常优雅地返回了兜底值。我的排查过程大致是这样的在getFeature()入口打印FEATURE发现输出undefined。在调用getFeature()之前打印FEATURE依然输出undefined。检查loadFeature()是否被调用以及它的返回值是否存在——结果都正常。把var FEATURE loadFeature()移动到init()之前再运行问题消失。此时才意识到var FEATURE的声明被提升到了当前作用域顶部但赋值动作留在了原位置init()因为函数声明被整体提升所以在第一行就能被调用而它执行时FEATURE的赋值语句还没跑到。这个案例真正让我意识到一件事我们平时常说的“先声明再使用”在var面前是不完整的。你必须把“声明”和“赋值”拆成两个独立时刻来看——声明可能在编译登记阶段就生效了赋值却要等执行到那一行才发生。1.3 关键认知提升只提前“声明”不提前“赋值”踩过这次坑之后我把Hoisting的认知修正为一句很容易记的话引擎在代码执行前先把所有声明都登记好但只有赋值语句能在轮到它的时候修改变量的值。用更通俗的方式说var a 1这条语句实际上被引擎拆成了两部分声明部分var a在执行前就被登记并初始化为undefined。赋值部分a 1留在原来的位置按顺序执行。至于函数声明比如function foo() {}则是一整个函数对象在登记阶段就被准备好了。所以你在声明之前调用它它也能正常工作。这是很多老派JavaScript代码喜欢把工具函数写在文件底部的原因——反正会提升调用顺序无所谓。但“无所谓”这三个字恰恰是隐患的温床。2. Hoisting的本质引擎在执行前做了什么2.1 把“提升”理解成编译期预登记不少教程会把提升描述成“变量声明被物理移动到了作用域的最顶部”这是为了教学方便但严格来说并不准确。JavaScript在执行一段可执行代码比如一个函数、一个脚本之前引擎会先完成解析与编译生成执行上下文。在这个上下文的创建阶段引擎会把代码中出现的所有声明逐一登记到作用域里而不是真的去改写源码的位置。所以“提升”的正确理解是你写的代码顺序没有变但声明绑定在代码运行前就已经存在于作用域中了。这种现象在外部看起来就像是声明被“抬”到了作用域顶部。这里有一个我自己常用的思想实验把一段代码里所有var和function声明按它们在源码中出现的顺序想象成一行一行“贴”到作用域的最上方。然后把赋值语句、函数表达式、业务逻辑都留在原地。做完这个动作你再用普通顺序执行代码就能准确预测很多奇怪的行为了。2.2 var、function、let/const、class在登记阶段的不同处理不同声明类型在登记阶段的行为差别非常大这也是Hoisting经常让人混乱的根本原因。我用一个表格来对比声明类型是否登记登记时的初始值登记后立即访问会怎样var a是undefined可以得到undefined不报错function foo() {}是整个函数对象可以直接调用let a是未初始化uninitialized抛ReferenceErrorconst a是未初始化uninitialized抛ReferenceErrorclass Foo {}是未初始化uninitialized抛ReferenceError注意很多人以为let和const完全没有提升这是不对的。从规范角度看它们也会在进入作用域时被绑定只是绑定状态是“未初始化”。直到执行到let那一行这个绑定才被真正初始化。这中间的这段时间就叫“暂时性死区”TDZ。所以与其说let不提升不如说它提升得更严格——在初始化之前任何读取行为都会直接抛错。function声明之所以看起来“完整提升”是因为引擎在登记阶段直接把函数对象整个创建好了不像var只给一个undefined占位符。2.3 从执行上下文的角度看声明绑定在引擎层面执行上下文的创建阶段会建立两个环境记录词法环境Lexical Environment存放let、const、class声明。变量环境Variable Environment存放var声明和函数声明在函数级作用域。当代码执行到某个作用域时引擎先完成这两个环境的初始化再去逐行执行代码。你可以理解为在正式“开门营业”之前服务员已经把菜单上的菜名全部登记到后厨系统里了只是有的菜已经备好食材函数声明有的菜只贴了个标签说“食材稍后到”var还有的菜连标签都写着“未激活”let/const。营业一开始你能不能点成菜完全取决于这张登记表的状态。这里也顺便解释一个常见误区不要在脑内把提升理解为“把代码行搬上去”。引擎不会重新排列你的源码它只是在执行前建立了一张关于声明绑定的表。这张表决定了你在任意一行代码访问某个名字时看到的是undefined、函数对象还是一个ReferenceError。3. 同名冲突函数声明和var谁赢谁输3.1 三个反直觉的实验结果函数声明是“完整提升”var是“半提升”那它们同名字时会发生什么这个问题我在面试别人时经常问自己也踩过。先看第一段代码console.log(typeof foo); // 输出 function function foo() {} var foo 1;按很多人“var提升后是undefined”的朴素理解foo应该输出undefined。实际输出是function。原因是在登记阶段函数声明先被注册后续扫描到var foo时引擎发现这个名字已经存在就不会再用undefined去覆盖已有的函数对象。再看第二段代码console.log(typeof foo); // 输出 number var foo 1; function foo() {}这段代码执行顺序是登记阶段依然先函数后var但执行阶段第一行没有console.log之前的赋值吗等等仔细看。console.log在第一行var foo 1在第二行function foo(){}在第三行。但函数声明在登记阶段已经建立执行阶段第三行并不会重新赋值而第二行foo 1会在console.log之前执行吗不会第二行在console.log后面。所以这个例子输出什么让我重新理一遍。console.log(typeof foo); // 返回的是第一行的输出 var foo 1; function foo() {}在登记阶段function foo整体提升var foo不覆盖所以foo此时是函数对象。执行阶段先执行第一行console.log(typeof foo)此时第二行的赋值还没执行因此输出function。这是第三段代码var foo 1; function foo() {} console.log(typeof foo); // 输出 number登记阶段结束后执行第一行foo 1把函数对象覆盖为数字1所以最后输出number。真正反直觉的是第一段var foo 1虽然在函数声明后面但执行阶段会执行赋值最终foo还是会变成1。如果我在第一段代码末尾加上console.log(foo)输出就是1。也就是说登记阶段函数优先执行阶段赋值仍然生效。3.2 为什么函数声明优先级更高这里涉及一个比较底层的规则创建执行上下文时引擎会先实例化函数声明再处理var声明。当处理var声明时如果作用域中已经存在同名绑定var不会重新初始化这个绑定。从规范角度说这是“把函数声明优先放入环境记录然后变量声明在遇到同名绑定的时候被忽略”的体现。说人话就是函数声明是“先来的”var是“后来的”后来的var不会把先来的函数对象顶掉但后面的赋值语句可以修改函数对象所指向的内存地址。所以只要你在执行阶段给这个名重新赋值最终拿到的还是新值。这带来的经验是如果你在代码里遇到“函数与变量同名”的写法不要试图硬背规则。直接用“登记顺序优先函数执行顺序赋值覆盖”去心算多数情况都能推对。3.3 块级函数声明一个容易忽略的版本差异比“函数与var同名”更隐蔽的是块级作用域里的函数声明。例如if (true) { function foo() { console.log(1); } } foo();这段代码在现代浏览器和严格模式下表现并不完全一致。ES6之前块级函数声明在很多浏览器里会被当作函数声明提升到函数作用域顶部所以foo()在if块外也能调用但ES6之后规范把块级声明约束在块级作用域内同时Web兼容性又让浏览器在某些情况下表现得像提升了一样。这就导致同一段代码在不同浏览器或不同严格模式下结果可能不同。我自己在老旧项目里遇到过类似问题代码在Chrome里正常在某个老版本浏览器里却报foo is not a function。排查到最后才发现是块级函数声明的兼容性差异。所以在现代项目里我的建议是不要在if块内声明具名函数改用const foo () {}或者函数表达式。这样既避免了提升规则的不确定性也让代码语义更清晰。4. 更隐蔽的Hoisting翻车现场闭包、TDZ与默认参数4.1 循环里var导致的3、3、3问题本质还是提升你大概率遇到过这个经典问题for (var i 0; i 3; i) { setTimeout(function () { console.log(i); // 输出 3 3 3而不是 0 1 2 }, 100); }很多人把这个归类为“闭包问题”但它最根源的地方就是var i被提升到了函数或全局作用域循环体里的i始终指向同一个变量。三次循环并没有创建三个独立的i而是不断修改同一个i。当定时器回调真正执行时循环早已结束i已经变成了3。这个问题的解法有很多种。用let i是最直接的因为let拥有块级作用域每一次迭代都会创建独立的绑定或者用IIFE把i作为参数传进去for (var i 0; i 3; i) { (function (j) { setTimeout(function () { console.log(j); }, 100); })(i); }但更本质的思考方式是只要知道var i被提升到了顶部你就能在写第一行for循环时预判所有回调都共享同一个i。这不是闭包特有的魔法而是var作用域规则与异步回调延后执行的综合结果。4.2 typeof安全判断在TDZ面前也失灵了很多人习惯用typeof来检查一个全局变量是否存在认为这样永远安全。但在let/const导致暂时性死区时typeof也会直接抛错console.log(typeof neverDeclared); // undefined这是安全的 console.log(typeof declaredLater); // ReferenceError let declaredLater 1;第一行typeof neverDeclared变量从未声明返回undefined这没问题。第二行typeof declaredLaterdeclaredLater已经在作用域中被let绑定但还没有初始化处于TDZ所以typeof访问它会抛出ReferenceError。这个坑在真实项目里比想象中容易出现。比如你在函数顶部想用typeof检测某个配置对象是否存在而这个配置对象后面用let声明并初始化那你提前访问就会出现运行时错误。解决办法很简单尽量把let声明放在函数最前面或者避免同作用域内的提前检测。4.3 默认参数里暗藏的TDZ链ES6的默认参数很常用但它和TDZ碰撞时会产生一些难以察觉的问题function foo(x y, y 2) { return x; } foo(); // ReferenceError: Cannot access y before initialization原因是函数的默认参数会形成一个独立的参数作用域并且参数是按顺序初始化的。x的默认值是y但此刻y还没有初始化处在TDZ中于是抛错。这看起来和Hoisting没有关系实际仍然是“绑定存在但未初始化”的约定在起作用。如果我交换参数顺序function foo(y 2, x y) { return x; } foo(); // 2因为y先初始化x再引用y就合法了。所以写默认参数时依赖顺序是有实际后果的尤其是在参数相互引用的时候。4.4 class声明的“半提升”与函数表达式的作用域边界class声明和let、const一样也会在作用域中建立绑定但同样有TDZconst instance new Foo(); // ReferenceError class Foo {}这段代码会抛错因为Foo在初始化之前被访问了。所以你不能像对待函数声明那样把class调用放在声明之前。还有一个容易被误认为“提升”的现象是带名函数表达式const bar function inner() { return inner; }; console.log(bar()); // 输出函数本身 console.log(typeof inner); // undefined外部访问不到inner这个名字只存在于函数自身作用域内外面访问不到。这不算Hoisting而是带名函数表达式的函数名绑定范围问题。但很多初学者会把这种行为和函数声明提升搞混以为inner会被提升到外部作用域。实际上函数表达式无论是否带名都不参与提升var bar提升后也只有undefined。5. 我在实际项目中怎么“防”Hoisting5.1 变量声明区集中放在函数顶部解决了“能不能运行”的问题之后更重要的其实是“怎么让代码不容易被坑”。我在现在的项目里会刻意把变量声明集中放到作用域顶部就算不赋值也先写上正确初始值function loadPage() { // 变量声明区 let config null; let pageData []; let errorMessage ; // 业务逻辑 config fetchConfig(); pageData config.list || []; // ... }这样做的好处是你一眼就能看到当前作用域有哪些绑定访问某个变量时不用再担心它是不是被var提升但还没赋值。即使后面逻辑复杂变量声明区也会像一个目录一样告诉你这个作用域里所有名字的“底账”。5.2 const优先let其次var最后这个建议很多教程都提过但背后的原因值得多说一句const和let的TDZ虽然会让程序报错但报错总比静默拿到undefined强。提升机制真正可怕的地方不是它不符合直觉而是它常常让代码“看起来没问题、跑起来全不对”。而TDZ把这种不确定性变成了显式错误反而帮你更快定位问题。我个人的实践是能写const就不写let因为重新赋值只会在确有必要时出现。确实会变的变量用let并且尽量在声明时初始化。新代码里基本不再用var除非是在维护没有任何编译步骤的老代码。用const还有一个隐形好处它能防止你无意中对同一个变量多次赋值从而减少“这个值到底是什么时候变的”这类排查负担。5.3 函数定义放在调用之前或统一用函数表达式函数声明提升虽然让你可以在定义之前调用函数但这并不代表推荐这么写。我接手过一些老文件工具函数全堆在文件最后业务入口却在最上面阅读体验极差一旦文件变大你根本不知道哪些函数是声明提升提供的“隐藏依赖”。我现在写代码时遵循一个简单原则一个作用域内优先把函数定义放在使用之前。如果实在做不到那我会用const handleXxx () {}这种函数表达式形式。这样代码的阅读顺序和执行顺序基本一致看到一行调用往上翻几行就能找到对应的定义心智负担小很多。5.4 团队层面用ESLint把不确定性变成硬错误个人习惯只能约束自己团队协作还得靠工具。我维护的团队代码库里ESLint规则中有几条和Hoisting直接相关{ no-var: error, prefer-const: warn, no-use-before-define: [ error, { functions: false, classes: true, variables: true } ] }说明一下no-use-before-define默认会比较严格连函数声明在定义前使用都会报错。我这里把functions设为false因为利用函数声明提升在局部作用域中先调用后定义有时确实无伤大雅但variables设为true确保变量不会在定义前被访问。这个组合能拦截掉大多数“顶着undefined上阵”的代码。no-var设为error之后团队新提交的代码基本不会再出现var。这样一来由var提升导致的那一类静默问题从源头就被堵住了。5.5 五分钟“心算提升”自查法最后分享一个我每次排查可疑代码时都会用的心算方法分四步列出当前作用域内所有声明包括var、function、let、const、class。确定每个声明在登记阶段的初始状态var是undefined函数是可调用对象let/const/class是未初始化。从该作用域的第一行代码开始按顺序模拟执行每遇到一次访问就在心里问这个绑定此刻处于什么状态。如果访问发生在初始化之前要么是undefined要么是ReferenceError只有函数声明是例外可以提前调用。举个例子遇到下面这段代码function outer() { console.log(inner); // undefined var inner function () { return hello; }; console.log(inner); // 函数 }心算过程是作用域里有var inner登记后为undefined所以第一个console.log输出undefined执行inner function后第二个console.log输出函数。整个过程不需要翻文档也不用打开浏览器控制台几秒钟就能判断结果。把这种心算变成日常写代码的习惯之后我发现被Hoisting坑的次数急剧下降。它不再是一个需要背下来的“面试陷阱”而是变成了一种能预判行为、主动规避问题的思维方式。对于一个JavaScript开发者来说这种思维方式可能比记住某条规则本身更加值钱。每次同事拿着代码来问“为什么这里有undefined”的时候我都会带着他一步步画出声明登记表——看到表的那一瞬间绝大多数问题都迎刃而解。
返回列表