深入浅出 JavaScript 核心:从底层内存与编译阶段彻底看透 var、let、const

深入浅出 JavaScript 核心:从底层内存与编译阶段彻底看透 var、let、const

  • 一、内存分配与作用域(Scope)的潜规则
    • [1. 作用域的分类](#1. 作用域的分类)
    • [2. 变量的"冒泡查找规则"](#2. 变量的“冒泡查找规则”)
    • [3. 从内存角度看变量的生命周期](#3. 从内存角度看变量的生命周期)
  • [二、 作用域嵌套](#二、 作用域嵌套)
  • [三、for 循环 + setTimeout 的作用域死穴](#三、for 循环 + setTimeout 的作用域死穴)
    • [1. 经典场景代码实战](#1. 经典场景代码实战)
    • [2. 核心底幕:var 与 let 的执行上下文差异](#2. 核心底幕:var 与 let 的执行上下文差异)
  • [四、const 的"锁赋值"本质](#四、const 的“锁赋值”本质)
    • [1. 核心特性代码实战:3.js](#1. 核心特性代码实战:3.js)
    • [2. 底层原理解析:const 到底锁死了什么?](#2. 底层原理解析:const 到底锁死了什么?)
  • [五、从 V8 引擎视角看变量提升与暂时性死区](#五、从 V8 引擎视角看变量提升与暂时性死区)
    • [1. var 的变量提升(Hoisting)机制](#1. var 的变量提升(Hoisting)机制)
    • [2. let / const 的防御机制:暂时性死区(TDZ)](#2. let / const 的防御机制:暂时性死区(TDZ))
  • [六、 总结:var、let、const 的终极抉择表格](#六、 总结:var、let、const 的终极抉择表格)
    • [总结:var、let、const 的终极抉择方案](#总结:var、let、const 的终极抉择方案)

JavaScript 早期设计只用了短短一周时间。作为一款为了给网页添加简单交互(如 DOM 编程、幻灯片效果)而诞生的弱类型动态语言,它在初期难免有一些瑕疵。

随着 2015 年 ES6(ECMAScript 2015) 标准的发布,企业级大型项目开发迎来了春天。在现代前端开发中,理解变量声明的底层逻辑是迈向高级工程师的第一步。本文将从作用域、内存、编译机制 等底层视角,带你彻底搞懂 varletconst 的本质。

一、内存分配与作用域(Scope)的潜规则

JavaScript 早期设计只用了短短一周时间。作为一款为了给网页添加简单交互(如 DOM 编程、幻灯片效果)而诞生的弱类型动态语言,它在初期难免有一些瑕疵。

随着 2015 年 ES6(ECMAScript 2015) 标准的发布,企业级大型项目开发迎来了春天。在现代前端开发中,理解变量声明的底层逻辑是迈向高级工程师的第一步。

1. 作用域的分类

在 JavaScript 中,变量并不是凭空存在的,它们都属于特定的作用域。作用域决定了程序在哪些区域可以访问到这些变量:

  • 全局作用域(Global Scope): 代码最外层的区域。在全局声明的变量,在整个代码的任何地方都可见。
  • 函数局部作用域(Local Scope): 在函数内部声明的变量,只有函数内部可以访问,外部无法窥探。
  • 块级作用域(Block Scope): ES6 新增,由一对大括号 { } 包裹的区域(如 if 块、for 循环块、或者纯粹的一个 {} 代码块)。

2. 变量的"冒泡查找规则"

变量的声明是弱类型的,其类型完全由值决定。当我们在当前作用域访问一个变量时,JavaScript 引擎会遵循以下规则:

  1. 先在当前作用域查找,找到了就直接使用。
  2. 如果没找到,就向上一层外层作用域 查找(冒泡查找)。
  3. 一直查找到最顶层的全局作用域 ,如果依然没有找到,程序就会停下来并抛出经典的错误:ReferenceError: XXX is not defined

3. 从内存角度看变量的生命周期

在底层,变量的声明本质上是在内存中申请了一块区域

  • 函数或代码块运行完毕后,该区域对应的内存就会被系统垃圾回收(GC)
  • 销毁函数的同时会回收内存,这就是变量的生命周期。

二、 作用域嵌套

为了更直观地理解上述规则,我们直接来看下面这段真实的经典作用域嵌套代码:

javascript 复制代码
var height = 200; // 区域作用域 global scope 全局作用域

function setWidth(){
    // 局部作用域
    var width = 100;
    console.log(width, height); // 顺着作用域链:当前找到 width(100),冒泡向外找到 height(200)
}

setWidth(); // 执行函数,创建局部作用域 -> 运行后销毁并触发垃圾回收

var age = 100;
if (age > 12)
{
    // var 不支持块级作用域,dog 溢出成为了全局变量
    var dog = age * 7;
    console.log(dog);
    
    // es6 常量 块级作用域
    let x = 100; // x 被牢牢锁在 if 的大括号 { } 内部
    dog++;
}

// 验证 var 与 let 的块级作用域区别
console.log(dog); // ✅ 正常打印出 701,证明 var 声明的变量在 if 块外依然可见
console.log(x);   // ❌ 报错!ReferenceError: x is not defined,证明 let 在退出块级作用域后已被垃圾回收

三、for 循环 + setTimeout 的作用域死穴

这是前端面试中极为高频的经典考点,它生动地展示了 var 的无作用域限制与 let 的块级作用域在异步场景下的本质区别。

1. 经典场景代码实战

先来看这段极具代表性的测试代码:

javascript 复制代码
// 全局作用域
{
    // 代码块
    // 申明了变量,属于当前作用域
    const name = '张三';
    console.log(name);
}

// 经典的定时器循环
for(let i = 0; i < 10; i++)
{
    // 同步代码
    console.log(i);
    
    // 异步代码
    setTimeout(function(){
        console.log(`This number is ${i}`);
    }, i * 1000);
}

2. 核心底幕:var 与 let 的执行上下文差异

❌ 如果把上面的循环改成 var i = 0 会发生什么?

早期的 JavaScript 中 var 不支持块级作用域。如果使用 var,整个 for 循环在全局执行上下文中有且仅有一个变量 i

  • 同步阶段 : for 循环作为同步代码会以极快的速度执行完毕,此时全局的 i 早已被一路累加,退出了循环,最终停留在 10
  • 异步阶段 : 当一秒钟后,异步的 setTimeout 回调函数开始触发时,它内部的 console.log(i) 顺着作用域链向外查找变量 i。由于没有块级作用域的隔离,它只能抓到全局那个已经变成 10 的 i。结果就是:每隔一秒,屏幕上打印的通通是 This number is 10

使用 let i = 0 的内存真相

换成 let 之后,由于 let 完美支持块级作用域,底层的内存模型发生了降维打击的变化:

  • 独立作用域嵌套: 每次循环迭代,JavaScript 引擎都会在底层为当前的这次循环专门创建并嵌套一个独立的块级作用域。

  • 闭包锁死: 尽管 for 循环在同步阶段很快就跑完了,但每一次循环所诞生的那个大括号 {} 内部,都独立锁死了一个专属于当前状态的 i 变量。当异步的 setTimeout 回调函数被触发时,它通过闭包牢牢记住了它当时诞生时所属的那一个特定块级作用域里的 i 值。从而实现了完美依次打印 0, 1, 2...9 的正确效果。

四、const 的"锁赋值"本质

在现代开发中,var 已经被彻底弃用,取而代之的是 let(变量)和 const(常量)。而对于 const 的理解,绝不仅仅是"不可变"那么简单。

1. 核心特性代码实战:3.js

我们直接用 代码测试用例来层层剖析:

javascript 复制代码
// 1. 常量必须在声明的一开始就要赋值
const item = 1;
let a;  // let 允许只声明不赋值,默认初始化为 undefined

// 2. 简单数据类型(基本类型)
const key = 'abc123';
let points = 50;
points = 51; // let 的值可以任意改变

// 🌟 警惕:let 不仅值可以改变,连数据类型也可以动态改变
// 虽然 JS 允许这么做,但类型乱改会导致代码极度不好维护,实际开发中千万不要这么干!
points = "52"; 

let winner = false;
winner = 'moss';

// 3. 复杂数据类型(对象/数组)
// 核心规则:值可以改变,但是数据类型和指针引用不能改变
const person = {
    name: 'moss',
    age: 18
}

person.age++; // ✅ 完全合法!属性值成功从 18 变为了 19
console.log(person);

// ❌ 报错!TypeError: Assignment to constant variable.
// 试图从复杂类型的对象直接扭转成字符串类型,或者重新用 = 赋值,都是绝不允许的!
// person = "111";

2. 底层原理解析:const 到底锁死了什么?

很多初学者会疑惑:既然 person 是用 const声明的常量,为什么执行 person.age++ 却不报错?

核心结论:const 锁死的永远是 "= 赋值" 这个动作本身,而不是值的内容。

① 简单数据类型(栈内存物理锁定)

对于字符串、数字、布尔值 等基本类型,它们的值是直接死死存放在栈内存 中的。如果你想修改 key 的值,就必须重新使用 = 赋值符号来让它指向一个新的内存空间。这个 = 动作会被 const 的底层机制当场拦截并抛出 Assignment to constant variable 错误。

② 复杂数据类型(堆内存地址锁定

对于对象(Object)数组(Array)等复杂类型,它们真实庞大的数据是存放在堆内存中的。而变量 person 放在栈内存里的,仅仅是一个指向堆内存数据起始位置的 "引用地址(指针)"

当你执行person.age++ 时,你是在顺着指针找到堆内存里的数据并修改它,这个过程完全没有触发 = 赋值符号,指针地址没有发生任何变更。所以这是完全合法的。

当你试图执行 person = "111" 时,你试图用 = 把栈内存里的这个指针地址抹掉,换成一串字符串的物理值。这种改变指针或改变变量类型的 = 行为,会瞬间触发 const 的防御机制,直接报错崩溃

五、从 V8 引擎视角看变量提升与暂时性死区

要真正理解为什么 letconstvar 更安全,我们必须把视角移到 JavaScript 引擎(如 V8)的底层执行机制中。

一段 JavaScript 代码在 CPU 中运行,并不是死板地"读一行执行一行",而是分为两个关键阶段:

  • 编译阶段(Compile Phase): 引擎会对代码进行词法分析和语法分析,并准备好相应的执行上下文与变量环境。

  • 执行阶段(Execution Phase): 引擎按照生成的字节码,真正开始从上到下一行行执行代码。

1. var 的变量提升(Hoisting)机制

我们直接通过测试源码来剖析它的底层轨迹:

javascript 复制代码
// 执行顺序演示
console.log(pizza); // 打印 undefined
var pizza = 'Deep Dish';

V8 引擎在幕后的真实动作

  • 在编译阶段 : 引擎扫描到 var pizza,会提前在当前的"变量环境" 中为 pizza 开辟好内存空间。因为是 var 声明,引擎会网开一面地把它默认初始化为undefined

  • 在执行阶段 : 当代码走到第一行 console.log(pizza) 时,引擎在变量环境里一查,发现这个变量确实存在,且值是 undefined,于是静默打印输出。直到走到第二行,原地的赋值语句才会把 'Deep Dish' 填入对应内存。

也就是说,你写的代码在底层实际执行起来,相当于被无形中重写成了这样:

javascript 复制代码
var pizza;                  // 声明被提升至作用域顶部,并默认初始化为 undefined
console.log(pizza);         // 打印 undefined
pizza = 'Deep Dish';        // 赋值留在原地

这种设计极不严谨,经常会导致开发者在不知情的情况下调用了还没初始化的变量,留下隐蔽的 Bug。

2. let / const 的防御机制:暂时性死区(TDZ)

为了彻底终结变量提升带来的安全隐患,ES6 的 letconst 改变了游戏规则。

javascript 复制代码
console.log(pizza);         
// 直接抛出运行时错误:ReferenceError: Cannot access 'pizza' before initialization
let pizza = 'Deep Dish';

V8 引擎在幕后的真实动作

  • 在编译阶段 : 引擎扫描到 let pizza 时,同样会提前在内存中为 pizza 登记开辟空间(也就是说,letconst 也有提升动作)。但是,引擎绝对不会为它初始化任何值,而是打上一个"未初始化(uninitialized)"的特殊死区标记。

  • 在执行阶段 : 从块级作用域开始,直到执行到 let pizza = 'Deep Dish' 这行声明代码之前的整个区域,都在底层被称为暂时性死区(Temporal Dead Zone, TDZ)。

  • 在死区内 ,凡是企图提前访问、打印、或操作该变量的行为,都会被引擎拦截并强行报错:ReferenceError: Cannot access 'pizza' before initialization

这也是 let / constvar 更安全的原因之一------它绝不会让你在变量"还没准备好"的时候,静默地拿到一个莫名其妙的 undefined

六、 总结:var、let、const 的终极抉择表格

为了让这篇博客有一个完美的结尾,我们用一张最直观的横向大表,把这三者的底层特性进行全面复盘,方便读者死记硬背:

总结:var、let、const 的终极抉择方案

序号 声明方式 块级作用域支持 变量提升表现 初始赋值要求 允许重复声明 现代项目推荐指数
1 var ❌ 否 是 (编译期静默初始化为 undefined) ❌ 否 彻底淘汰
2 let ❌ 否 (进入 TDZ 暂时性死区,未声明前访问直接报错) ❌ 否 ❌ 否 🌟 推荐(仅用于计数器或确实需要重写的变量)
3 const ❌ 否 (进入 TDZ 暂时性死区,未声明前访问直接报错) 是 (声明的同时必须立刻初始化赋值) ❌ 否 🔥 绝对首选(常量、对象引用、数组、函数声明)

在编写现代 JavaScript 项目时,请彻底在你的编辑器中封印 var。在写新代码时,默认无脑优先使用 const。因为大部分复杂对象引用、配置参数在整个生命周期内都是保持静态的,锁死赋值符号 = 能极大地保护代码稳定性。只有当你明确知道这个变量需要用于 for 循环递增、或者后面需要被重新覆盖时,再小心翼翼地把声明换成 let。把权力锁在安全的盒子里,代码才会真正健壮!

本球分享到此结束,我们下期再见👋

相关推荐
Python+9914 小时前
C++ 注解(注释)完整讲解
java·开发语言·c++
ZC跨境爬虫14 小时前
跟着 MDN 学CSS day_27:(处理不同方向的文本)
前端·javascript·css·ui·html
Reisentyan14 小时前
[Review]GoLang Learn Data Day 3
java·开发语言·golang
H_老邪14 小时前
Java基础-Java 核心语法与面向对象(底层原理级)篇
java·开发语言
承渊政道14 小时前
我的创作纪念日写在创作第256天:从第一篇C语言博客,到一路向前的自己!
c语言·开发语言·笔记·学习·学习方法
山有木兮啊14 小时前
Windows C++ 跨 CRT 内存管理与安全释放
开发语言·c++·windows
lly20240614 小时前
Linux Memcached 安装指南
开发语言
止语Lab14 小时前
从文件到配置中心:Go 配置管理的三个升级拐点
开发语言·后端·golang
小张小张爱学习14 小时前
Java-io流
java·开发语言