从 JS 引擎执行原理理解数据类型:栈内存、堆内存与作用域

从 JS 引擎执行原理理解数据类型:栈内存、堆内存与作用域

📢 本文从 JavaScript 引擎编译执行的底层视角出发,带你理解原始类型和引用类型在内存中的真实面貌,彻底搞懂 nullundefinedNumberSymbol 等数据类型的本质。

导读

本文适合以下读者:

  • ✅ 学过 JS 基础,知道 7 种数据类型,但说不清为什么原始类型存栈、引用类型存堆
  • ✅ 背过 null vs undefined 的区别,但换个问法就犹豫
  • ✅ 了解变量提升和暂时性死区,但不知道它们和执行上下文的关系
  • ✅ 想用一条完整的推导链把零散的知识点串起来,而不是死记硬背

阅读路线建议:

javascript 复制代码
第一节(执行上下文)→ 第二节(var/let/const)→ 第三节(栈堆内存)
                                            ↓
                                    第四节(null vs undefined)
                                    第五节(Number 精度 & BigInt)
                                    第六节(Symbol 唯一性)
                                            ↓
                                    第七节(全景总结)

🔗 第一节是整篇文章的地基------你需要先理解"编译阶段和执行阶段",后面的所有结论才能顺理成章地推导出来。如果时间有限,至少把第一节读完再跳到感兴趣的章节。


前言

学 JS 数据类型时,我背过不少结论:

  • "原始类型存在栈里,引用类型存在堆里"
  • "null 是主动赋值的空,undefined 是未定义"
  • "0.1 + 0.2 不等于 0.3,因为浮点数精度问题"

背是背下来了,但每次遇到变体题目还是会犹豫------因为我不知道这些结论是怎么推导出来的

后来我换了一个角度:不再从"结论"出发,而是从 JS 引擎执行代码的那一刻开始,一步步往下推。神奇的事情发生了------那些需要死记硬背的知识点,突然串成了一条完整的链条。

这篇文章就是我的推导过程。希望能帮你用同样的方式,真正"理解"而不是"记住"这些概念。


一、JS 引擎如何执行代码?------ 两个阶段

当我们写下一段代码时,JS 引擎并不是逐行执行的,而是分为两个阶段

js 复制代码
console.log(a); // undefined(不是报错!)
var a = 10;
console.log(a); // 10

1.1 创建阶段(编译阶段)

JS 引擎会先把整段代码扫描一遍,做两件事:

  1. 创建全局执行上下文(Global Execution Context)
  2. 在变量环境中注册变量名(注意:此时只注册名字,还没有赋值)

🧪 为什么这个例子用 var 因为 var 在创建阶段就会被初始化为 undefined,最能直观展示"声明"和"赋值"是两个独立阶段。let/const 也是两个阶段,但行为不同------后面会详细对比。

javascript 复制代码
┌─────────────────────────────────────────────┐
│           全局执行上下文                       │
│                                             │
│  变量环境(Variable Environment)             │
│  ┌──────────────┐                           │
│  │ a: undefined  │  ← 只声明,未赋值         │
│  └──────────────┘                           │
│                                             │
│  词法环境(Lexical Environment)             │
│  ┌──────────────┐                           │
│  │ (empty)       │  ← let/const 在此注册     │
│  └──────────────┘                           │
└─────────────────────────────────────────────┘

1.2 执行阶段

引擎逐行执行代码,对变量进行赋值和操作:

ini 复制代码
第1行: console.log(a) → 从变量环境取到 a = undefined → 输出 undefined
第2行: a = 10 → 变量环境中 a 更新为 10
第3行: console.log(a) → 输出 10

💡 这就是变量提升(Hoisting)的本质:不是代码被移动到了顶部,而是编译阶段就已经把变量名注册了。


二、变量环境 vs 词法环境:var、let、const 的区别

2.1 var → 存入变量环境(Variable Environment)

js 复制代码
console.log(a); // undefined
var a = 10;

var 声明的变量在创建阶段 就被注册到变量环境,并初始化为 undefined

2.2 let/const → 存入词法环境(Lexical Environment)

js 复制代码
console.log(b); // ❌ ReferenceError: Cannot access 'b' before initialization
let b = 20;

let/const 声明的变量在创建阶段 也被注册,但不会初始化 ,处于一个叫做 "暂时性死区"(TDZ, Temporal Dead Zone) 的状态。

javascript 复制代码
┌──────────────────────────────────────────────────┐
│              全局执行上下文                        │
│                                                  │
│  变量环境(Variable Environment)                  │
│  ┌──────────────┐                                │
│  │ a: undefined  │  ← var 声明,立即初始化        │
│  └──────────────┘                                │
│                                                  │
│  词法环境(Lexical Environment)                   │
│  ┌──────────────┐                                │
│  │ b: <TDZ>      │  ← let 声明,暂不初始化       │
│  └──────────────┘                                │
└──────────────────────────────────────────────────┘

2.3 三种声明方式对比

特性 var let const
存储位置 变量环境 词法环境 词法环境
初始化时机 创建阶段立即初始化 执行到声明语句时初始化 执行到声明语句时初始化
是否有 TDZ ❌ 无 ✅ 有 ✅ 有
重复声明 ✅ 允许 ❌ 报错 ❌ 报错
块级作用域 ❌ 无 ✅ 有 ✅ 有

2.4 块级作用域的词法环境

js 复制代码
{
    let x = 10;
    const y = 20;
    console.log(x, y); // 10, 20
}
console.log(x); // ❌ ReferenceError(块级作用域外不可访问)

每个 {} 块都会创建一个新的词法环境

yaml 复制代码
┌───────────────────────────┐
│  全局词法环境              │
│  ┌─────────────────────┐  │
│  │  块级词法环境         │  │
│  │  ┌────────────────┐ │  │
│  │  │ x: 10          │ │  │
│  │  │ y: 20          │ │  │
│  │  └────────────────┘ │  │
│  └─────────────────────┘  │
└───────────────────────────┘

三、栈内存与堆内存:原始类型 vs 引用类型的存储

现在我们知道了变量是怎么被声明和注册的,接下来的问题是:变量的值存在哪里?

3.1 栈内存(Stack)------ 原始类型的家

栈内存的特点:

  • 空间较小,但访问速度快
  • 由系统自动分配和释放
  • 存储大小固定的数据(原始类型)
csharp 复制代码
栈内存(Stack)
┌────────────────┐
│  变量名  │  值   │
├────────────────┤
│  a      │ null  │  ← 原始类型,值直接存在栈中
│  b      │ 42    │
│  c      │ true  │
│  d      │ "hi"  │
└────────────────┘

3.2 堆内存(Heap)------ 引用类型的家

堆内存的特点:

  • 空间较大,但访问速度相对较慢
  • 需要手动管理(JS 中由 GC 垃圾回收器自动处理)
  • 存储大小不固定的数据(对象、数组、函数等)
css 复制代码
栈内存(Stack)              堆内存(Heap)
┌──────────────┐           ┌──────────────────────┐
│ 变量名 │ 值   │           │  内存地址             │
├──────────────┤           ├──────────────────────┤
│ obj   │ 0x01 │ ────────> │ { name: "谢如是" }    │
│ obj2  │ 0x01 │ ────────> │                      │
└──────────────┘           └──────────────────────┘
   ↑                            ↑
   │  只存引用地址(指针)       │  存储实际数据
   └────────────────────────────┘

3.3 拷贝式赋值 vs 引用式赋值

理解了栈和堆的区别,就自然理解了两种赋值方式的本质:

js 复制代码
// ✅ 原始类型:拷贝值(Copy by Value)
let a = null;
let b = a;    // 把 null 这个值复制一份给 b
b = 2;        // 修改 b,不影响 a
console.log(a); // null

// ✅ 引用类型:拷贝引用地址(Copy by Reference)
let obj = { name: "谢如是" };
let obj2 = obj;           // 把 obj 的引用地址复制给 obj2
obj2.company = "字节跳动"; // 通过 obj2 修改堆内存中的对象
console.log(obj);          // { name: "谢如是", company: "字节跳动" }
// obj 和 obj2 指向同一块堆内存,所以 obj 也被修改了

内存变化图解:

vbscript 复制代码
原始类型赋值过程:              引用类型赋值过程:

Step 1: let a = null          Step 1: let obj = { name: "谢如是" }
┌──────┐                      栈            堆
│ a    │ null                 ┌──────┐    ┌─────────────────┐
└──────┘                      │ obj  │──> │ { name: "..." } │
                              └──────┘    └─────────────────┘

Step 2: let b = a             Step 2: let obj2 = obj
┌──────┐                      栈            堆
│ a    │ null                 ┌──────┐    ┌─────────────────┐
│ b    │ null ← 复制值        │ obj  │──> │ { name: "..." } │
└──────┘                      │ obj2 │──> │                 │
                              └──────┘    └─────────────────┘
Step 3: b = 2                           (同一个对象!)
┌──────┐
│ a    │ null
│ b    │ 2    ← 只改 b
└──────┘

3.4 为什么原始类型存在栈里,引用类型存在堆里?

特性 原始类型 引用类型
大小 固定(8 字节左右) 动态(可能很大)
存储 栈内存 堆内存
赋值 拷贝值 拷贝引用地址
可变性 不可变(创建后值不能修改) 可变(可以添加/删除属性)
比较 比较值是否相等 比较引用地址是否相同

🔥 关键理解 :原始类型是不可变 的。当你执行 let b = a; b = 2; 时,不是把 b 的值从 null 改成了 2,而是创建了一个新的值 2,让 b 指向它。


四、从执行上下文理解 null 和 undefined

现在我们有了底层视角,再来看 nullundefined,就清晰多了。

4.1 undefined:引擎的"占位符"

js 复制代码
let a;
console.log(a); // undefined

执行上下文视角:

javascript 复制代码
创建阶段:
  词法环境 → a: <TDZ>(暂时性死区)

执行阶段(执行 let a):
  词法环境 → a: undefined  ← 引擎自动初始化为 undefined

undefined 是 JS 引擎在变量声明但未赋值时,自动赋予的默认值

4.2 常见的 undefined 场景

js 复制代码
// 1. 变量声明未赋值
let a;
console.log(a); // undefined

// 2. 访问对象不存在的属性
let obj = {};
console.log(obj.property); // undefined

// 3. 函数没有返回值
function noReturn() {}
console.log(noReturn()); // undefined

// 4. 数组越界
let arr = [1, 2, 3];
console.log(arr[4]); // undefined

💡 底层理解 :当你访问 obj.property 时,JS 引擎在对象的属性表中找不到 property 这个键,就返回 undefined------这是语言规范规定的默认行为,不是 null

4.3 null:开发者主动标记的"空"

js 复制代码
// 场景:处理完一个大对象后,主动释放引用
let largeData = {
    list: new Array(1000000).fill("data"),  // 100 万个元素
    cached: true
};

// 使用完毕,主动断开引用
largeData = null;

内存视角:

yaml 复制代码
Step 1: let largeData = { list: [...], cached: true }

栈                    堆
┌───────────┐      ┌──────────────────────────┐
│ largeData │──>   │ { list: [100万个元素],     │
└───────────┘      │   cached: true }          │
                   └──────────────────────────┘

Step 2: largeData = null

栈                    堆
┌───────────┐      ┌──────────────────────────┐
│ largeData │ null │ { list: [100万个元素],     │ ← 无引用,等待 GC 回收
└───────────┘      │   cached: true }          │
                   └──────────────────────────┘

⚠️ GC 机制 :当堆内存中的对象没有任何变量引用它时,垃圾回收器(Garbage Collector)会在适当的时机回收这块内存。将变量设为 null 是告诉 GC:"这块内存我不用了,你可以回收了。"

4.4 null vs undefined 的本质区别

维度 null undefined
谁赋的值 开发者主动赋值 JS 引擎自动赋值
语义 "我故意让它为空" "这个东西还没定义"
typeof "object"(历史遗留 bug) "undefined"
== 比较 null == undefinedtrue ---
=== 比较 null === undefinedfalse ---
js 复制代码
// null 和 undefined 在宽松相等下相等
console.log(null == undefined);  // true
console.log(null === undefined); // false

// 实际应用
let user = {
    name: "Alice",
    address: null  // 明确表示:地址字段存在,但值为空
};

console.log(user.address); // null(字段存在,值为空)
console.log(user.age);     // undefined(字段根本不存在)

五、Number 类型:栈中的 64 位浮点数

5.1 Number 在栈中的存储方式

JavaScript 统一使用 IEEE 754 双精度浮点数(64 位)存储所有数值:

复制代码
64 位双精度浮点数结构:
┌──────┬────────────┬──────────────────────────────┐
│ 符号 │   指数      │          尾数               │
│ 1位  │  11位      │          52位               │
└──────┴────────────┴──────────────────────────────┘

5.2 经典问题:0.1 + 0.2 ≠ 0.3

js 复制代码
let a = 0.1;
let b = 0.2;
console.log(a + b); // 0.30000000000000004

为什么?因为 0.1 和 0.2 在二进制中是无限循环小数:

scss 复制代码
0.1 (十进制) = 0.00011001100110011... (二进制,无限循环)
0.2 (十进制) = 0.0011001100110011...  (二进制,无限循环)

存储时只能截断保留 52 位尾数 → 精度丢失

解决方案:

js 复制代码
// 方案一:toFixed(返回字符串)
console.log((0.1 + 0.2).toFixed(2)); // "0.30"

// 方案二:转整数计算
console.log((0.1 * 100 + 0.2 * 100) / 100); // 0.3

// 方案三:Number.EPSILON 判断相等
function isEqual(a, b) {
    return Math.abs(a - b) < Number.EPSILON;
}
console.log(isEqual(0.1 + 0.2, 0.3)); // true

5.3 BigInt:突破 64 位限制

当数值超过 Number.MAX_SAFE_INTEGER(2⁵³ - 1 ≈ 9007 万亿)时,52 位尾数就不够用了,会出现精度丢失:

js 复制代码
// Number 的精度天花板
console.log(9007199254740992 === 9007199254740993); // true 😱 两个不同的数被认为相等!

// BigInt:用后缀 n 声明,任意精度
const big1 = 9007199254740993n;
const big2 = 9007199254740992n;
console.log(big1 === big2); // false ✅ 精确区分

// 实际场景:处理超大 ID(如雪花算法生成的订单号)
const orderId = 202506131234567890123456789n;
console.log(typeof orderId); // "bigint"

💡 BigInt 不使用 64 位浮点数,它可以表示任意精度的整数。但注意:

  • BigInt 和 Number 不能混用:10n + 20 → TypeError
  • BigInt 没有小数:10n / 3n3n(不是 3.333...)

六、Symbol:运行时的唯一标识

6.1 Symbol 的"唯一性"是怎么来的?

Symbol 是 ES6 引入的第七种原始类型。虽然它也是原始类型、也存在栈里,但它有一个独特的行为:每次调用 Symbol() 都会创建一个全新的、唯一的值------即使描述符相同。

js 复制代码
console.log(Symbol("张志恒") === Symbol("张志恒")); // false
console.log(typeof Symbol("张志恒")); // "symbol"

从执行上下文的角度看:

js 复制代码
const s1 = Symbol("id");
const s2 = Symbol("id");

执行到这里时,引擎在词法环境中分别创建了两个 Symbol 值。关键点在于:Symbol("id") 不是一个"查找已存在的 Symbol"的操作,而是一个"创建一个新的 Symbol"的指令 。每执行一次 Symbol(),引擎就在内存中生成一个新的唯一值。这和字符串的行为完全不同------"id" === "id" 永远为 true,因为相同的字符串字面量指向同一个值。

css 复制代码
词法环境
┌─────────────────────────┐
│ s1: Symbol("id")  #ref1 │ ← 第一次执行 Symbol("id"),创建 #ref1
│ s2: Symbol("id")  #ref2 │ ← 第二次执行 Symbol("id"),创建 #ref2(不是同一个!)
└─────────────────────────┘

💡 和字符串的对比很能说明问题"hello" 这个字符串字面量,JS 引擎在编译阶段就确定好了;但 Symbol("hello") 是一个运行时调用,每次调用都产生新值。这就是为什么 Symbol 能做唯一键------它的"唯一性"是运行时保证的,不是编译时决定的。

6.2 Symbol 的核心应用场景

场景一:避免对象属性名冲突

js 复制代码
const ID = Symbol("id");
const user = {
    [ID]: 12345,
    name: "张志恒"
};

console.log(user[ID]); // 12345
console.log(user.ID);  // undefined(点语法会把它当成字符串 "ID")

场景二:模拟私有属性

js 复制代码
const _private = Symbol("private");

class MyClass {
    constructor() {
        this[_private] = "secret";
    }
    getSecret() {
        return this[_private];
    }
}

const obj = new MyClass();
console.log(obj.getSecret()); // "secret"
console.log(obj._private);    // undefined(无法从外部访问)

场景三:自定义迭代行为

js 复制代码
const collection = {
    items: [1, 2, 3],
    [Symbol.iterator]() {
        let index = 0;
        return {
            next: () => ({
                value: this.items[index],
                done: index++ >= this.items.length
            })
        };
    }
};

for (const item of collection) {
    console.log(item); // 1, 2, 3
}

七、完整总结:从编译到执行的全景图

7.1 JS 引擎执行代码的完整流程

javascript 复制代码
源代码
  │
  ▼
┌─────────────────────────────────────────────┐
│  创建阶段(编译)                              │
│  1. 创建执行上下文                             │
│  2. var → 变量环境,初始化为 undefined          │
│  3. let/const → 词法环境,进入 TDZ             │
│  4. 函数声明 → 变量环境,绑定函数体             │
└─────────────────────────────────────────────┘
  │
  ▼
┌─────────────────────────────────────────────┐
│  执行阶段                                     │
│  1. 逐行执行代码                               │
│  2. 赋值操作 → 更新变量环境/词法环境             │
│  3. 原始类型 → 值存在栈内存                     │
│  4. 引用类型 → 值存在堆内存,栈中存引用地址      │
└─────────────────────────────────────────────┘
  │
  ▼
┌─────────────────────────────────────────────┐
│  垃圾回收                                     │
│  无引用的堆内存对象 → GC 自动回收               │
│  手动设为 null → 加速回收                      │
└─────────────────────────────────────────────┘

7.2 七种原始类型速查表

类型 typeof 存储位置 特点
Number "number" 栈(64位浮点数) 有精度问题
String "string" 栈(或堆) 不可变
Boolean "boolean" true/false
Undefined "undefined" 引擎自动赋值
Null "object" ⚠️ 开发者主动赋值
Symbol "symbol" 每次创建都唯一
BigInt "bigint" 任意精度整数

7.3 原始类型 vs 引用类型对比

特性 原始类型 引用类型
编译时 注册变量名,初始化值 注册变量名,初始化值
运行时存储 栈内存(直接存值) 堆内存(栈中存引用)
赋值方式 拷贝值 拷贝引用地址
可变性 不可变 可变
比较 比较值 比较引用地址
内存回收 栈自动释放 堆需要 GC 回收

结语

写这篇文章的过程中,我最大的感受是:JS 里很多看似零散的知识点,其实都是从同一条主线推导出来的

那条主线就是------JS 引擎如何执行代码:

  1. 编译阶段var 进变量环境并初始化为 undefinedlet/const 进词法环境但卡在 TDZ------变量提升和暂时性死区的行为差异,根源就在这里
  2. 执行阶段 ,遇到赋值才真正写入值。原始类型直接入栈(大小固定、不可变),引用类型则"栈里放地址、堆里放数据"(大小不定、可变)------这解释了拷贝行为、比较行为、以及为什么修改 obj2 会影响 obj
  3. 回收阶段 ,堆里的数据靠 GC 扫描引用链来回收。null 就是手动掐断引用、告诉 GC "这块可以收了"------而 undefined 是引擎的默认占位符,语义完全不同

当你把这些串起来之后,Number 的精度问题、Symbol 的唯一性、BigInt 的存在意义......都不再是需要死记的结论,而是顺着这条推导链自然就能得到的答案。

希望这条推导链对你有帮助 ✨


👨‍💻 作者:ReBound

📅 日期:2026-06-12

🏷️ 标签JavaScript 执行上下文 内存机制 数据类型 前端进阶

如果觉得有帮助,别忘了点赞、收藏、关注三连哦~ ❤️

相关推荐
橘子星1 小时前
深入理解线性数据结构:栈、队列与链表
前端·javascript
郝学胜_神的一滴1 小时前
干货版《算法导论》09:让哈希表稳如泰山的终极解法
数据结构·算法
Larcher1 小时前
JS 数据类型的八重人格与内存真相
前端·javascript
Maimai108082 小时前
Web3 前端实时通信如何落地:从 SSE 订阅到行情、订单与账户状态更新
前端·javascript·react.js·前端框架·web3·状态模式
胡萝卜术2 小时前
从零搞懂 AJAX:手把手带你从 XMLHttpRequest 到 fetch,彻底理解前后端数据交互
前端·后端·面试
阿黎梨梨2 小时前
二分查找进阶:在排序数组中寻找元素的边界
javascript
洛水水2 小时前
【力扣100题】88.多数元素
数据结构·算法·leetcode
昭昭颂桉a2 小时前
TypeScript 前端的必修课,从 JS 到 TS
开发语言·前端·javascript·typescript
用户938515635072 小时前
从零实现一个 Todos 应用:原生 Ajax + Node 服务,顺便吃透 JSON.stringify
前端·javascript·后端