闭包用多了会造成内存泄露 ?

10,496 阅读6分钟

闭包,是JS中的一大难点;网上有很多关于闭包会造成内存泄露的描述,说闭包会使其中的变量的值始终保持在内存中,一般都不太推荐使用闭包

而项目中确实有很多使用闭包的场景,比如函数的节流与防抖

那么闭包用多了,会造成内存泄露吗?

场景思考

以下案例: A 页面引入了一个 debounce 防抖函数,跳转到 B 页面后,该防抖函数中闭包所占的内存会被 gc 回收吗?

该案例中,通过变异版的防抖函数来演示闭包的内存回收,此函数中引用了一个内存很大的对象 info(42M的内存),便于明显地对比内存的前后变化

注:可以使用 Chrome 的 Memory 工具查看页面的内存大小:

Memory.jpg

场景步骤:

1) util.js 中定义了 debounce 防抖函数

// util.js`
export const debounce = (fn, time) => {
  let info = {
     arr: new Array(10 * 1024 * 1024).fill(1),
     timer: null
  };
  return function (...args) {
    info.timer && clearTimeout(info.timer);
    info.timer = setTimeout(() => {
      fn.apply(this, args);
    }, time);
  };
};

2) A 页面中引入并使用该防抖函数

import { debounce } from './util';
mounted() {
    this.debounceFn = debounce(() => {
      console.log('1');
    }, 1000)
}
  • 抓取 A 页面内存: 57.1M
pageA.jpg

3) 从 A 页面跳转到 B 页面,B 页面中没有引入该 debounce 函数

问题: 从 A 跳转到 B 后,该函数所占的内存会被释放掉吗?

  • 此时,抓取 B 页面内存: 15.3M
pageB.png

前后对比发现,从 A 跳转到 B 后,该函数所占的内存会被释放掉,证明闭包没有造成内存泄露

事实证明:“闭包会使其中的变量的值始终保持在内存中” 这句话是错误的,闭包所占的内存依然会被 gc 回收

下面一起来研究下闭包的内存回收机制

闭包简介

闭包:一个函数内部有外部变量的引用,比如函数嵌套函数时,内层函数引用了外层函数作用域下的变量,就形成了闭包。最常见的场景为:函数作为一个函数的参数,或函数作为一个函数的返回值时

闭包示例:

function fn() {
  let num = 1;
  return function f1() {
    console.log(num);
  };
}
let a = fn();
a();

上面代码中,a 引用了 fn 函数返回的 f1 函数,f1 函数中引入了内部变量 num,导致变量 num 滞留在内存中

打断点调试一下

scope.jpg

展开函数 f 的 Scope(作用域的意思)选项,会发现有 Local 局部作用域、Closure 闭包、Global 全局作用域等值,展开 Closure,会发现该闭包被访问的变量是 num,包含 num 的函数为 fn

总结来说,函数 f 的作用域中,访问到了fn 函数中的 num 这个局部变量,从而形成了闭包

所以,如果真正理解好闭包,需要先了解闭包的内存引用,并且要先搞明白这几个知识点:

  • 函数作用域链
  • 执行上下文
  • 变量对象、活动对象

函数的内存表示

先从最简单的代码入手,看下变量是如何在内存中定义的

let a = '小马哥'

这样一段代码,在内存里表示如下

a.png

在全局环境 window 下,定义了一个变量 a,并给 a 赋值了一个字符串,箭头表示引用

再定义一个函数

let a = '小马哥'
function fn() {
  let num = 1
}

内存结构如下:

fn.png

特别注意的是,fn 函数中有一个 [[scopes]] 属性,表示该函数的作用域链,该函数作用域指向全局作用域(浏览器环境就是 window),函数的作用域是理解闭包的关键点之一

请谨记:函数的作用域链是在创建时就确定了,JS 引擎会创建函数时,在该对象上添加一个名叫作用域链的属性,该属性包含着当前函数的作用域以及父作用域,一直到全局作用域

函数在执行时,JS 引擎会创建执行上下文,该执行上下文会包含函数的作用域链(上图中红色的线),其次包含函数内部定义的变量、参数等。在执行时,会首先查找当前作用域下的变量,如果找不到,就会沿着作用域链中查找,一直到全局作用域

垃圾回收机制浅析

现在各大浏览器通常用采用的垃圾回收有两种方法:标记清除、引用计数

这里重点介绍 "引用计数"(reference counting),JS 引擎有一张"引用表",保存了内存里面所有的资源(通常是各种值)的引用次数。如果一个值的引用次数是0,就表示这个值不再用到了,因此可以将这块内存释放

link.png

上图中,左下角的两个值,没有任何引用,所以可以释放

如果一个值不再需要了,引用数却不为0,垃圾回收机制无法释放这块内存,从而导致内存泄漏

判断一个对象是否会被垃圾回收的标准: 从全局对象 window 开始,顺着引用表能找到的都不是内存垃圾,不会被回收掉。只有那些找不到的对象才是内存垃圾,才会在适当的时机被 gc 回收

闭包的内存回收

对上述案例中的代码进行断点调试

normal.png

其内存结构如下:

infoInner.png

当从 A 页面切换到 B 页面时,A 页面被销毁,debounce 函数的引用会被释放掉,此时会销毁该函数的作用域,从 window 对象上沿着引用表查找不到 info 对象,info 对象会被 gc 回收

innerDel.png

结论

综上所述,项目中大量使用闭包,并不会造成内存泄漏,除非是错误的写法

绝大多数情况,只要引用闭包的函数被正常销毁,闭包所占的内存都会被 gc 自动回收

特别是现在 SPA 项目的盛行,用户在切换页面时,老页面的组件会被框架自动清理,所以我们可以放心大胆的使用闭包,无需多虑

场景补充

如果把 info 变量放到 debounce 函数外部,从 A 页面跳转到 B 页面后,该 info 变量所占的内存会被释放掉吗?

// util.js`
let info = {
  arr: new Array(10 * 1024 * 1024).fill(1),
  timer: null
};
export const debounce = (fn, time) => {
  return function (...args) {
    info.timer && clearTimeout(info.timer);
    info.timer = setTimeout(() => {
      fn.apply(this, args);
    }, time);
  };
};
  • 此时,抓取 B 页面内存: 58.1M
pageB.jpg

前后对比发现,B 页面原始内存为 15.3M,现在为 58.1M,内存增大约了 42M,说明 info 变量所占的内存没有被释放掉

为什么会这样呢?

对该代码进行断点调试

infoClosure.png

内存结构如下:

infoOut.png

当从 A 页面切换到 B 页面时,虽然 debounce 函数被销毁,但 info 是 util.js 该模块的局部变量(如同全局变量),所以并没有被销毁 ,只要不刷新页面,util.js 模块就不会被销毁

之前我把这种情况当成了闭包所造成的内存泄露,其实是局部变量导致的,感谢评论区小伙伴的指正😘

以上关于闭包内存回收的理解,如果有误,欢迎指正 🌹

参考链接:
浏览器是怎么看闭包的。
JavaScript 内存泄漏教程
JavaScript闭包(内存泄漏、溢出以及内存回收),超直白解析

文章系列

文章系列地址:github.com/xy-sea/blog

文中如有错误或不严谨的地方,请给予指正,十分感谢。如果喜欢或有所启发,欢迎 star