打开控制台,堆栈敲下
let a = 1,堆栈再敲let b = a,堆栈然后改a = 2。堆栈打印b,堆栈结果是堆栈 1。这背后就是堆栈栈在干活——原始类型的值直接存在栈里,复制一份就是堆栈真复制。换成对象试试。堆栈
let obj1 = { n: 1},堆栈let obj2 = obj1,堆栈改obj1.n = 2,堆栈obj2.n也跟着变成 2。堆栈变量里存的堆栈其实是个地址,指向堆里的堆栈同一个对象。栈里放的是引用,堆里放的才是实体。栈:快,但小
栈内存由操作系统直接管理,分配和回收就是移动一个指针,速度极快。函数调用时,每进一层就压一个栈帧,里面装着局部变量、参数、返回地址。函数执行完,栈帧弹出,内存自动释放。
但栈的空间有限。Chrome 里大概 1MB 出头。写个无限递归:
function f() { f() } f()控制台立刻甩你一个
RangeError: Maximum call stack size exceeded。栈溢出了。每一层调用都往栈上压东西,压到装不下为止。有个细节值得注意:栈是后进先出。函数 A 调用 B,B 调用 C,C 先执行完弹出,再轮到 B,最后才是 A。这个顺序决定了闭包能捕获外层变量——因为外层栈帧还没弹出去。
堆:大,但慢
对象、数组、函数这些大小不固定的东西都扔在堆里。堆内存大得多,但分配和回收需要垃圾回收器介入,速度比不上栈。V8 把堆分成新生代和老生代,新生代用 Scavenge 算法,老生代用标记-清除和标记-整理。这些机制直接影响页面性能——频繁创建大对象,GC 就会频繁跑,主线程卡顿。
一个实际例子:在循环里拼接字符串,早期 JS 引擎会不断在堆上分配新字符串,GC 压力巨大。后来引擎做了优化,但思路没变——堆操作的成本远高于栈操作。
闭包:栈帧的“幽灵”
正常来说函数执行完,栈帧就该销毁。但闭包让这事情变复杂了:
function outer() { let count = 0 return function() { count++ return count } } const fn = outer() fn() // 1 fn() // 2outer已经执行完了,count却还活着。因为返回的函数持有对count的引用,V8 把本该在栈上的变量挪到了堆里,或者做了“上下文提升”。栈帧没了,变量在堆上继续存活。这也是闭包可能导致内存泄漏的原因——你以为函数结束了,其实引用链还在。深浅拷贝的坑,根源也在堆栈
let arr1 = [1, 2, { x: 3}],let arr2 = [...arr1]。改arr2[2].x = 4,arr1[2].x也变成 4。展开运算符只复制了栈上的引用,堆里的嵌套对象还是同一个。要彻底断开,得用structuredClone或者手写递归,把堆里的东西也复制一份。我见过一个 bug:组件里用
slice()复制了一份数组传给子组件,子组件改了数组里对象的属性,父组件的状态跟着变了。查了半天才发现是浅拷贝。这种问题在 React 里尤其常见,因为大家都以为slice是“安全”的。写代码时怎么用这个知识
原始类型随便复制,不心疼。对象和数组,心里要清楚你拿到的是引用还是副本。函数参数传对象,函数内部改了,外面也会变——除非你明确要这个效果,否则就是个隐患。
递归太深就改成迭代,用循环加显式栈来模拟,或者用
setTimeout拆成异步任务,让调用栈有机会清空。处理大数组时,for循环比map/filter链式调用更省内存,因为后者每一步都创建新数组,堆上多出好几份临时数据。栈和堆不是抽象概念,它们决定了变量怎么存、函数怎么跑、内存什么时候回收。理解这些,调试内存泄漏和性能问题时,方向会清楚很多。
顶: 837踩: 12251






评论专区