变量,作用域与内存
约 4703 字大约 16 分钟
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 中定义变量、函数和类在何处可被访问的一套规则。它决定了代码中某个标识符(如变量名)是否可见、能否被引用。
全局作用
- 在所有函数和块之外声明的变量属于全局作用域。
- 在整个程序中都可访问。
- 浏览器中:
var声明的全局变量会成为window的属性;let/const不会。
var globalVar = "I am global"; // 全局作用域
let globalLet = "Also global";
function foo() {
console.log(globalVar); // ✅ 可访问
}函数作用域
- 由
function创建。 - 仅
var和function声明受函数作用域限制。 - 在函数内任何位置声明的
var,在整个函数内都可见(变量提升)。
function example() {
if (true) {
var a = 1; // 函数作用域:整个函数内可用
}
console.log(a); // 1
}块级作用域
- 由
{}创建(如if、for、while、独立代码块)。 let、const、class、函数声明(在严格模式或现代环境中)属于块级作用域。var不受块级作用域影响。
{
let blockScoped = "only here";
var notBlockScoped = "visible outside";
}
console.log(blockScoped); // error: ReferenceError
console.log(notBlockScoped); // 'visible outside'词法作用域
JavaScript 采用 词法作用域(也称静态作用域),即: 作用域由代码的书写位置(嵌套结构)决定,而不是由函数调用位置决定。
const x = "global";
function foo() {
console.log(x); // 始终输出 'global'
}
function bar() {
const x = "local";
foo(); // 仍输出 'global',不是 'local'!
}
bar();foo 在定义时处于全局作用域,所以它的作用域链是 [foo] → [global],即使 foo 在 bar 内部调用,也无法访问 bar 的 x。
作用域链
作用域链 是由多个变量对象(Variable Object)组成的一个链式结构,它决定了代码在访问变量时的查找顺序和规则。
简单来说,作用域链就是 JavaScript 引擎查找变量的路线图。
创建时机
作用域链在函数定义时创建,而不是在函数调用时。
var globalVar = "全局变量";
function outer() {
var outerVar = "外部变量";
function inner() {
var innerVar = "内部变量";
console.log(innerVar); // 在当前作用域找到
console.log(outerVar); // 在父作用域找到
console.log(globalVar); // 在全局作用域找到
}
return inner;
}
// inner 函数的作用域链在定义时就已经确定了
const innerFunc = outer();inner 函数的作用域链在它被定义的时候就已经确定了,与它在哪里、何时被调用无关。
作用域链的组成
作用域链由多个变量对象按特定顺序组成:
[ 当前执行上下文的变量对象 ]
→ [ 外层函数的变量对象 ]
→ [ 更外层函数的变量对象 ]
→ ...
→ [ 全局变量对象 ]查找规则
- 从当前作用域开始:在当前变量对象中查找
- 逐级向外查找:如果当前作用域找不到,就沿着作用域链向上查找
- 直到全局作用域:如果全局作用域也找不到,抛出
ReferenceError
var x = "全局 x";
function test() {
var y = "局部 y";
function inner() {
var z = "内部 z";
console.log(z); // 1. 当前作用域找到: "内部 z"
console.log(y); // 2. 父作用域找到: "局部 y"
console.log(x); // 3. 全局作用域找到: "全局 x"
console.log(w); // 4. 所有作用域都找不到: ReferenceError
}
inner();
}
test();作用域链与闭包
闭包的本质就是函数保持对其定义时作用域链的引用。
function createCounter() {
let count = 0; // count 在 createCounter 的作用域中
return function() {
count++; // 内部函数访问外部函数的变量
return count;
};
}
const counter = createCounter();
// createCounter 已经执行完毕,但...
console.log(counter()); // 1
console.log(counter()); // 2
console.log(counter()); // 3为什么 count 没有被销毁?
counter 函数的作用域链:
[ counter的变量对象 ] → [ createCounter的变量对象 (count=3) ] → [ 全局变量对象 ]即使 createCounter 执行完毕,它的变量对象仍然被 counter 函数的作用域链引用着,所以不会被垃圾回收。
作用域链的修改
通常情况下作用域链是静态的,但有两个特例可以动态修改:
with 语句(已废弃)
var obj = { a: 1, b: 2 };
with (obj) {
console.log(a); // 1 - 先在 obj 中查找
console.log(b); // 2 - 先在 obj 中查找
var c = 3; // 不会添加到 obj,而是添加到外部作用域
}
console.log(obj.c); // undefined
console.log(c); // 3catch 语句块
try {
throw new Error("测试错误");
} catch (error) {
// error 变量只在这个 catch 块中有效
console.log(error.message); // "测试错误"
}
// console.log(error); // ReferenceError: error is not defined循环中的闭包问题
function createFunctions() {
var result = [];
for (var i = 0; i < 3; i++) {
result.push(function() {
return i; // 所有函数共享同一个 i
});
}
return result;
}
var funcs = createFunctions();
console.log(funcs[0]()); // 3 (不是 0!)
console.log(funcs[1]()); // 3 (不是 1!)
console.log(funcs[2]()); // 3 (不是 2!)问题原因:所有内部函数的作用域链都指向同一个 createFunctions 的变量对象,其中的 i 在循环结束后已经是 3。
解决方案:
function createFunctionsFixed() {
var result = [];
for (var i = 0; i < 3; i++) {
// 使用 IIFE 创建新的作用域
(function(j) {
result.push(function() {
return j; // 每个函数有自己的 j
});
})(i);
}
return result;
}
var fixedFuncs = createFunctionsFixed();
console.log(fixedFuncs[0]()); // 0
console.log(fixedFuncs[1]()); // 1
console.log(fixedFuncs[2]()); // 2执行上下文
执行上下文 是 JavaScript 代码被评估和执行时所在环境的抽象概念。任何代码在 JavaScript 引擎中运行时,都在一个执行上下文中进行。 变量或函数的上下文决定了它们可以访问哪些数据,以及它们的行为。每个上下文都有一个关联的变量对象(variable object),而这个上下文中定义的所有变量和函数都存在于这个对象上。
类型
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);
var a = 5;
// 输出:undefined解释:在创建阶段,变量 a 已经被声明并初始化为 undefined。执行阶段,console.log 先执行,此时 a 的值就是 undefined,然后才执行 a = 5。
现象二:函数与变量的提升优先级
console.log(foo);
function foo() {
console.log("Function");
}
var foo = "Variable";
console.log(foo);
// 输出:ƒ foo() { console.log('Function'); }
// Variable解释:在创建阶段,先处理函数声明 foo,在变量对象中创建 foo,指向函数。然后处理变量声明,但是发现 foo 已经存在所有跳过。 执行阶段:首先输出函数,然后赋值操作覆盖 foo 为支付才,输出 foo。
现象三:闭包
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 的底层工作机制提供了关键视角。
TODO: 垃圾回收