彻底理解JS的闭包概念

1,065 阅读6分钟

什么是闭包

闭包是指有权访问另外一个函数作用域中的变量的函数

关键点:

  • 是一个函数
  • 能访问另一个函数作用域中的变量(及时另一个函数的执行上下文已经销毁)

闭包的特性

  1. 闭包可以访问当前函数以外的变量
  2. 闭包可以更新闭包中外部变量的值

作用域链

作用域链是闭包实现的核心,js代码运行时,会创建若干个执行上下文,而函数会创建函数的执行上下文,在函数执行上下文中主要有两个组成部分,一个是环境记录,一个是对外部环境的引用

环境记录:

  • 声明性环境记录(函数中的变量,函数以及函数的参数)
  • 对象环境记录(用于定义在全局执行上下文中出现的变量和函数的关联。全局环境包含对象环境记录。)

对外部环境的引用,我们就称之为「作用域链」

当访问一个变量时,解释器会首先在当前的作用域查找对应的标识符,如果没有找到,就去父级作用域找,直到找到该变量的标识符或者不在任何一级作用域中

作用域链和原型继承查找时的区别:如果去查找一个普通对象的属性,但是在当前对象和其原型中都找不到时,会返回undefined;但查找的属性在作用域链中不存在的话就会抛出ReferenceError。

作用域链的顶端是全局对象,在全局环境中定义的变量就会绑定到全局对象中

举例说明

let a = 'global';
function myscope(){
    let a = 'scope';
    function f(){
        return a;
    }
    return f;
}

let foo = myscope(); //foo指向函数f
foo(); // 调用函数f

执行过程如下:

  1. 进入全局代码,创建全局执行上下文,全局执行上下文压入执行栈
  2. 全局上下文初始化(函数变量声明,函数提升,变量提升等)
  3. 执行myscope函数,创建myscope执行上下文,myscope执行上下文压入执行栈
  4. myscope执行上下文初始化,创建变量对象,作用域链,绑定this
  5. myscope函数执行完毕,myscope执行上下文从执行栈中弹出
  6. 执行函数f,创建f执行上下文,f执行上下文压入执行栈
  7. f进行上下文初始化
  8. f函数执行完毕,f函数从执行栈中弹出

函数f执行的时候,myscope函数执行上下文已经从执行栈中弹出了,那么是如何访问到myscope函数中的变量的呢,关键就在于作用域链

fContext = {
    Scop:[AO, myscopeContext.AO, globalContext.AO]
}

所以指向关系是当前作用域 --> myscope作用域--> 全局作用域,即使 myscopeContext.AO 被销毁了,但是 JavaScript 依然会让 myscopeContext.AO(活动对象) 活在内存中,f 函数依然可以通过 f 函数的作用域链找到它,这就是闭包实现的关键

闭包举例2

var data = [];

for (var i = 0; i < 3; i++) {
  data[i] = function () {
    console.log(i);
  };
}

data[0](); // 3
data[1](); // 3
data[2](); // 3

输出全部都是3

循环结束后,全局执行上下文的VO是

globalContext = {
    VO: {
        data: [...],
        i: 3
    }
}

执行 data[0] 函数的时候,data[0] 函数的作用域链为: data[0]Context = { Scope: [AO, globalContext.VO] }

改进方法

var data = [];

for (var i = 0; i < 3; i++) {
  data[i] = (function (i) {
    return function(){
        console.log(i);
    }
  })(i)
}

data[0](); // 0
data[1](); // 1
data[2](); // 2

此时执行 data[0] 函数的时候,data[0] 函数的作用域链为: data[0]Context = { Scope: [AO, 匿名函数Context.AO, globalContext.VO] }

此时匿名函数的执行上下文AO为:

匿名函数Context = {
    AO: {
        arguments: {
            0: 0,
            length: 1
        },
        i: 0
    }
}

因为闭包执行上下文中贮存了变量i,所以根据作用域链会在globalContext.VO中查找到变量i,并输出0。

块级作用域(let)

同样是上面的例子,我们可以通过let来实现目的

var data = [];

for (let i = 0; i < 3; i++) {
  data[i] = function () {
    console.log(i);
  };
}

data[0]();
data[1]();
data[2]();

let创建的块级作用域类似如下

var data = [];// 创建一个数组data;

// 进入第一次循环
{ 
	let i = 0; // 注意:因为使用let使得for循环为块级作用域
	           // 此次 let i = 0 在这个块级作用域中,而不是在全局环境中
    data[0] = function() {
    	console.log(i);
	};
}

上面的块级作用域,就像函数作用域一样,函数执行完毕,其中的变量会被销毁,但是因为这个代码块中存在一个闭包,闭包的作用域链中引用着块级作用域,所以在闭包被调用之前,这个块级作用域内部的变量不会被销毁。

闭包的存在于销毁

闭包不是所有情况下都是持久存在的,如果存在对于闭包的引用,那么只要这个引用存在,那么闭包就会存在

var scope = "global scope";
function checkscope(){
    var scope = "local scope";
    function f(){
        return scope;
    }
    return f;
}

// 执行完后,闭包是否会被垃圾回收
checkscope()();    // Yes

var foo = checkscope(); 
foo();     // No

checkscope()执行完成后,checkscope()()中自由变量特定时间之后回收,foo()中自由变量不回收。

现在主流浏览器的垃圾回收算法是标记清除,标记清除并非是标记执行栈的进出,而是从根开始遍历,也是一个找引用关系的过程,但是因为从根开始,相互引用的情况不会被计入。所以当垃圾回收开始时,从Root(全局对象)开始寻找这个对象的引用是否可达,如果引用链断裂,那么这个对象就会回收。

闭包中的作用域链中 parentContext.vo 是对象,被放在堆中,栈中的变量会随着执行环境进出而销毁,堆中需要垃圾回收,闭包内的自由变量会被分配到堆上,所以当外部方法执行完毕后,对其的引用并没有丢。

每次进入函数执行时,会重新创建可执行环境和活动对象,但函数的[[Scope]]是函数定义时就已经定义好的(词法作用域规则),不可更改。

对于第一种调用

checkscope()执行时,将checkscope对象指针压入栈中,其执行环境变量如下

checkscopeContext:{
    AO:{
        arguments:...
        scope:...
        f:...
    },
    this,
    [[Scope]]:[AO, globalContext.VO]
}

执行完毕后出栈,该对象没有绑定给谁,从Root开始查找无法可达,此活动对象一段时间后会被回收

对于第二种执行方式,foo作为闭包的引用,执行的时候执行上下文为:

fContext:{
    AO:{
        arguments:
    },
    this,
    [[Scope]]:[AO, checkscopeContext.AO, globalContext.VO]
}

此对象赋值给var foo = checkscope();,将foo压入栈中,foo指向堆中的f活动对象,对于Root来说可达,不会被回收。

如果需要scope的回收,取消引用即可,将foo = null