谈谈JavaScript的闭包

前言

闭包(closure)这个概念总是那么晦涩难懂,这篇文章希望结合自己的经验来说说这个概念。

作用域

在谈闭包之前,作用域这个概念是一定绕不开的。但是这里并不准备展开来详细说明他。简单总结几点。

  1. JavaScript中函数能形成一个作用域。
  2. 在使用某个变量时,会在当前作用域查找;如果没有找到,则向外层作用域查找,直到全局作用域。也就是说,变量的搜索是从内到外,而不是从外到内。

变量的生存周期

JavaScript对无用变量占用的内存会进行回收,现代浏览器是通过标记清除来实现垃圾回收的。以一个函数的局部变量为例。

  1. 当函数开始执行时,申明了一个变量,为他分配了内存。此时会将其标记为进入环境。
  2. 函数执行完成后,会将该变量标记为离开环境
  3. JavaScript引擎会在合适的时机将所有离开环境的变量全部回收。

闭包

先看一段十分简单的代码

const foo = () => {
    let duck = 0;
    return () => {
        console.log(duck++);
    }
}
try {
    console.log(duck);
} catch (e) {
    console.log(e.message);
}

const bar = foo();
for(let i = 0;i < 5; i++) {
    bar();
}
  1. 首先我们尝试打印duck,这里的异常会被捕获,打印出duck is not defined,因为外部无法访问内部的一个变量
  2. 我们在foo这个函数,返回了一个匿名函数并且赋值给bar,然后连续调用了他,连续的打印出了0.1.2.3.4,可以看出,在foo这个函数作用域内的duck变量在函数执行完后,没有被回收。

以上现象出现的原因就是因为我们返回了一个函数并且给到外部的环境,而这个函数内的作用域依然保存了一个变量的引用(duck)。所以即使在foo函数执行完后,duck这个变量依然存在于内存中。在这里,这个匿名函数可以理解为一个闭包

闭包有哪些实际应用场景

在面向对象设计中,闭包可以用来实现自定义属性读写。看一个简单的例子

function Student(name,age){
    this.name = name;
    this.age = age;
}

const s = new Student('xiaoming',27);

这里Student我们作为函数构造器来使用,但是这样得到的实例有两个问题

  1. 比如我们访问了一个不存在的属性,例如s.height,这时候会返回undefined,假如你又同时对这个未知的值做一些额外的操作,那么此时很大概率会抛出TypeError。
  2. JavaScript是弱类型的脚本语言,我们在定义Student的age时,明显希望他是一个Number类型,并且应该在一个合适的范围之内。但是实际上你有可能会这样
 s.age = "手动狗头"

瞧瞧,这是人干的事么…

这个时候利用下闭包就可以这么干了

function Person(name,age){
    var pName = name;
    var pAge = age;

    this.getName = function(){
        return pName || '';
    }
    this.setName = function(name){
        pName = name;
    }

    this.getAge = function(){
        return pAge || '';
    }
    this.setAge = function(age){
        if (isNaN(Number(age))) throw new Error('age must be a number');
        if (age < 0 || age > 200) throw new Error('emmmm...');
        pAge = age;
    }
}


const s = new Person('xiaoming',27);

我们可以利用闭包来延长某些局部变量的存活时间
前端中很多有这样的场景,希望发起一个日志请求,比如打点

var report = function (src) {
    var img = new Image();
    img.src = src;
};
report('http://xxx.com/getUserInfo');

用图片来构造请求,的确十分方便,但是实际使用的时候会发现并不是所有请求都能发出去。原因就在于report函数执行完后,img这个局部变量被回收了,此时的请求还没来得及发出去。
利用闭包可以这么改造一下。

var reportGen = function () {
    var imgs = [];
    return function (src){
        var img = new Image();
        img.src = src;
        imgs.push(img);
    }
};
var report = reportGen();
report('http://xxx.com/getUserInfo');

闭包会不会引起内存泄漏

之前我们提到, 闭包可以使某些本应该被回收的变量存活了下来,那么使用闭包会引起内存泄漏么。看一段小Demo
注意,以下代码运行时都是Node

function createArray(){
    const SIZE = 20 * 1024 * 1024;
    return new Array(SIZE);
}

for (let i = 0; i < 15;i++ ) {
    createArray();
    console.log(`第${i+1}次执行,目前已使用内存${process.memoryUsage().heapUsed/1024/1024}M`);
}

看下结果

第1次,目前已使用内存164.11568450927734M
第2次,目前已使用内存324.1443176269531M
第3次,目前已使用内存484.1500015258789M
第4次,目前已使用内存644.1517562866211M
第5次,目前已使用内存804.1533584594727M
第6次,目前已使用内存964.1549606323242M
第7次,目前已使用内存1124.1565628051758M
第8次,目前已使用内存1284.1581649780273M
第9次,目前已使用内存323.87422943115234M
第10次,目前已使用内存483.87635040283203M
第11次,目前已使用内存643.8779983520508M
第12次,目前已使用内存803.879524230957M
第13次,目前已使用内存964.1310577392578M
第14次,目前已使用内存1124.132583618164M
第15次,目前已使用内存1284.1341094970703M

我们不断的开辟内存,发现从第1次到第8次内存使用量都在不停的增加,从第9次开始又开始回到正常水平,证明此时JavaScript进行了一次内存的回收,你们可以自己试试看,值得一提的是,从8到9程序会有细微的卡顿,这可以说明一些问题,这里不展开。

我们来改造下这个Demo

const createArray = (function(){
    const arrays = [];
    return function (){
        const SIZE = 20 * 1024 * 1024;
        arrays.push(new Array(SIZE));
    }
})()

for (let i = 0; i < 15;i++ ) {
    createArray();
    console.log(`第${i+1}次执行,目前已使用内存${process.memoryUsage().heapUsed/1024/1024}M`);
}

我们来看下执行结果

<--- Last few GCs --->

[72721:0x102802400]     3012 ms: Mark-sweep 1283.9 (1290.9) -> 1283.9 (1290.9) MB, 549.4 / 0.0 ms  allocation failure GC in old space requested
[72721:0x102802400]     3589 ms: Mark-sweep 1283.9 (1290.9) -> 1283.8 (1287.9) MB, 576.8 / 0.0 ms  last resort GC in old space requested
[72721:0x102802400]     4122 ms: Mark-sweep 1283.8 (1287.9) -> 1283.8 (1287.9) MB, 533.2 / 0.0 ms  last resort GC in old space requested


<--- JS stacktrace --->

==== JS stack trace =========================================

Security context: 0x294d13f258b9 <JSObject>
    2: createArray [/Users/zhongzeming/myWork/practise/design-mode/advance-fun/memory.js:18] [bytecode=0x294dce463991 offset=16](this=0x294d90c8c2f1 <JSGlobal Object>)
    3: /* anonymous */ [/Users/zhongzeming/myWork/practise/design-mode/advance-fun/memory.js:22] [bytecode=0x294dce4633e1 offset=22](this=0x294d93b3d5d9 <Object map = 0x294dd66823b9>,exports=0x294d93b3d5d9 <Object map = 0x294dd668...

FATAL ERROR: CALL_AND_RETRY_LAST Allocation failed - JavaScript heap out of memory

这里截取了部分报错的信息,看最后一句 JavaScript heap out of memory。
我们的arrays这个变量没有被回收,导致他占的内存越来越大。最后就内存溢出了。这样看起来,闭包似乎会引起内存泄漏

再看一段Demo

let s = [];

function createArray(){
    const SIZE = 20 * 1024 * 1024;
    return new Array(SIZE);
}

for (let i = 0; i < 15;i++ ) {
    s[i] = createArray();
    console.log(`第${i+1}次执行,目前已使用内存${process.memoryUsage().heapUsed/1024/1024}M`);
}

这段代码的执行结果也会报错,内存也会溢出。

改造一下这个代码,我们在合适的时机把数组s的元素引用置为null

let s = [];

function createArray(){
    const SIZE = 20 * 1024 * 1024;
    return new Array(SIZE);
}

for (let i = 0; i < 15;i++ ) {
    s[i] = createArray();
    console.log(`第${i+1}次执行,目前已使用内存${process.memoryUsage().heapUsed/1024/1024}M`);
    s[i] = null;
}

这样又可以正常运行了

闭包和内存泄漏其实没有关系

为什么这么说,其实在我看来,内存泄漏只是因为你没有对内存进行妥善的管理。尤其在浏览器环境,我们经常在闭包里面形成对象的循环引用,而这在早期的一些浏览器中,由于他们使用的垃圾回收机制是引用清除,这会导致两个对象占用的内存都不会被回收(现代浏览器大多数已经解决了这个问题)
所以说,内存泄露在本质上也不是闭包造成的。

服务端要妥善使用

浏览器环境,我们一般不会用到需要申请非常大内存的场景。但是如果你通过Node来实现某个服务,这个时候就要特别注意,因为大量的请求很容易让你的内存泄漏问题暴露出来,一旦内存溢出会导致服务crash,即使通过守护进程重启,也会造成很大的损失。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章