变量,作用域与内存
约 3357 字大约 11 分钟
2025-11-11
原始值与引用值
在上一章中,我们提到了 JavaScript 的变量类型有原始数据类型和引用数据类型。 原始值就是由原始类型声明的值,引用值则是由多个原始值或引用值构成的对象。 它们之间的区别不仅表现在数据结构上,还有其他区别。
1 .动态属性
原始值不能有属性,即使在声明后添加属性不会发生错误,但并不会添加成功。
const user = "zhangsan";
user.age = "18";
console.log(user); // zhangsan
console.log(user.age); // undefined引用值可以有属性,且在声明后可以增加、删除和修改属性。
const user = {
name: "zhangsan",
};
user.age = "19";
console.log(user); // {name: 'zhangsan', age: '19'}
console.log(user.age); // 19
console.log(user.hasOwnProperty("age")); // true
delete user.age;
console.log(user); // {name: 'zhangsan'}2. 复制值
原始值与引用值在将一个值赋给变量时,都会复制这个值,但它们的复制方式并不同。
原始值复制时会将值复制到新的位置,即复制前后的两个值相互独立,并不影响。
let str = "hello";
let str2 = str;
str2 = "world";
console.log(str); // hello
console.log(str2); // world引用值复制时,也会将值复制到新的位置。但复制的还有指针,它们指向存储在堆内存中的相同对象。 因此这两个对象实际上指向同一个对象,在一个对象上象进行修改时,其变化会在另一个对象中体现出来。
let obj1 = {
name: "Python",
};
let obj2 = obj1;
obj2.name = "JavaScript";
console.log(obj1); // {name: 'JavaScript'}
console.log(obj2); // {name: 'JavaScript'}3. 传递参数
在 JavaScript 中,函数的参数是按值传递的,即函数调用时参数的值会被复制给参数变量。所以函数内部修改参数与原始值和引用值的复制一样。
传入的原始值在函数内部被改变,并不会影响外部的值,因为在传入时它们就是两个值,它们之间是相互独立的。而传入的引用值,外部与内部指向同一个对象,因此修改对象属性,也会在函数外部体现。 在函数内部声明的引用类型,当函数退出时会被销毁。
// 传递原始值
let str = "hello";
function change(s) {
s = "world";
}
change(str); // 调用 change 传入 str 原始值
console.log(str); // 仍然输出 hello
// 传递引用值
let obj = {
name: "Python",
};
function changeObj(o) {
o.name = "JavaScript";
let obj = new Object();
obj.name = "C++";
return obj;
}
console.log(changeObj(obj));
// changeObj(obj); // 调用 changeObj 函数传入 obj 引用值
console.log(obj); // 输出 {name: 'JavaScript'} ,str 的被修改了执行上下文与作用域
执行上下文 是 JavaScript 代码被评估和执行时所在环境的抽象概念。任何代码在 JavaScript 引擎中运行时,都在一个执行上下文中进行。
类型
JavaScript 中有三种类型的执行上下文:
- 全局执行上下文
- 这是默认的、最外层的上下文。
- 在浏览器环境中,它关联着
window对象。一个程序中只有一个全局上下文。 - 它会做两件事:创建一个全局对象(浏览器中为
window),以及将this的值设置为这个全局对象。
- 函数执行上下文
- 每次调用函数时,都会为该函数创建一个新的函数执行上下文。
- 每个函数都有自己独立的执行上下文,但它们都是在函数被调用时才会创建,而不是在函数定义时。
- Eval 函数执行上下文
- 在
eval函数内部执行的代码也会获得自己的执行上下文。 - 但由于
eval通常不被推荐使用,我们在此不做深入讨论。
执行过程
执行上下文分为两个主要阶段:
- 创建阶段
- 执行阶段
阶段一:创建阶段
在代码执行之前,执行上下文会被创建。此时,引擎会完成以下工作:
A. 创建变量对象B. 建立作用域链C. 确定 this 的值
让我们详细看看这三个步骤:
A. 创建变量对象
变量对象是一个与上下文相关的数据作用域,它存储了在该上下文中定义的变量和函数声明。
- 对于函数上下文:变量对象也被称为活动对象。
- 创建过程遵循以下规则:
- 处理函数声明(函数提升):扫描所有函数声明,在变量对象中创建一个同名的属性,并指向该函数在内存中的引用。如果函数名已存在,则覆盖之前的。
- 处理变量声明(变量提升):扫描所有通过
var声明的变量,在变量对象中创建一个同名的属性,但将其初始化为undefined。 如果变量名已存在,则不会影响已存在的值(例如,不会覆盖已创建的函数引用)。 - 处理函数参数(仅适用于函数上下文):将传入的参数添加到变量对象中,并为其赋值。如果没有传入参数,则初始化为
undefined。
示例分析:
function foo(a) {
var b = 2;
function c() {}
var d = function () {};
}
foo(1);在调用 foo(1) 的创建阶段,其变量对象(活动对象)大致如下:
AO = {
arguments: { 0: 1, length: 1 }, // 参数
a: 1, // 参数赋值
b: undefined, // var 变量,初始化为 undefined
c: reference to function c() {}, // 函数声明,直接引用
d: undefined // var 变量,初始化为 undefined (函数表达式按变量处理)
}B. 建立作用域链
作用域链本质上是一个指向各个变量对象的指针列表,它保证了当前执行上下文对变量和函数的有序访问。
当代码在一个上下文中查找变量时(这个过程称为标识符解析),它会从作用域链的最前端(即当前上下文的变量对象)开始查找。 如果找不到,就会沿着作用域链向上查找,直到全局上下文。如果全局上下文中也没有,则会抛出 ReferenceError。
作用域链是在函数定义时就确定的,而不是在调用时。这就是词法作用域(静态作用域)的体现,也是闭包实现的基础。
C. 确定 this 的值
this的值在执行上下文创建阶段被确定。- 其指向取决于函数的调用方式:
- 全局上下文:
this指向全局对象(浏览器中为window)。 - 函数上下文:
- 直接调用:在非严格模式下指向
window,严格模式下为undefined。 - 作为对象方法调用:指向该对象。
- 使用
call,apply,bind:指向绑定的对象。 - 作为构造函数(
new):指向新创建的对象。
- 直接调用:在非严格模式下指向
- 全局上下文:
阶段二:执行阶段
在这个阶段,JavaScript 引擎开始逐行执行代码,并完成以下工作:
- 变量赋值:按照代码顺序,执行赋值操作。之前被初始化为
undefined的变量现在被赋予实际的值。 - 执行其他代码:函数调用、表达式计算等都在这个阶段进行。
继续上面的例子,在执行阶段:
// 创建阶段后的 AO
// AO = { a: 1, b: undefined, c: reference, d: undefined }
b = 2; // 将 b 赋值为 2
d = function () {}; // 将 d 赋值为函数表达式
// 执行阶段结束后的 AO
// AO = { a: 1, b: 2, c: reference, d: reference }执行栈(调用栈)
为了管理多个执行上下文,JavaScript 引擎使用了一个执行栈(也称为调用栈)。这是一个 LIFO(后进先出)的栈结构。
工作流程:
- 当 JavaScript 引擎开始执行脚本时,它首先创建全局执行上下文并将其压入栈底。
- 每当遇到一个函数调用,引擎就会为该函数创建一个新的函数执行上下文,并将其压入栈顶。
- 引擎总是执行位于栈顶的上下文。
- 当该函数执行完毕后,其执行上下文会从栈顶弹出,控制权交还给栈中的下一个上下文。
- 脚本结束时,全局执行上下文也会从栈中弹出。
执行示例
function level1() {
console.log("进入 level1");
var a = "level1的变量";
function level2() {
console.log("进入 level2");
var b = "level2的变量";
function level3() {
console.log("进入 level3");
var c = "level3的变量";
console.log("level3中访问:", a, b, c);
console.log("退出 level3");
}
level3();
console.log("回到 level2,b =", b);
console.log("退出 level2");
}
level2();
console.log("回到 level1,a =", a);
console.log("退出 level1");
}
console.log("=== 程序开始 ===");
level1();
console.log("=== 程序结束 ===");阶段一:初始化
| 步骤 | 执行栈状态 | 当前上下文 | 输出 | 说明 |
|---|---|---|---|---|
| 1 | [Global] | Global | "=== 程序开始 ===" | 全局上下文入栈 |
阶段二:level1 执行
| 步骤 | 执行栈状态 | 当前上下文 | 输出 | 说明 |
|---|---|---|---|---|
| 2 | [Global, level1] | level1 | "进入 level1" | level1入栈 |
| 3 | [Global, level1] | level1 | - | 变量 a 赋值为 "level1的变量" |
此时 level1 的变量对象:
VO_level1 = {
a: "level1的变量",
level2: reference to function level2() { ... }
}阶段三:level2 执行
| 步骤 | 执行栈状态 | 当前上下文 | 输出 | 说明 |
|---|---|---|---|---|
| 4 | [Global, level1, level2] | level2 | "进入 level2" | level2入栈 |
| 5 | [Global, level1, level2] | level2 | - | 变量 b 赋值为 "level2的变量" |
此时 level2 的作用域链和变量对象:
// level2的作用域链
Scope_Chain_level2 = [
VO_level2, // 当前变量对象
VO_level1, // 父级变量对象
Global // 全局变量对象
]
VO_level2 = {
b: "level2的变量",
level3: reference to function level3() { ... }
}阶段四:level3 执行(最深嵌套)
| 步骤 | 执行栈状态 | 当前上下文 | 输出 | 说明 |
|---|---|---|---|---|
| 6 | [Global, level1, level2, level3] | level3 | "进入 level3" | level3入栈,栈达到最深 |
| 7 | [Global, level1, level2, level3] | level3 | - | 变量 c 赋值为 "level3的变量" |
| 8 | [Global, level1, level2, level3] | level3 | "level3中访问: level1的变量 level2的变量 level3的变量" | 闭包体现:level3可以访问所有外层变量 |
| 9 | [Global, level1, level2, level3] | level3 | "退出 level3" | level3执行完毕 |
此时 level3 的作用域链和变量对象:
// level3的作用域链 - 体现了闭包的词法作用域
Scope_Chain_level3 = [
VO_level3, // 当前变量对象
VO_level2, // 父级变量对象
VO_level1, // 祖父级变量对象
Global // 全局变量对象
]
VO_level3 = {
c: "level3的变量"
}阶段五:逐层返回(栈弹出过程)
- level3 弹出
| 步骤 | 执行栈状态 | 当前上下文 | 输出 | 说明 |
|---|---|---|---|---|
| 10 | [Global, level1, level2] | level2 | "回到 level2,b = level2的变量" | level3弹出,控制权回到level2 |
| 11 | [Global, level1, level2] | level2 | "退出 level2" | level2执行完毕 |
- level2 弹出
| 步骤 | 执行栈状态 | 当前上下文 | 输出 | 说明 |
|---|---|---|---|---|
| 12 | [Global, level1] | level1 | "回到 level1,a = level1的变量" | level2弹出,控制权回到level1 |
| 13 | [Global, level1] | level1 | "退出 level1" | level1执行完毕 |
- level1 弹出
| 步骤 | 执行栈状态 | 当前上下文 | 输出 | 说明 |
|---|---|---|---|---|
| 14 | [Global] | Global | "=== 程序结束 ===" | level1弹出,控制权回到全局上下文 |
阶段六:程序结束
| 步骤 | 执行栈状态 | 当前上下文 | 输出 | 说明 |
|---|---|---|---|---|
| 15 | [] | - | - | 全局上下文弹出,栈空,程序结束 |
解释常见现象
理解了执行上下文,很多 JavaScript 的“怪异”行为就变得清晰了。
现象一:变量提升
console.log(a); // 输出:undefined
var a = 5;解释:在创建阶段,变量 a 已经被声明并初始化为 undefined。执行阶段,console.log 先执行,此时 a 的值就是 undefined,然后才执行 a = 5。
现象二:函数与变量的提升优先级
console.log(foo); // 输出:ƒ foo() { console.log('Function'); }
function foo() {
console.log("Function");
}
var foo = "Variable";解释:在创建阶段,先处理函数声明 foo,再处理变量声明 foo。由于函数声明优先级更高且变量声明不会覆盖已存在的同名函数,所以最初的 foo 指向函数。直到执行到 foo = 'Variable' 时,它才被重新赋值为字符串。
现象三:闭包
function outer() {
var outerVar = "I am outside!";
function inner() {
console.log(outerVar); // 成功访问 outerVar
}
return inner;
}
const myInner = outer();
myInner(); // 输出:"I am outside!"解释:当 outer 执行时,inner 函数被定义。此时,inner 的作用域链就已经确定了,它包含了 outer 的活动对象。即使 outer 已经执行完毕,其活动对象因为仍然被 inner 的作用域链引用着,所以不会被垃圾回收。这就是闭包——函数可以“记住”并访问其词法作用域,即使该函数在其作用域之外执行。
总结
JavaScript 中的执行上下文是代码被评估和执行时所在环境的抽象概念,它定义了变量、函数和 this 关键字的行为环境。根据运行环境的不同,执行上下文主要分为三种类型:全局上下文、函数上下文和 Eval 上下文。每个执行上下文分为两个关键阶段:首先是创建阶段,在这个阶段会创建变量对象(VO/AO)、建立作用域链并确定 this 的指向;接着是执行阶段,在此阶段才会真正逐行执行代码,完成变量的赋值和其他计算操作。所有的执行上下文都通过执行栈(调用栈) 这个后进先出的数据结构进行统一管理,确保程序的有序运行。正是这套完整的机制,从根本上解释了 JavaScript 中变量提升、作用域链、闭包等核心特性的运行原理,为我们理解 JavaScript 的底层工作机制提供了关键视角。