阅读视图

发现新文章,点击刷新页面。

把「作用域链」讲透:6 道面试题背后的编译期/执行期 + 一次讲清 JS 垃圾回收(GC)

你会发现:大多数作用域链题,不是在考你“会不会算输出” ,而是在考你能不能把 JavaScript 运行时拆成两句话讲清楚:

  1. 编译期:声明(var/function)先“挂上去”(提升),但赋值不提升
  2. 执行期:读/写变量都沿着作用域链找(找不到时的行为决定了坑有多深)

下面我用一套“固定解题模板”把 6 道题讲到可迁移,然后把 GC 也用同样的思路讲成体系。内容与题目、图片全部保留并强化讲解。


一套通用解题模板:作用域链题别背答案

以后看到任何“打印输出题”,你就按这个模板走,十题九稳:

Step 0:先画作用域(只要三件事)

  • 变量/函数分别声明在哪一层作用域
  • 函数定义位置(决定它的父级作用域,也就是“词法作用域”)
  • 执行时从“当前作用域 → 父级 → … → 全局”查找

Step 1:拆成编译期 & 执行期

  • 编译期:var 声明提升,初始值是 undefined;函数声明也提升(更强)

  • 执行期:

    • 读变量(RHS) :我要“取值”,沿作用域链找
    • 写变量(LHS) :我要“赋值”,也沿作用域链找,找不到会触发“隐式全局”(非严格模式)

你能把“这题是在编译期埋坑,还是执行期沿链找到/找不到导致的”说清楚,基本就过关了。


题 1:函数里改全局变量,为什么立刻生效?

var n = 100
function foo(){
  n = 200
}
foo()
console.log(n) // 200

结论:输出 200

按模板拆解

  • 作用域定位n 声明在全局(GO),foo 的父级作用域也是全局。
  • 执行期(LHS 赋值) :在 foo 内执行 n = 200,引擎会沿作用域链找 n:当前作用域没有 → 去全局找到 n → 直接改掉全局 n

更“面试官爱听”的一句话

这是一次 LHS 引用:赋值操作会沿作用域链定位到“最近的同名变量”,这里最近的是全局的 n,所以全局被改写。

图示(保留原图)


题 2:同名变量 + var 提升:为什么先 undefined 再正常?

function foo(){
  console.log(m)
  var m = "吴"
  console.log(m);
}
var m = "why"
foo()
// undefined
// 小吴

结论:第一次打印 undefined,第二次打印 "小吴"

关键点只有一个:遮蔽 + var 提升

  • foo 里也声明了 var m,它会在 编译期提升到 foo 作用域顶部,初始为 undefined
  • 因为“就近原则”,foo 内部对 m 的读写都优先命中 函数自己的 m,外层的 m="why" 被遮蔽了。

等价于:

var m
function foo(){
  var m
  console.log(m) // undefined(提升后的默认值)
  m = "小吴"
  console.log(m) // "小吴"
}
m = "why"
foo()

常见追问怎么答

  • 问:为啥不是打印外层 "why"
    答:因为 foo 作用域里存在同名 m查找在命中第一个同名标识符时停止,形成遮蔽。

题 3:父级作用域看“函数写在哪”,不看“在哪调用”

var n = 100

function foo1(){
  console.log("这是foo1内部",n);
}

function foo2(){
  var n = 200
  console.log("这是foo2内部",n);
  foo1()
}

foo2()
console.log("这是最外层",n);

结论输出顺序

  • foo2 内部:200
  • foo1 内部:100
  • 最外层:100

核心原因:词法作用域(lexical scope)

  • foo1 定义在全局,它的父级作用域就是全局
  • 即使它在 foo2 里被调用,它也不会“认 foo2 当爹”

一句话总结:

作用域链 = 写代码时就确定的链(函数写在哪决定父级作用域),不是运行时调用栈决定的。


题 4:return 前的 var 也会提升:为什么拿不到全局的 a?

var a = 100

function foo(){
  console.log(a)
  return
  var a = 200
}

foo() // undefined

结论:打印 undefined

为什么这题特别“阴”?

  • 很多人以为 return 之后代码不执行,所以 var a 不存在
  • var编译期处理:var a 依然会被提升到函数顶部,初始值 undefined

等价于:

var a = 100
function foo(){
  var a
  console.log(a) // undefined(命中的是函数内 a)
  return
  a = 200
}
foo()

一句话加分

这题不是在考 return,而是在考 “var 提升会提前制造一个同名变量,从而遮蔽外层变量”


题 5:var a = b = 10:谁是全局变量?

function foo(){
  var a = b = 10
}
foo()
console.log(a);
console.log(b);

结论a 报错(未定义),b10(非严格模式下)。

拆解(从右往左)
var a = b = 10 实际上是:

b = 10       // 注意:没有声明!
var a = b
  • a:被 var 声明在 foo 作用域内,函数外拿不到
  • b:没有 var/let/const,在非严格模式下会变成隐式全局变量(挂到全局对象上)

面试官很爱追问:严格模式呢?

  • 严格模式下:b = 10 会直接抛 ReferenceError,因为禁止隐式全局。

补充:隐式全局变量到底有多危险

function foo(){
  m = 200
}
foo()
console.log(m); // 200

这段之所以能跑,是因为非严格模式下 m 被“偷偷”挂到全局上了。
真实项目里它会带来:

  • 污染全局命名空间(更容易冲突)
  • 更难定位数据来源(调试成本爆炸)
  • 更容易产生“意外长生命周期对象”(和内存问题强相关)

建议:业务代码默认开启严格模式 / 使用 ESM(天然严格)+ ESLint(no-undef / no-global-assign)。


垃圾回收 GC:从堆/栈到可达性

如果说“作用域链”是在解释变量怎么找,那 GC 就是在解释对象什么时候死

先记住一句话:

内存有限,所以不再需要的对象必须被回收;关键问题是:GC 怎么判断“你不需要了”?


堆 vs 栈:到底谁存什么?

  • 栈(Stack) :放基础类型的值、以及引用类型的“地址/指针”
  • 堆(Heap) :放对象实例本体(数组、对象、函数对象等)

文中这部分总结得很清楚:基础类型偏栈,复杂类型偏堆,栈里保存指向堆的引用。

图示(保留原图)


两大核心算法:引用计数 vs 标记清除

1)引用计数(Reference Counting)

思路很直观:对象记录自己被引用了几次(retain count)。

  • 引用 +1,断开 -1
  • 计数变 0 → 可以回收

致命缺陷:循环引用

var obj1 = {friend:obj2}
var obj2 = {friend:obj1}

两个对象互相引用,计数永远不为 0 → 回收不了 → 内存泄漏。

图示(保留原图):


2)标记清除(Mark-Sweep):JS 最主流的“可达性”路线

V8 等 JS 引擎主流使用“可达性”(Reachability):从“根对象”出发,能走到的对象就是活的。

过程

  1. 从 root 出发遍历引用图,能到达的标记为“可达”
  2. 没被标记的就是“不可达” → 清除回收

它为什么能解决循环引用?

  • 循环引用本身不重要,重要的是:这坨循环是否还能从 root 到达
  • 到不了,就是垃圾,一样清。

图示(保留原图):


V8 为啥更快:分代、增量、闲时、整理

现实世界里,“一次性全量标记清除”会带来 STW(暂停)和碎片问题,所以引擎会做工程级优化:

  • 标记整理(Mark-Compact) :回收时把存活对象往一边搬,减少碎片
  • 分代收集(Generational) :新对象死得快(新生代频繁收),老对象活得久(老生代低频收)
  • 增量收集(Incremental) :把一次长暂停拆成多段小暂停
  • 闲时收集(Idle-time) :尽量在 CPU 空闲时做 GC,降低卡顿感

V8 的堆内存分区(保留原图):


面试加分:如何从代码层面避免内存问题

GC 是“清理工”,但你写代码时决定了垃圾是“可达”还是“不可达”。下面这些回答,既能落地又能加分

  1. 不要制造意外长生命周期引用
  • 全局变量、单例缓存、模块级 Map/Array:如果只增不删,对象就一直可达
  • 解决:设计“上限 + 淘汰策略”(LRU / TTL),或者主动 delete/clear
  1. 事件监听与定时器要能解除
  • addEventListener / setInterval 如果不移除,会让回调闭包一直可达
  • 解决:组件卸载/页面销毁时 removeEventListenerclearInterval
  1. 避免隐式全局
  • m = 200 这种写法会把对象挂到全局,生命周期直接拉满
  • 解决:严格模式 + ESLint
  1. 理解“可达性”的调试方式
  • 当你怀疑泄漏:不是问“GC 为什么不回收”,而是问

    “是谁还在引用它?从 root 到它的引用链是什么?”


结尾:把知识变成“可迁移能力”

你会发现,题目怎么变都逃不掉这两条主线:

  • 作用域链题:编译期提升 + 执行期沿链查找(读/写)
  • 内存题:对象是否还可达(谁还在引用它)

后续如果你要接着写“闭包”那一章,这篇其实已经把最关键的地基铺好了:闭包的本质,就是“让某些变量在函数执行完后依然可达”,从而延长它的生命周期。

🚀JS 为什么能跑这么快?一文把 V8 “翻译官 + 加速器” 机制讲透(AST / 字节码 / JIT / 去优化)

你的 JS 到底怎么跑起来的?一文看懂 V8:从源码到机器码的“流水线”(含图解)

写下 console.log('hi') 的那一刻,CPU 其实完全看不懂。
真正让 JS “跑起来”的,是 JavaScript 引擎——尤其是 Chrome/Node.js 背后的 V8
这篇文章用一条清晰的流水线,把 V8 的核心机制讲透:Parse → AST → Ignition(字节码) → TurboFan(机器码) → Deopt(去优化回退)


文章推荐:

代码10倍提速!吃透底层架构就是如此简单-腾讯云开发者社区-腾讯云

先建立直觉:V8 是一条“翻译+加速”的流水线

可以把 V8 想象成一个“会学习的翻译官”:

  • 第一目标:让代码尽快跑起来(启动快)
  • 第二目标:把经常跑的代码越跑越快(热点优化)
  • 第三目标:发现假设错了就回退重来(去优化 Deopt)

接下来所有细节,都围绕这三句话展开。


01|为什么 CPU 才是最终执行者

计算机的大脑——CPU(中央处理器) ,只认识机器语言(指令集)。

  • 高级语言:JavaScript, Python, Java(人类易读,抽象程度高)。
  • 机器语言:二进制指令(CPU 易读,直接控制硬件)。

JavaScript 引擎的本质,就是一个超级翻译官。它的核心职责就是:解释和执行 JS 代码,将其转换为 CPU 能看懂的机器指令。

所以:CPU 是“执行者”,V8 是“翻译官 + 加速器”。

再看一张更直观的图:代码最终一定要落到 CPU 可执行的机器码上。


02|JavaScript 引擎在浏览器里处在什么位置

2.1 常见的翻译官(JS 引擎)

虽然今天的主角是 V8,但在这个江湖里还有其他大佬:

  • SpiderMonkey:JS 之父 Brendan Eich 的作品,Firefox 的心脏。
  • JavaScriptCore:苹果 WebKit 内核的一部分,Safari 的动力源。
  • Chakra:微软 IE/Edge 时代的产物。
  • V8:Google 的杰作,它不仅驱动了 Chrome,更是 Node.js 的核心。

浏览器内核并不是“只有渲染”,它通常至少包含两大块:

  • 渲染相关:HTML/CSS 解析、布局、绘制
  • 脚本相关:解析并执行 JavaScript

以 WebKit 举例:它可以拆成 WebCoreJavaScriptCore 两部分(JS 引擎就是内核的一部分)。遇到 <script> 标签时,WebCore 会暂停工作,把控制权交给 JS 引擎。这就是为什么我们常说 JS 会阻塞 DOM 渲染。


2.2、 为什么 V8 能从众神中脱颖而出?

V8 是用 C++ 编写的高性能开源引擎。它之所以强,主要归功于这几点:

  1. JIT (Just-In-Time) 即时编译:V8 抛弃了传统的“先转字节码再慢慢解释”或者“全量编译”的极端,而是采用了混合模式。它能在运行时将 JS 代码直接编译成机器码,速度极快。
  2. Node.js 的心脏:V8 证明了 JS 不仅仅能跑在浏览器,也能跑在服务器端,实现了全栈的可能。
  3. 垃圾回收(GC) :V8 拥有极其先进的分代式垃圾回收机制(后续文章我们将专门拆解)。

03|V8 全流程:从源码到机器码

把 V8 的执行流程浓缩成 6 步,会非常清晰:

  1. Parse(解析) :源码 → AST(抽象语法树),并采用 Lazy Parsing(函数即将执行时才完整解析)
  2. Ignition(解释器) :AST → 字节码 Bytecode
  3. 执行字节码:先跑起来,并收集运行信息(类型、分支、调用频率…)
  4. TurboFan(优化编译器) :热点代码 → 优化后的机器码
  5. Deopt(去优化) :假设不成立(常见是类型变化)→ 回退到字节码
  6. 机器码执行:最终交给 CPU

用一张图把这条流水线钉死在脑子里:

同时,AST 长什么样?大概是这种结构化树形表示:


04|Parse 细节:词法分析、语法分析与 AST

很多人卡在“Parse 解析”这一步,原因是:概念名词多,但直觉不够

4.1 词法分析:把代码拆成 token(最小语法单元)

在生成树之前,代码首先要被“打碎”。这就是 词法分析

V8 会将代码拆解成一个个最小单元,称为 Tokens

比如代码:const name = "coderwhy"

会被拆解为:

  • const (Keyword 关键字)
  • name (Identifier 标识符)
  • = (Operator 操作符)
  • "coderwhy" (StringLiteral 字符串字面量)

4.2 语法分析:把 token 重新组装成树(AST)

拿到 Tokens 后,V8 会依据语法规则,将其组装成一棵树——AST(抽象语法树)

AST 是前端工程化的基石,Babel 转译、ESLint 检查、Vue 模板编译,本质上都在操作 AST。

看看一段简单的代码生成的 AST 结构:

JavaScript

function sayHi(name) {
  console.log("Hi " + name)
}

对应的 AST 结构概览:

  • FunctionDeclaration (函数声明: sayHi)

    • Identifier (参数: name)

    • BlockStatement (函数体)

      • ExpressionStatement (表达式)

        • CallExpression (调用 console.log)

05|为什么要保留“字节码”这一层

直觉上会觉得:少一层转换就更快,那为什么不直接 AST → 机器码?

因为工程里真正的目标不是“某一步最快”,而是“整体更快、更稳、更可控”。保留字节码主要带来:

  1. 跨平台:字节码不绑定某一种 CPU 指令集
  2. 优化更聪明:先跑字节码,收集运行数据,再决定怎么生成更优机器码
  3. 更安全、更可控:更容易做隔离、策略、内存管理
  4. 更容易调试:断点/单步在字节码层更容易实现

配合这张图理解,会很顺:


06|架构拆解:Parse / Ignition / TurboFan 各做什么

用“岗位职责”来记:

  • Parse:把 JS 代码变成 AST(解释器不直接认识 JS 源码)
  • Ignition:把 AST 变成字节码并执行,同时收集 TurboFan 需要的运行信息(比如类型信息)
  • TurboFan:把热点字节码编译成更快的机器码(并持续迭代优化)

这里有一个非常关键的运行规律:

热点函数会被优化,但类型变化等情况会触发去优化回退


07|预解析 vs 全量解析:Lazy Parsing 为什么能让启动更快

V8 并不会“上来就把一切都解析得巨细无遗”,它会做取舍:

7.1 预解析(Pre-parsing)

  • 目标:快速扫描,提取结构信息(变量/函数声明等)
  • 特点:不深挖函数体内部逻辑 → 更快

7.2 全量解析(Full parsing)

  • 目标:把函数体、表达式、语句细节全部建出来
  • 特点:AST 更完整 → 便于后续生成字节码与优化

因此,“函数没执行会不会生成 AST?”更准确的回答是:

  • 会生成一个简化的结构架子(预解析)
  • 真要执行之前,会补齐为完整 AST(全量解析)

08|走一遍官方图:token、AST、字节码到底怎么来的

先准备一段模板代码:

name = "XiaoWu"
console.log(name)

function sayHi(name) {
  console.log("Hi " + name)
}

sayHi(name)

8.1 官方流程图:从输入到字节码

这张图非常经典,建议收藏:

按图理解就是:

  • Scanner:扫描字符流 → 生成 tokens
  • PreParser:做预解析(快速判断结构)
  • Parser:构建 AST
  • Bytecode:AST → 字节码

8.2 token 长什么样(词法分析结果)

下面是典型 token 形态(摘取关键类型,方便理解):

Token(type='Keyword', value='const')            // 关键字
Token(type='Identifier', value='name')          // 标识符
Token(type='Operator', value='=')               // 运算符
Token(type='StringLiteral', value='"coderwhy and XiaoYu"') // 字符串字面量
Token(type='Punctuation', value=';')            // 标点符号

Token(type='Identifier', value='console')
Token(type='Punctuation', value='.')
Token(type='Identifier', value='log')
Token(type='Punctuation', value='(')
Token(type='Identifier', value='name')
Token(type='Punctuation', value=')')
Token(type='Punctuation', value=';')

8.3 语法分析:预解析如何参与

这张图专门解释“预解析/解析”的关系:


09|热点优化与去优化:为什么“有时突然变慢”

V8 会把被频繁执行的函数标记为 热点函数,然后交给 TurboFan 编译为更快的机器码。

但注意:优化是有前提假设的。最常见的假设就是“类型稳定”。

来看这个例子:

function sum (num1,num2){
  return num1 + num2
}

// 多次调用 -> 可能成为热点函数 -> 被优化
sum(20,20)
sum(20,20)

// 类型突然变化 -> 之前的机器码假设不成立 -> 去优化回退
sum('xiaoyu','coderwhy')

发生了什么?

  • 前两次传入 number,优化器可能会假设“这里一直是 number 加法”
  • 第三次突然变成 string 拼接,机器码可能无法正确处理 → 回退到字节码重新收集信息,再决定是否重新优化

这就是性能“抖一下”的根源之一:Deopt(去优化)


10|字节码与机器码(了解即可):JIT 到底做了什么

机器码的生成通常依赖 JIT(Just-In-Time Compilation,即时编译)

  • 把字节码转换成本地机器码
  • 把结果缓存起来
  • 后续执行直接复用缓存的机器码(更快)

TurboFan 作为优化编译器,会基于 IR(中间表示)做多层优化(类型、内联、控制流等):

同时,字节码到机器码的过程中,会存在不同优化策略:

这里还有两张配图(保持原样保留):


结尾:把知识用起来

看到这里,你是否对那个黑盒子一样的浏览器有了新的认识?

我们简单回顾一下 V8 的奇幻旅程:

  1. Scanner & Parser:把代码拆解成 Tokens,再组装成 AST(注意预解析优化)。
  2. Ignition:把 AST 翻译成字节码,边解释边执行。
  3. TurboFan:在后台偷偷观察,把热点代码编译成机器码,但一旦类型变化,就会被打回原形。

下期预告:

理解了执行流程,还有一个大魔王在等着我们——内存管理

  • JS 的闭包为什么会导致内存泄漏?
  • V8 的垃圾回收(GC)是如何区分“新生代”和“老年代”的?
  • 全停顿(Stop-the-world)是什么?

❌