如果你学 JavaScript 学到一定程度,迟早会遇到这些问题:
- 为什么基本数据类型和引用数据类型的表现不一样?
- 为什么函数执行完了,里面的变量好像还“活着”?
- 闭包到底是怎么形成的?
- 为什么说理解内存机制,能让你真正看懂 JS?
很多人学 JS 会先背概念:
“基本类型存在栈里,引用类型存在堆里,闭包可以访问外部变量……”
但如果只停留在背诵层面,遇到复杂代码还是会懵。 要学会理解底层,这样才不会遇到面试和代码时无从下手。
所以这篇文章我们不只讲结论,还会从JavaScript 的执行机制和内存机制出发,去理解为什么会这样。
一、先搞清楚:JavaScript 到底是怎么执行代码的?
在理解内存之前,先简单看一下 JS 的执行方式。
JavaScript 代码执行时,核心离不开两个东西:
- 调用栈(Call Stack)
- 执行上下文(Execution Context)
可以把它理解成:
JS 每执行一段代码,都会先准备好一个“工作环境”,然后放到调用栈里,按顺序执行。
1. 调用栈:JS 执行的主角
调用栈就像一个叠起来的盘子:
- 新函数执行,就压栈
- 函数执行完,就出栈
例子
function foo() {
bar();
}
function bar() {
console.log('bar');
}
foo();
执行过程大致是:
- 全局代码进入调用栈
- 执行
foo() foo()入栈foo()内部执行bar()bar()入栈bar()执行完,出栈foo()执行完,出栈- 全局执行完毕
你可以把它理解为:
调用栈负责记录“谁在执行、执行到哪了”。
二、执行上下文:函数运行时的“工作场所”
每次函数执行,都会创建一个执行上下文。
它里面主要包含这些内容:
- 变量环境
- 词法环境
- 外部词法作用域链(outer)
- this
虽然这些名词听起来有点抽象,但它们本质上都在回答一个问题:
函数执行时,能访问哪些变量?当前 this 指向谁?
1. 变量环境
变量环境主要存放 var 声明的变量和函数声明。
例子
function demo() {
console.log(a);
var a = 10;
}
demo();
在代码真正执行前,JS 会先进行“预扫描”,把 var a 提前声明出来。
所以这段代码实际上更像这样:
function demo() {
var a;
console.log(a); // undefined
a = 10;
}
这也是为什么 var 会出现“变量提升”的现象。
2. 词法环境
词法环境更关注“代码写在哪里”,也就是变量的作用域是如何嵌套的。
它会配合外部词法作用域链一起工作,决定一个变量到底去哪找。
例子
function outer() {
let name = 'outer';
function inner() {
console.log(name);
}
inner();
}
outer();
inner() 中没有 name,会去外层作用域找,于是找到 outer() 里的 name。
这就是作用域链的本质:
当前作用域找不到,就向外层一层层找。
3. this
this 也是执行上下文的重要部分,它指向谁,取决于函数怎么调用。
例子
const obj = {
name: 'Jack',
say() {
console.log(this.name);
}
};
obj.say(); // Jack
这里 say() 是通过 obj.say() 调用的,所以 this 指向 obj。
如果单独拿出来调用:
const fn = obj.say;
fn(); // 可能是 undefined 或 window.name,取决于环境
this 的指向会发生变化。
三、JavaScript 的内存空间:栈和堆
文档里提到,JavaScript 的内存空间主要可以理解为:
- 代码空间
- 栈内存
- 堆内存
其中我们最常接触的是 栈内存 和 堆内存。
1. 栈内存:快、连续、适合小而固定的数据
栈内存主要用来存放:
- 简单数据类型
- 函数执行时的上下文信息
它的特点是:
- 分配快
- 回收快
- 空间相对固定
- 数据连续
例子
let a = 1;
let b = true;
let c = 'hello';
这些简单数据类型,通常会直接存在栈内存中。
你可以把栈理解为:
速度快、管理简单,但空间有限。
2. 堆内存:大、灵活、适合复杂对象
堆内存用来存放复杂数据类型,比如:
- 对象
- 数组
- 函数
- 日期对象等
堆的特点是:
- 空间大
- 适合动态分配
- 不要求连续
- 分配和回收成本更高
例子
let obj = {
name: 'Tom',
age: 18
};
这个对象本体会被存放在堆内存中,而变量 obj 本身通常会在栈里保存一个“引用地址”。
换句话说:
- 栈里存的是“地址”
- 堆里存的是“真正的数据”
四、为什么 JavaScript 要把数据分成栈和堆?
这是个很重要的问题。
如果所有数据都放在栈里,会发生什么?
问题 1:栈空间有限
复杂对象可能很大,如果也放在栈里,会让栈变得非常庞大。
而栈本来是为了快速执行上下文切换的,过大的栈会影响效率。
问题 2:对象大小不固定
对象、数组的内容是动态变化的,不像数字、布尔值那么简单。
栈更适合存放小而确定的数据,堆更适合存放动态变化的数据。
例子
let arr = [1, 2, 3];
arr.push(4, 5, 6);
如果数组完全放在栈里,随着内容变化,管理会非常麻烦。
放在堆里更灵活,也更符合它的特点。
五、简单数据类型和复杂数据类型到底有什么区别?
这是 JS 的基础重点,也是很多人经常搞混的地方。
1. 简单数据类型:直接存值
简单数据类型包括:
numberstringbooleannullundefinedsymbolbigint
它们通常是直接存储值。
例子
let a = 10;
let b = a;
b = 20;
console.log(a); // 10
console.log(b); // 20
因为 b = a 时,复制的是值本身,所以 b 改变不会影响 a。
2. 复杂数据类型:存引用
复杂数据类型包括:
objectarrayfunction
它们更像是“指向堆内存的地址”。
例子
let obj1 = {
name: 'Lucy'
};
let obj2 = obj1;
obj2.name = 'Lily';
console.log(obj1.name); // Lily
console.log(obj2.name); // Lily
这里 obj2 = obj1 复制的不是对象本体,而是同一个引用地址。
所以改 obj2,obj1 也会受到影响。
六、Object 类型:为什么 key 是字符串或 Symbol?
文档里提到:
Object 类型 key:value,key string | symbol
这是因为对象的属性键,本质上是字符串或 Symbol。
例子
const obj = {
1: 'one',
true: 'yes'
};
console.log(obj['1']); // one
console.log(obj['true']); // yes
你会发现数字 1 和布尔值 true 作为 key,最终都会被转换成字符串。
这也是为什么对象属性访问时,通常写成:
obj['name']
而不是依赖“其他类型的 key”。
七、闭包:从内存机制角度理解,才真正不容易忘
接下来是重点:闭包。
很多人对闭包的理解停留在一句话:
函数可以访问并记住它定义时的词法作用域。
这句话没错,但太抽象了。
我们从内存角度来理解,会更清晰。
1. 闭包到底是什么?
闭包本质上是:
内部函数引用了外部函数中的变量,而这些变量在外部函数执行完后依然没有被销毁。
也就是说,内部函数“把外部变量带走了”。
2. 为什么会形成闭包?
第一步:扫描内部函数是否引用了外部变量
JS 在编译阶段会检查内部函数是否使用了外部函数的变量。
第二步:把这些变量保存到堆中
如果内部函数引用了外部变量,那么这些变量就会被保存在一个特殊的堆空间里,形成一个 closure。
这样,即使外部函数执行结束,这些变量也不会立刻被回收。
因为内部函数还在引用它们。
3. 例子:最经典的闭包
function foo() {
let myName = 'Tom';
function getName() {
console.log(myName);
}
return getName;
}
const fn = foo();
fn(); // Tom
这个例子发生了什么?
foo()执行myName被定义在foo()的作用域里getName()引用了myNamefoo()执行结束后,按理说它的局部变量应该被释放- 但因为
getName()还在引用myName - 所以
myName被保留下来,形成闭包 - 最后执行
fn()时,仍然可以访问到myName
4. 再看一个修改变量的例子
function createCounter() {
let count = 0;
return function () {
count++;
console.log(count);
};
}
const counter = createCounter();
counter(); // 1
counter(); // 2
counter(); // 3
这里 count 没有随着 createCounter() 执行完毕而消失。
因为返回的内部函数一直在引用它。
这就是闭包最实用的地方:
让变量保持私有,同时又能被内部函数持续访问。
八、闭包为什么常常被说成“变量不会被释放”?
更准确地说,不是变量“永远不会被释放”,而是:
只要还有函数引用它,它就不会被回收。
例子
function test() {
let data = 'important';
return function () {
console.log(data);
};
}
let fn = test();
fn(); // important
fn = null;
当 fn = null 后,内部函数的引用被断开了。
如果没有其他地方引用闭包中的变量,那么这些内存最终会被垃圾回收机制回收。
所以闭包并不是“内存泄漏”的代名词。
真正的问题是:如果你不再使用闭包,但又一直持有引用,就可能造成内存占用增加。
九、闭包的优点和风险
1. 优点:可以封装私有变量
例子
function createUser() {
let password = '123456';
return {
getPassword() {
return password;
}
};
}
const user = createUser();
console.log(user.getPassword()); // 123456
password 被封装在函数内部,外部无法直接访问,保护了数据安全。
2. 优点:可以保存状态
例子
function add() {
let num = 0;
return function () {
num += 1;
return num;
};
}
const count = add();
console.log(count()); // 1
console.log(count()); // 2
闭包使得函数可以“记住上一次的状态”。
3. 风险:容易造成内存占用
如果闭包里保存了大量数据,而这些数据长期不释放,就可能让内存占用变高。
例子
function bigData() {
let arr = new Array(1000000).fill(1);
return function () {
console.log(arr.length);
};
}
如果 bigData() 的返回值一直被引用,arr 也会一直留在内存中。
所以使用闭包时要注意:
- 不需要时及时解除引用
- 避免把大对象长期留在闭包中
十、为什么说“理解内存机制,才能真正理解闭包”?
因为闭包不是一个“语法特性”,它更像是 JS 作用域和内存管理配合后的结果。
你可以这样记:
- 作用域链决定“能不能访问”
- 内存引用决定“能不能活着”
- 闭包就是“内部函数访问外部变量,并让变量存活下来”
简单总结例子
function outer() {
let name = 'JS';
return function inner() {
console.log(name);
};
}
inner()可以通过作用域链找到nameinner()被返回后仍然持有对name的引用- 所以
name不会马上被回收 - 这就是闭包
十一、常见误区:闭包不是“函数套函数”这么简单
很多初学者会误以为:
只要函数里面有函数,就是闭包。
这其实不准确。
不是所有嵌套函数都是闭包
例子
function outer() {
function inner() {
console.log('hello');
}
inner();
}
outer();
这里虽然函数嵌套了,但 inner() 没有引用外部变量,也没有在外部被返回或持久保存。
所以严格来说,这并不是我们通常所说的“典型闭包”。
真正的闭包,关键在“引用了外部变量,并且这个引用被保留了”
十二、总结一下
想真正理解 JavaScript 的内存机制,我总结出几个核心点:
1. JS 的执行核心是调用栈
函数执行时入栈,执行完出栈。
2. 执行上下文决定变量和 this 的访问方式
它包含变量环境、词法环境、outer 作用域链和 this。
3. 栈内存适合简单数据类型
特点是快、连续、好管理。
4. 堆内存适合复杂数据类型
特点是空间大、灵活,但分配和回收更耗时。
5. 闭包的本质是“外部变量被内部函数引用并保留下来”
这也是为什么函数执行完后,某些变量依然能被访问。
结语
JavaScript 看起来“灵活”,其实背后有一套非常清晰的执行和内存逻辑。
理解了栈、堆、执行上下文、作用域链,再去看闭包,你会发现它并不是神秘的黑魔法,而是 JS 语言设计的自然结果。
真正的高手,不能是背会了闭包,而是能从内存角度解释闭包。 不然可面不了大厂