
文章目录一、开篇一段代码引发的思考二、编译原理JavaScript 到底是怎么跑起来的2.1 JavaScript 是编译型还是解释型2.2 编译的三个阶段阶段一词法分析Tokenizing / Lexing阶段二语法分析Parsing阶段三代码生成三、作用域的核心工作机制3.1 回到 var a 2;3.2 引擎的两种查找方式LHS 与 RHS3.3 作用域的嵌套3.4 两种错误的区别四、词法作用域规则在写代码时就已定好4.1 什么是词法作用域4.2 块级作用域 vs 函数作用域4.3 词法作用域的查找规则五、欺骗词法作用域以及为什么不该这样做5.1 eval()5.2 with 语句5.3 核心教训六、实战作用域在代码中的体现6.1 闭包的前置理解6.2 模块化的雏形6.3 变量提升Hoisting的本质七、全文总结八、核心知识点复盘九、常见问题 / 避坑指南一、开篇一段代码引发的思考先来看一段最简单的代码vara2;你可能会说“这有什么好讲的不就是声明了一个变量a并赋值为2吗”但如果我们站在JavaScript 引擎的角度来看这一行代码背后发生了一系列精密的协作。谁在执行这行代码谁来分配变量谁来存储值这些幕后角色就是我们今天要深入探讨的主角。在正式开始之前先记住三个核心角色整篇文章都会围绕它们展开角色职责通俗理解引擎负责整个 JavaScript 程序的编译和执行总指挥从头到尾把控流程编译器负责词法分析、语法分析、代码生成翻译官把源码变成机器能懂的指令作用域负责收集并维护所有声明的变量制定访问规则户籍系统管着谁住在哪、谁能访问谁二、编译原理JavaScript 到底是怎么跑起来的2.1 JavaScript 是编译型还是解释型一个常见的误解是JavaScript 是纯解释型语言写完一行执行一行。实际上JavaScript 是一门编译语言。只不过它不像 C/Java 那样提前编译好而是在代码执行前的几微秒内完成编译即 JITJust-In-Time 编译。这个过程对我们是透明的但它确实存在。2.2 编译的三个阶段任何一段 JavaScript 代码在执行前都会经历以下三个步骤源代码 → [词法分析] → [语法分析] → [代码生成] → 可执行代码阶段一词法分析Tokenizing / Lexing编译器把源代码字符串拆解成一个个有意义的词法单元Token。vara2;这一行代码会被拆解为[var, a, , 2, ;]每个 token 是一个不可再分的原子单位。这个过程就像把一句话拆成一个个词语——“我吃饭” →[我, 吃, 饭]。阶段二语法分析Parsing将 token 数组转换成一棵抽象语法树ASTAbstract Syntax Tree。AST 描述了代码的语法结构和层级关系。Program | VariableDeclaration / | \ var a Literal(2)有了这棵树引擎就知道哦这是一个变量声明名字叫a初始值是2。阶段三代码生成将 AST 转换成可执行的机器指令。不同引擎V8、SpiderMonkey、JavaScriptCore的实现细节不同但本质都是把 AST 变成 CPU 能执行的指令。关键认知变量的声明和初始化是在编译阶段就确定好的而不是执行阶段临时决定的。这就是为什么会有变量提升——我们后面会讲到。三、作用域的核心工作机制3.1 回到var a 2;现在我们把编译原理和作用域结合起来看看这行代码在引擎内部到底发生了什么。编译器看到var a 2;时会把它拆成两步来处理// 第一步编译阶段 — 声明 // 编译器问作用域你那儿有一个叫 a 的变量吗 // 作用域说没有。 // 编译器说好那在当前作用域创建一个变量 a。 // 第二步执行阶段 — 赋值 // 引擎拿到编译器生成的代码开始执行。 // 引擎问作用域你那儿有一个叫 a 的变量吗我需要对它赋值。 // 作用域说有在这儿。 // 引擎把 2 赋给 a。核心结论var a 2;本质上是两个独立动作编译时声明变量由编译器和作用域协作完成运行时赋值操作由引擎和作用域协作完成这就是为什么你能在声明之前访问一个var变量得到undefined——因为声明已经在编译阶段完成了只是赋值还没执行。这就是变量提升的底层原理。3.2 引擎的两种查找方式LHS 与 RHS引擎在执行代码时需要频繁地查找变量。查找方式分为两种这是理解作用域的核心概念查找方式全称目的通俗理解LHSLeft-Hand Side找到变量的容器准备赋值“我要把东西放进哪个盒子”RHSRight-Hand Side找到变量的值准备读取“这个盒子里装的是什么东西”注意LHS 和 RHS 中的左/右指的是赋值操作的左侧和右侧不是字面上的左边和右边。来看具体例子vara2;// LHS找到 a 的容器把 2 放进去console.log(a);// RHS找到 a 的值因为需要读取 a 的值传给 log// 注意 console.log(...) 本身也是一次 RHS 查找// 引擎需要找到 console 对象再访问它的 log 方法a3;// LHS找到 a 的容器把 3 放进去再看一个稍微复杂的例子帮你彻底分清 LHS 和 RHSfunctionfoo(a){// 隐式 LHS把实参 2 赋给形参 a相当于 a 2console.log(a);// RHS读取 a 的值}foo(2);// RHS先查找 foo 的值确认它是一个函数// 再执行调用逐行拆解上述代码的查找过程foo(2)— 引擎对foo做一次RHS查找“foo 是什么” → 得到一个函数传参过程 — 隐式LHS把实参2赋给形参a即a 2console.log(a)— 先对console做RHS查找找到 console 对象再访问.log属性最后对a做RHS查找读到值23.3 作用域的嵌套作用域是可以嵌套的。当一个变量在当前作用域找不到时引擎会向外层作用域查找直到全局作用域为止。这就是作用域链。varglobalVar我是全局的;functionouter(){varouterVar我在外层;functioninner(){varinnerVar我在内层;console.log(innerVar);// 在当前作用域找到 ✓console.log(outerVar);// 向上一级在 outer 作用域找到 ✓console.log(globalVar);// 再向上一级在全局作用域找到 ✓console.log(notExist);// 一直找到全局都没找到 → ReferenceError ✗}inner();}outer();查找规则引擎从当前执行的作用域开始查找找不到就向上一级找一直找到全局作用域。如果全局也找不到如果是RHS查找 → 抛出ReferenceError如果是LHS查找 → 在非严格模式下全局作用域会自动创建这个变量在严格模式下同样抛出ReferenceError// 非严格模式functiontest(){a10;// LHS 查找 a找不到 → 全局自动创建 window.a 10}test();console.log(a);// 10 变量泄露到了全局这是一个常见的坑// 严格模式use strict;functiontestStrict(){b10;// LHS 查找 b找不到 → ReferenceError: b is not defined}testStrict();3.4 两种错误的区别错误类型触发条件含义ReferenceErrorRHS 查找失败或严格模式下 LHS 查找失败“这个变量根本不存在”TypeError找到了变量但对它做了不该做的事“变量存在但你的操作不合法”varnum123;num();// TypeError: num is not a function// 解释RHS 找到了 num值是 123但你把它当函数调用——操作不合法console.log(unknownVar);// ReferenceError: unknownVar is not defined// 解释RHS 找不到 unknownVar——变量不存在一句话总结ReferenceError是找不到容器作用域层面的失败TypeError是容器里的东西不对操作层面的失败。四、词法作用域规则在写代码时就已定好4.1 什么是词法作用域词法作用域Lexical Scope是 JavaScript 采用的作用域模型。它的核心规则只有一句话作用域是由你写代码时变量和函数声明的位置决定的而不是运行时决定的。换句话说你把function写在哪里它的作用域就固定在那里——不管这个函数后来被谁调用、在哪里调用都不会改变它的作用域链。varname全局;functionouter(){varname外层;functioninner(){// 词法作用域inner 定义在 outer 内部// 所以 inner 的作用域链是inner → outer → 全局console.log(name);// 输出什么}inner();}outer();// 输出外层inner函数在outer内部定义所以它的作用域链是inner → outer → 全局。当它查找name时先在outer作用域找到了外层就直接使用不会再向全局查找。这就是词法作用域一切由代码的书写位置决定编译时的词法分析阶段就已经确定了作用域关系。4.2 块级作用域 vs 函数作用域在 ES6 之前JavaScript 只有函数作用域和全局作用域。ES6 引入了let和const带来了块级作用域。// 函数作用域varfunctiontestVar(){if(true){varx10;// var 无视块级边界}console.log(x);// 10 — 可以访问因为 x 属于整个函数作用域}// 块级作用域let / constfunctiontestLet(){if(true){lety20;// let 尊重块级边界}console.log(y);// ReferenceError: y is not defined}块级作用域的出现解决了哪些实际问题// 经典问题循环中的异步操作for(vari1;i5;i){setTimeout(function(){console.log(i);// 输出 5 个 6因为 var 让所有回调共享同一个 i},i*100);}// 解决方案一用 let 创建块级作用域for(letj1;j5;j){setTimeout(function(){console.log(j);// 输出 1, 2, 3, 4, 5 — 每次迭代都有独立的 j},j*100);}// 解决方案二用 IIFE立即执行函数手动创建函数作用域for(vark1;k5;k){(function(capturedK){setTimeout(function(){console.log(capturedK);// 输出 1, 2, 3, 4, 5},capturedK*100);})(k);}let不仅仅是一个新的变量声明方式它在for循环中的行为是专门设计过的——每次迭代都会创建一个新的绑定这是块级作用域最重要的应用场景之一。4.3 词法作用域的查找规则作用域的查找是单向的、由内而外的而且遵循一个铁律找到第一个匹配的变量就停止。外层同名变量会被内层遮蔽Shadowing。varvalue1;functionshadow(){varvalue2;// 内层声明了同名变量console.log(value);// 2 — 在内层找到就停了不会查到全局的那个 valuefunctiondeeper(){varvalue3;// 继续遮蔽console.log(value);// 3}deeper();}shadow();console.log(value);// 1 — 全局的 value 从未被修改过无论是var、let还是const都遵循这个遮蔽规则。全局变量和局部变量同名时局部变量会遮挡全局变量。五、欺骗词法作用域以及为什么不该这样做JavaScript 中有两种机制可以在运行时修改词法作用域。它们在你不知道的JavaScript中被归类为欺骗词法作用域的手段。了解它们的存在即可实际开发中应该避免使用。5.1eval()eval()接受一个字符串参数并将其作为代码来执行。这段代码会被插入到eval()调用所在的作用域中。functionevil(str,a){eval(str);// 执行 str 中的代码影响了当前作用域console.log(a,b);// 这里能访问到 b 吗}varb2;evil(var b 100;,1);// 输出1, 100发生了什么eval(var b 100;)在当前函数作用域中声明了一个局部变量b这个局部变量遮蔽了外层的全局变量b。为什么不该用eval()性能问题引擎在编译阶段做了大量优化如变量位置缓存而eval()可能在运行时动态修改作用域导致引擎不得不放弃这些优化回退到更慢的执行模式。安全风险如果eval()的参数包含用户输入攻击者可以注入任意代码。可维护性代码的行为不再由书写位置决定调试和理解变得极其困难。硬知识任何使用了eval()的代码JavaScript 引擎都无法对作用域查找进行优化。因为引擎无法在编译阶段确定eval()中的字符串内容它必须假设最坏情况——这会导致整个函数的执行变慢。5.2with语句with可以展开一个对象让它的属性看起来像当前作用域中的变量varobj{a:1,b:2};with(obj){a10;// 修改了 obj.ab20;// 修改了 obj.bc30;// obj 中没有 c这是一个 LHS 查找}console.log(obj.a);// 10console.log(obj.b);// 20console.log(obj.c);// undefinedobj 上没有 cconsole.log(c);// 30 — 怎么回事c 泄露到了全局解析c 30的执行过程引擎在with创建的作用域中 LHS 查找c找不到向obj查找——obj中也没有c继续向外层全局查找——全局也没有非严格模式下全局作用域自动创建了变量c所以c变成了全局变量with在严格模式下直接被禁用了非严格模式下也应该完全避免。5.3 核心教训机制对作用域的影响现状eval()在运行时插入新代码可能动态修改当前词法作用域应完全避免with在运行时创建一个全新的词法作用域严格模式禁用不应使用记住一点这两个机制的共同问题是让作用域规则变得不可预测。而代码的可预测性是写出健壮程序的基石。词法作用域的设计理念就是写代码时就确定一切不要试图去改变它。六、实战作用域在代码中的体现6.1 闭包的前置理解作用域和词法作用域是理解闭包的基础。虽然闭包在后续章节才深入讲解但这里可以先建立直观感受functioncreateCounter(){varcount0;// count 在 createCounter 的作用域内returnfunction(){// 返回的函数记住了它定义时的作用域count;// 访问的是 createCounter 作用域中的 countconsole.log(count);};}varcountercreateCounter();counter();// 1 — count 没有被销毁counter();// 2 — 同一个 count 在累加counter();// 3createCounter执行完毕后按理说它的内部变量count应该被垃圾回收。但返回的那个匿名函数仍然持有对count的引用所以count一直存活着。这个函数记住自己的词法作用域的能力就是闭包。闭包的理解建立在词法作用域之上——正是因为函数的作用域链由定义位置决定返回的函数才能始终访问到createCounter内部的count。6.2 模块化的雏形利用函数作用域和闭包可以在没有 ES Module 的年代实现模块化varMyModule(function(){// 私有变量和函数 — 外部无法直接访问varprivateDatasecret;varprivateCount0;functionprivateMethod(){privateCount;returnprivateCount;}// 公开的 API — 通过 return 暴露return{getData:function(){returnprivateData;},increment:function(){returnprivateMethod();}};})();console.log(MyModule.getData());// secretconsole.log(MyModule.increment());// 1console.log(MyModule.increment());// 2console.log(MyModule.privateData);// undefined — 无法访问私有变量这就是利用函数作用域天然隔离变量的特性实现的模块模式。现代前端工程中的 Webpack、Vite 等打包工具在底层同样利用了函数作用域来隔离各个模块的变量。6.3 变量提升Hoisting的本质前面提到var声明的提升现在结合作用域来完整理解console.log(a);// undefined不是 ReferenceErrorvara2;理解过程编译阶段编译器遇到var a在当前作用域注册变量a此时值为undefined执行阶段引擎先执行console.log(a)此时a已存在但值为undefined然后执行a 2将a的值更新为2// 等价于vara;// 提升——编译阶段完成console.log(a);// undefineda2;// 赋值——原地不动函数声明也会提升且优先级高于变量声明foo();// 函数声明 — 函数声明整体提升functionfoo(){console.log(函数声明);}// 对比函数表达式bar();// TypeError: bar is not a functionvarbarfunction(){console.log(函数表达式);};let和const也有提升但它们有暂时性死区TDZ——在声明之前访问会抛出ReferenceError而不是返回undefined。这实际上更符合直觉也是推荐使用let/const的原因之一。七、全文总结本文从 JavaScript 的编译原理出发系统梳理了作用域和词法作用域的核心知识JavaScript 是先编译再执行的编译阶段会完成变量和函数声明的注册执行阶段再进行赋值和运算。理解这个两段式过程是理解作用域的前提。作用域是一套规则规定了引擎如何查找变量。LHS 查找关心容器在哪准备赋值RHS 查找关心值是什么准备读取。这两种查找的区别直接决定了找不到变量时引擎的行为——是静默创建全局变量还是抛出ReferenceError。词法作用域由代码的书写位置决定编译时就已经确定运行时不会改变。嵌套的作用域形成作用域链查找规则是由内向外、找到即停。这是 JavaScript 最基础也最重要的设计原则。eval()和with可以在运行时修改词法作用域但这种做法既牺牲性能又引入安全风险应当完全避免。块级作用域let/const补上了var的设计缺陷解决了循环中的异步引用等经典问题是现代 JavaScript 开发的推荐做法。八、核心知识点复盘知识点一句话总结编译三阶段词法分析token化→ 语法分析AST→ 代码生成引擎 vs 编译器 vs 作用域编译器负责声明变量引擎负责执行代码作用域负责管理变量访问规则LHS vs RHSLHS 找容器赋值目标RHS 找值取值来源作用域链由内向外逐级查找找到即停词法作用域作用域由代码书写位置决定与运行时调用无关函数作用域var声明的变量属于最近的外层函数块级作用域let/const声明的变量属于最近的{}块变量提升编译阶段注册声明执行阶段赋值let/const有 TDZeval()的危害运行时修改作用域引擎优化失效有安全风险with的危害创建不可预测的作用域严格模式下被禁用闭包前提函数记住其定义时的词法作用域是闭包的基础九、常见问题 / 避坑指南Q1为什么typeof一个未声明的变量不报错console.log(typeofunknownVar);// undefined不报错typeof有一个特殊的安全机制当变量不存在时它不会触发ReferenceError而是返回undefined。这常被用作判断全局变量是否存在的安全手段// 安全检查某个全局 API 是否存在if(typeofglobalAPI!undefined){globalAPI.doSomething();}Q2在if块中用function声明函数会发生什么if(true){functiontest(){console.log(hello);}}test();// 不同环境行为不同在 ES5 中规范不允许在块级作用域中声明函数但浏览器普遍支持行为各不相同。ES6 规范了块级函数声明但实际兼容性仍有差异。实践建议永远不要在if/for等块中使用函数声明改用函数表达式配合变量声明。Q3const声明的对象属性能修改吗constobj{a:1};obj.a2;// OKobj.a 变成了 2obj{b:3};// TypeError — 不能改变 obj 的绑定const约束的是变量绑定不能重新指向新的值而不是值的不可变性。对于对象和数组内部属性是可以修改的。如果需要完全不可变使用Object.freeze()。Q4为什么可以用window.xxx访问全局变量在浏览器环境中全局作用域中使用var声明或在非严格模式下未声明就赋值的变量会成为window对象的属性。但let/const声明的全局变量不会挂载到window上vara1;letb2;console.log(window.a);// 1console.log(window.b);// undefinedQ5作用域链和原型链是一回事吗不是。作用域链用于变量查找是词法嵌套结构决定的原型链用于对象属性查找是[[Prototype]]链决定的。它们解决的是不同层面的问题但查找方向都是由内向外、找到即停。