这道闭包题,你不会

副标题:内存管理 —— 闭包导致的内存泄漏

背景

闭包是 JavaScript 中最重要的特性之一,但也极其容易出现内存泄漏的情况。就这个情况,我打算,通过一个例子来说明闭包的复杂性和隐匿性(不容易被发现),来加深对闭包的理解,从而尽早地发现内存泄漏。

例子

  • 首先下面的代码只是例子,无需纠结为什么要这样写,为什么要那样写。
  • 其次这是最精简的代码,实际项目中会有各种干扰,难度系数更高。

挑战两个问题:

  1. 页面打开后,点击很多次,是否会内存泄漏?
  2. 如果有内存泄漏,那么是哪部分代码导致的?
class TestData {
    static count = 0;
    constructor() {
        this.dataList = new Array(5000).fill(0).map(() => Math.random().toString());
        this.count = TestData.count;
    }
}

let globalData = null;
document.onclick = function handleClick() {
    const originGlobalData = globalData;
    function unused() {
        if (!originGlobalData) return;
    
        // 做一些事情......
    }
  
    TestData.count += 1; // 点击次数
    globalData = {
        data: new TestData(),
        someMethod: function() {
            // 做一些事情
            // 但是没有使用到 originThing 常量
        }
    }
}

上面的问题,如果都答对了,那么请离开,不要浪费时间看此文章;如果不确定,那么说明对闭包的认识可能还需要加强:

点击看答案 答案:1)有内存泄漏;2)下面代码共同导致的内存泄漏:
    const originGlobalData = globalData;
    function unused() {
        if (!originGlobalData) return;

        // 做一些事情......
    }

    someMethod: function() {
        // 做一些事情
        // 但是没有使用到 originThing 常量
    }

接下来,我们将:

  1. 先了解一波内存泄漏排查的方法
  2. 再讲解上面的代码为什么会导致内存泄漏/为什么不会内存泄漏,怎样做才会内存泄漏。

排查方法

queryObjects

作用:传递构造函数,返回其构造函数生成的实例对象,未被 GC 回收掉的实例列表。此 API,仅在 DevTools 控制台上下文中可使用。

class TestData {
    constructor() {
      this.name = 'GWJ';
      this.dataList = new Array(600).fill(90);
    }
}

let mockData = new TestData();


// 在控制台中运行
queryObjects(TestData);

image.png

说明 TestData 这个构造函数,有 1 个实例对象没有被 GC 回收。

此时要将控制台的打印全部清空[^注释1],然后在控制台输入 mockData = null; 此时 TestData 唯一的实例对象被 GC 回收。再 queryObjects(TestData); 一遍试试:

DevTools 性能监视器工具

以如下代码为例子,打开 DevTools 中的性能监视器 选项卡,来观察 JS 生成的内存大小:

class TestData {
  constructor() {
    this.name = Math.random().toString();
    this.dataList = new Array(5000).fill(0).map(() => Math.random().toString());
  }
}

let globalDataList = [];

document.onclick = () => {
  const mockData = new TestData();
  globalDataList.push(mockData);
};

多次点击,可以看到 JS 堆内存呈明显的上升趋势,而且不会下降;稍后下降时是对 globalDataList 进行了释放。

image.png

DevTools 性能工具

此工具,更为细致、也更为强大,本文不做重点讲解,后期会进行讲解。此工具可以查看 JS 堆内存暴增的时间段,然后查看这段时间段内,渲染器主线程的都干了什么,从而发现是那些函数导致的,最后再进入此函数内部断点调试。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-0MIfmRAg-1678199626744)(image/image_fQ7R8ierad.png)]

console 是个坑

当一个引用对象以非字符串的方式,输出到控制台,那么就意味着控制台会引用着该对象,直到控制台清空或者控制台关闭为止,GC才会回收这部分内存。下面来举一些例子:

  1. console.log、console.error、console.dir … console 对象下的大部分打印方法都有这个现象。比如:

    class TestData {
        constructor() {
          this.name = 'GWJ';
          this.dataList = new Array(600).fill(90);
        }
    }
    
    let mockData = new TestData();
    console.log(mockData);
    mockData = null;
    
    
    // mockData 输出到了控制台,先不要清空控制台的输出
    // queryObjects(TestData) 返回能实例对象。 
    
  2. 上面的 queryObjects() 也有类似的现象,因为将实例对象直接输出到了控制台,控制台会对该对象保持引用。比如:

    class TestData {
        constructor() {
          this.name = 'GWJ';
          this.dataList = new Array(600).fill(90);
        }
    }
    
    let mockData = new TestData();
    
    
    // 在控制台中运行
    queryObjects(TestData);
    
    // 第一步:queryObjects 将会输出 TestData 到控制台
    // 第二步:不清空控制台,执行代码:mockData = null;
    // 第三步:再执行 queryObjects(TestData);
    // 仍然能打印出 TestData 的实例对象,并没有被 GC 回收。
    
  3. 只有对象以字符串序列化的方式输出到控制台,控制台才不会保持引用。比如:

    class TestData {
        constructor() {
          this.name = 'GWJ';
          this.dataList = new Array(600).fill(90);
        }
    }
    
    let mockData = new TestData();
    console.log(JSON.stringify(mockData));
    mockData = null;
    
    
    // mockData 输出到了控制台,但是是字符串格式的,也不用清空控制台。
    // queryObjects(TestData)  返回空
    
    

讲解`例子`

先说结论:下面代码,点击 N 多次,会造成很严重的内存泄漏。

class TestData {
    static count = 0;
    constructor() {
        this.dataList = new Array(5000).fill(0).map(() => Math.random().toString());
        this.count = TestData.count;
    }
}

let globalData = null;
document.onclick = function handleClick() {
    const originGlobalData = globalData;
    function unused() {
        if (!originGlobalData) return;
    
        // 做一些事情......
    }
  
    TestData.count += 1; // 点击次数
    globalData = {
        data: new TestData(),
        someMethod: function() {
            // 做一些事情
            // 但是没有使用到 originThing 常量
        }
    }
}

一起捋一下

表面上看,触发 N 次页面 click 事件,虽然多次对globalData 全局变量赋值,但最后只有一个被保留。实际上,复杂就复杂在这里,比如我第二次点击后,那么会覆盖第一次的 globalData 数据,… 以此类推,最后一次的 globalData 覆盖上一次的 globalData,那按照一般思路,上一次的 globalData 应该会被 GC 回收掉,但实际情况是上一次的 globalData 仍然不会被GC 回收,这就是内存泄漏。

那么我们就动手看看,到底是什么导致的内存泄漏:
应用刚才讲过的排查方法来找出,是否有内存泄漏。

  1. 打开「性能监视器工具」。发现点击页面的过程中,JS堆内存一直在增长,说明有新分配的内存。但是一直是增长,说明上一次的事件处理函数中,分配的内存没有被GC回收,疑点出现了 !

40 秒内,内存大小没有下降

  1. 定位到了 handleClick 函数中,而且也知道是 new TestData() 的实例没有被销毁,那么我们通过 queryObjects 看看,到底有没有被 GC 回收。

结果非常的出乎意料!我不禁再次自言自语起来:“上一次产生的实例,明明被本次产生的实例覆盖,为什么还没被 GC 回收?”

先抑制一下心中的好奇(说给我自己听的,哈哈哈)后面再详细的说明。把代码简化一下,简化成:handleClick 事件处理函数中,对一个全局变量赋值;除此之外,不包含其他干扰代码。

不包含干扰的代码:于是我又点击了 N 次。「性能监视器」增长一段,然后又下降;「queryObjects(TestData)」只有一个实例对象。

class TestData {
    static count = 0;
    constructor() {
        this.dataList = new Array(5000).fill(0).map(() => Math.random().toString());
        this.count = TestData.count;
    }
}

let globalData = null;
document.onclick = function handleClick() {  
    TestData.count += 1; // 点击次数
    globalData = {
        data: new TestData(),
    }
}

下降点,是进行了强制垃圾回收的操作

原来是你

我们似乎是知道了哪些代码导致了上一次生成的实例无法被 GC 回收。下图红框所选的代码,就可能导致内存泄漏。

闭包到底是什么?

在 JavaScript 中,闭包指的是一个函数与其它变量之间的组合。具体来说,一个闭包就是一个函数以及其创建时所能访问到的所有变量的集合。闭包使得函数可以“记住”它被创建时的环境,即使在函数在其被定义的作用域之外执行时,这些变量仍然可以被访问。
想了解更多,请查看`参考`部分

以下仅仅是我个人的理解:闭包(Closure)是特殊的对象,用来存储子代函数访问父函数的变量。这样说可能有些抽象。我将分几个例子,循序渐进地深入理解闭包:

简单

function father() {
  const data = { name: 'GWJ' };
  const uname = 'GWJ';

  return function son() {
    console.log('data: ', data);
  };
}

const resFn = father();

在控制台,打印一下 resFn 函数,可以看到,resFn 函数对象有个特殊的 [[Scopes]] 属性:

[[Scopes]] 此属性只有在控制台可以看到,代码中是无法访问到的。[[Scopes]] 是函数的作用域链列表,在函数声明时被创建。

接下来,结合下图我们详细看看[[Scopes]]属性,都包含了什么?我们发现,该属性值是一个数组;数组的第一个元素是一个 名为闭包(Closure)的对象,对象内部拥有 father 函数执行时创建的 data 变量和对应的值。

  • 当函数执行时,例如 father 函数,其内部的局部变量将会被创建,比如 data 变量,而这些变量称之为AO活跃对象,这个对象很重要,与闭包(Closure)对象关联很大。

  • 当 father 函数执行,son 函数被创建。前面说过函数创建时,会生成[[Scopes]]属性,该属性是个数组,会把当前函数的父函数的 [[Scopes]] 元素、以及父函数执行生成的 AO对象,依次压入自身的[[Scopes]]数组中。

    在这个例子中就是 father 函数执行,son 函数被创建,那么 father.[[Scopes]]、father AO对象,将依次被压入 son.[[Scopes]] 数组中。

  • 全局函数创建时,比如会被压入 Global:全局对象,即window; 全局 Script:全局let、const

函数在查找变量时,其实就是沿着 AO 和 [[Scopes]]查找。father 函数执行时,AO创建完毕后,JavaScript v8 引擎会深度扫描 father 函数的所有子函数,看是否对 father 函数创建的 AO 对象成员有所访问(father 函数的局部变量),若有则从 AO 中把该变量标记一下,表示 father 函数执行完后,不销毁此变量。

随后, son 函数被返回赋值给一个全局变量resFn,至此 son.[[Scopes]] = [ fatherAO 标记, Script, Global ],又因为全局变量 resFn === son,那么 son.[[Scopres]] 属性即被引用着,会阻止 GC 回收 fatherAO标记。于是 father 函数中的成员变量 data 所指向的对象被保留。fatherAO 标记,被称之为闭包(Closure)。

这点是不是动态语言的强大性?

进阶

把上一个例子稍微改一下:

function father() {
  const data = { name: 'GWJ' };
  const uname = 'GWJ';

  // 注意这段,是搅屎棍
  (function () {
    data, uname;
    console.log('hhh');
  })();

  return function son() {
    console.log('啥也不引用');
  };
}

const resFn = father();

看下图,我们返回的 son 函数,并没有访问 data、uname 这些局部变量。但最终这两个局部变量还是被压入 son.[[Scopes]] 数组中。

不用说,都知道是立即执行函数的原因,因为我们说过:

father 函数执行,JavaScript v8 引擎会深度扫描 father 函数的所有子函数,是否对 father 函数创建的 AO 对象成员有所访问(father 函数的局部变量),若有则从 AO 把该变量标记一下,表示 father 函数执行完后,不销毁此变量。

那么,data、name 两个 father 的AO成员,将被标记,之后变为闭包(Closure)对象。

讲解`例子`

有了以上的铺垫,想要弄清楚文章开头的例子,是很容易的。

class TestData {
    static count = 0;
    constructor() {
        this.dataList = new Array(5000).fill(0).map(() => Math.random().toString());
        this.count = TestData.count;
    }
}

let globalData = null;
document.onclick = function handleClick() {
    const originGlobalData = globalData;
    function unused() {
        if (!originGlobalData) return;
    
        // 做一些事情......
    }
  
    TestData.count += 1; // 点击次数
    globalData = {
        data: new TestData(),
        someMethod: function someMethod() {
            // 做一些事情
            // 但是没有使用到 originThing
        }
    };
}

下面将以用户的操作视角进行剖析:

1、第一次点击,handleClick 函数执行时:

  • 创建 AO对象(只有 originGlobalData 一个成员),此时 AO.originGlobalData = null
  • 接下来 chrome v8 引擎深度扫描,发现 unused 函数访问了 originGlobalData 局部变量,开始标记。
  • 最后创建 someMethod 函数,其Scopes 属性的变化
    someMethod.[[Scopes]].push(
          handleClick[[Scopes]],   
          handleClick.AO标记, 
    )
    
    // 即:
    someMethod.[[Scopes]].push(
          handleClick[[Scopes]],   
          {
            originGlobalData: null
          } 
    )
    
    
  • 最后,globalData.someMethod[[Scopes]] 中有闭包对象,即 { originGlobalData: null }

2、第二次点击,handleClick 函数执行中:

  • 创建 AO对象(只有 originGlobalData 一个成员),此时 AO.originGlobalData = 第一次 globalData 指向的对象
  • 接下来 chrome v8 引擎深度扫描,发现 unused 函数访问了 originGlobalData 局部变量,开始标记。
  • 最后创建 someMethod 函数
    someMethod.[[Scopes]].push(
          handleClick[[Scopes]],   
          handleClick.AO标记, 
    )
    
    // 即:
    someMethod.[[Scopes]].push(
          handleClick[[Scopes]],   
          {
            originGlobalData: 第一次 globalData 指向的对象
          } 
    )
    
  • 结尾处,globalData.someMethod[[Scopes]] 中有闭包对象,即 { originGlobalData: null }

已经不需要再往下写了,旧的 globalData 指向的对象,之所以不被 GC 回收,是因为在闭包中。

这是一个很经典的例子,让闭包形成了链表。

解决内存泄漏

所以,我们发现,绿色部分简直是搅屎棍,someMethod 函数什么都没引用,却导致产生了闭包,真冤枉啊。

class TestData {
    static count = 0;
    constructor() {
        this.dataList = new Array(5000).fill(0).map(() => Math.random().toString());
        this.count = TestData.count;
    }
}

let globalData = null;
document.onclick = function handleClick() {
    const originGlobalData = globalData;
    function unused() {
        if (!originGlobalData) return;
    
        // 做一些事情......
    }
  
    TestData.count += 1; // 点击次数
    globalData = {
        data: new TestData(),
        someMethod: function someMethod() {
            // 做一些事情
            // 但是没有使用到 originThing
        }
    };
}

改进(一):

document.onclick = function handleClick() {
    const originGlobalData = globalData;
    function unused() {
        if (!originGlobalData) return;
        if (!globalData) return;

        // 做一些事情......
    }
    
   // .....
}

改进(二):

document.onclick = function handleClick() {
    {
      const originGlobalData = globalData;
      if (!originGlobalData) return;
      // 做一些事情......
    }
    
    // 这段函数立即调用和上面的代码其实是等效的。
    // 或
    (function() {
      var originGlobalData = globalData;
      if (!originGlobalData) return;
      // 做一些事情......
    })();

  
    TestData.count += 1; // 点击次数
    globalData = {
        data: new TestData(),
        someMethod: function() {
            // 做一些事情
            // 但是没有使用到 originThing 常量
        }
    }
}

参考

0、闭包的概念

1、闭包引起的内存泄漏的例子

2、词法作用域和作用域链:

3、Chrome v8 内存管理机制

4、DevTools perform 工具的使用

5、queryObjects API

感谢

感谢您的认真阅读!如果您有任何问题、建议或想法,都可以畅所欲言,我将尽快回复您。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值