JavaScript:闭包

1,030 阅读7分钟

  本文和大家聊聊闭包,闭包与变量对象和作用域链有着比较多的联系,在阅读本文前,大家需要理解执行上下文、变量对象以及作用域链等内容,这些内容对理解闭包的本质有很大的帮助,前面的两篇文章已经梳理过了,不清楚的同学可以先阅读之前的文章。

自由变量

  上篇文章没有提到自由变量这个概念,现在需要理解这个概念。

  在一个作用域中使用了一个变量,但是这个变量没有在这个作用域中声明(在其他作用域中声明),对于该作用域而言,这个变量就是一个自由变量。

let a = 10;
function foo() {
  let b = 20;
  console.log(a + b); // 10 在foo函数作用域中,a就是一个自由变量
}
foo();

  从上面的实例来看,调用foo函数时,a的取值是来自全局作用域,所以变量a相对foo函数作用域而言变量a是一个自由变量,而b的取值是来自foo作用域,所以变量b对于foo作用域变量b不是自由变量。

定义

  闭包是函数和声明该函数的词法环境的组合。

  其实闭包的概念不好解释,似乎解释不清楚,目前业界对闭包的概念解释有两种,但是不管是哪种解释,思想是一致的,只是包含的范围不同而已,我们看下面的实例,再来说说闭包这个东西。

function foo() {
  let a = 10;
  function bar() {
    console.log(a); // 10
  }
  return bar;
}
let baz = foo();
baz();

  上面是一个很简单的实例,这就产生了闭包,为啥产生了闭包???

  函数foo中创建了函数bar,并返回了函数bar,并在函数foo作用域外执行了函数bar,当函数bar执行时,访问了foo作用域中的变量a,这就产生了闭包。

  也就是说当一个函数有权访问另一个函数作用域中的变量,并且该函数在另一个函数的词法作用域外执行就会产生闭包。

  从上面的实例来看,也就有人会理解函数foo是闭包,也有人理解函数bar是闭包,Chrome开发者工具中会以函数foo代指闭包。其实不用管闭包是指哪个,我们需要理解什么情况下会产生闭包,闭包产生是在一个什么样的场景。下面从底层原理上分析闭包产生的原因。

原理

  我们先看一个实例:

function foo() {
  let a = 10;
  function bar() {
    console.log(a); // 10
  }
  return bar;
}
let baz = foo();
baz();

  这个实例和上面的举例是同一个,产生了闭包,我们分析下这个实例在代码执行过程中,执行上下文栈的情况:

// 创建执行上下文栈
ECStack = [];

// 最先进入全局环境,全局执行上下文被创建被压入栈
ECStack.push(globalContext);

// foo() 创建该函数执行上下文并压入栈中
ECStack.push(<foo> functionContext);

// foo()执行完毕弹出
ECStack.pop();

// baz被调用,创建baz执行上下文并压入栈中
ECStack.push(<baz> functionContext);

// baz执行完毕弹出
ECStack.pop();

// 代码全局执行完毕,全局执行上下文弹出
ECStack.pop();

  在来看看bar函数执行上下文的内容:

bar.[[scope]] = [fooContext.VO, globalContext.VO];

barContext = {
  VO: {xxx}, // 变量对象
  this: xxx,
  scopeChain: [barContext.VO].concat(bar.[[scope]]) // [barContext.VO, fooContext.VO, globalContext.VO]
}

  从上面的执行上下文栈的执行情况来看,baz函数执行的时候,foo函数的执行上下文已经出栈了,按照JavaScript垃圾回收机制,foo函数执行上下文的变量对象失去引用后会被垃圾回收机制回收。

  但是上面的实例特殊,bar函数在foo函数中创建,foo函数最终是返回了bar函数,并通过变量baz,在foo函数作用域外执行了,以及访问了foo函数作用域中的a变量。

  函数bar执行上下文中的作用域链包含了函数foo执行上下文中的变量对象fooContext.VO,所以函数foo执行上下文的变量对象不会被垃圾回收机制回收,函数bar访问了函数foo中的变量,阻止了函数foo执行上下文的变量对象被垃圾回收机制回收,正因此函数bar在函数foo的词法作用域外执行,同时也可以访问foo作用域中的变量a,这也就是产生闭包的原因。

  我们来归纳下闭包本质是什么:

  闭包是一个函数,上面的实例来看,不管是foo函数还是bar函数,归根结底还是一个函数,但是和普通函数不一样,其拥有特殊能力。

  概括的讲,我们可以把闭包看作是一个场景,如果一个函数B在函数A中创建,当函数A的执行上下文已经出栈了,但是函数B在函数A的词法作用域外执行并仍然能访问函数A中的变量对象,我们就可以说这产生了闭包。我们可以不用在意函数A是闭包还是函数B是闭包,但我们要清楚什么场景下会产生闭包。

  归纳下闭包的特点:

  • 函数A的执行上下文已经出栈
  • 函数B能访问函数A执行上下文的变量对象
  • 函数B在函数A的词法作用域外执行

  最后总结性的说,函数A调用完成后,函数A的执行上下文已经出栈,其变量对象会失去引用等待被垃圾回收机制回收,然而闭包,阻止这一过程,因为函数B的作用域链包含了函数A的执行上下文的变量对象。

  下面我们看一个实例,熟悉下闭包,增强对闭包的理解。

function foo() {
  let a = 'Hello world';
  function bar() {
    a += ' 6';
    console.log(a);
  }
  return bar;
}
let baz = foo();
baz(); // Hello world 6
baz(); // Hello world 6 6

  函数foo调用完成后,此时函数foo执行上下文的变量对象内容如下:

fooContext.VO = {
  bar:  <reference to FunctionDeclaration 'bar'>,
  a: 'Hello world'
}

  当函数foo调用完成后,其执行上下文出栈后,它的变量对象没有被垃圾回收机制回收,因为baz函数调用,函数bar的作用域链保存了函数foo执行上下文的变量对象,其变量对象一直在内存中,没有被销毁。

  在函数baz第一次调用后,访问了函数foo作用域中的变量a,并对变量a做相关的操作,使得变量a的值发生了变化,值为Hello world 6,此时函数foo执行上下文的变量对象内容如下:

fooContext.VO = {
  bar:  <reference to FunctionDeclaration 'bar'>,
  a: 'Hello world 6'
}

  第一次调用baz后,函数foo中的变量a值为Hello world 6,没有被销毁,所以第二次调用baz时,函数foo中的变量a值为Hello world 6 6

  也正因为闭包会阻止垃圾回收机制对变量进行回收,变量会永久存在内存中,相当于全局变量一样会占用着内存,内存消耗很大,所以不能滥用闭包,否则会造成网页的性能问题,在IE中可能导致内存泄露。解决方法是,在退出函数之前,将不使用的局部变量全部删除。如上面实例,我们可以将变量设置为null

function foo() {
  let a = 'Hello world';
  function bar() {
    a += ' 6';
    console.log(a);
  }
  return bar;
}
let baz = foo();
baz(); // Hello world 6
baz(); // Hello world 6 6
baz = null; //如果baz不再使用,将其指向的对象释放

闭包应用

  在JavaScript中,因为闭包独有的特性,其应用场景很多。

  • 用于保存私有属性,将不需要对外暴露的属性、函数保存在闭包函数的父函数里,避免外部操作对值的干扰
  • 避免局部属性污染全局变量空间导致的命名空间混乱
  • 模块化封装,将对立的功能模块通过闭包进去封装,只暴露较少的API供外部应用使用
  • 柯里化,在函数式编程中,利用闭包能够实现很多炫酷的功能,柯里化便是其中很重要的一种

  关于闭包的应用,在这里先不做展开,因为里面也有很多自己不太清楚的东西,例如函数式编程,目前自己也不太熟悉,里面还涉及很多其他的知识,关于闭包的应用这块内容暂时不做详细的输出,避免不懂装懂,在这里先梳理闭包有哪些应用,后期对柯里化、模块化封装等内容另外做文字输出。