Skip to content

js中的闭包

前言

面试的时候经常会问到闭包的概念,经常是一时语塞,不知道怎么回答。本篇文章就来对闭包做一个总结,通过回答下面几个问题,让你彻底了解闭包。

  1. 什么是闭包
  2. 闭包的作用原理
  3. js中的词法环境
  4. 闭包的应用场景

什么是闭包

我们先来看一个闭包的例子。

一个小例子

在下面的代码中,我们声明了一个函数fn(),返回值是一个匿名函数。所以我们调用一下fn()可以拿到这个匿名函数,将其赋值到fn2变量上,然后调用fn2。这时你会惊奇的发现,我们每次调用fn2,都能够访问到fn中定义的变量a,并且每执行一次,a都会在原来的基础上自增1。也就是说,fn函数内部定义的变量a,并没有在fn()执行完毕后销毁掉,仍然存储在内存中。这是为什么呢?

按我们正常的理解,原函数fn()在调用一次后,内部的局部变量a理应被销毁掉了(大部分语言都具有的特性),但是在这个例子中我们发现并没有。这是为什么呢?

js
function fn() {
    let a = 10
    return function () {
        return a++
    }
}

let fn2 = fn()
console.log(fn2()); //10
console.log(fn2()); //11
console.log(fn2()); //12

闭包的定义

实际上,上述例子中返回的匿名函数,就是js中所谓的闭包。因此我们给出js中闭包的定义,如下:

闭包:是指内部函数总是可以访问到其所在的外部函数中声明的变量和参数,即使在其外部函数被返回(寿命终结)了之后。

定义参考:https://zh.javascript.info/closure

闭包的作用原理

js
function fn() {
    let a = 10
    return function () {
        return a++
    }
}

let fn2 = fn()
console.log(fn2()); //10
console.log(fn2()); //11
console.log(fn2()); //12

我们从内存和词法环境的角度分析一下上面的例子,如下图所示:

闭包

  1. 我们使用function创建了一个函数fn时,函数存储在堆内存中,对应的变量指向内存地址(图中的Ox123)。

  2. 当使用let fn2 = fn()我们获取到fn的返回值时,得到了一个新的匿名函数(图中的Ox456),变量fn2指向这块内存地址。

    🚩在js中,每个运行的函数,代码块{}以及整个脚本,都有一个被称为词法环境的内部(隐藏)的关联对象,该对象由两部分组成:

    • 环境记录(Environment Record):一个存储所有局部变量作为其属性(还包括一些其他信息,例如this的值)的对象。
    • 对外部词法环境的引用[[Environment]],与外部代码相关联。

    函数在创建时,js引擎就默认将该函数创建时对外部环境的引用保存了下来(也即自己来自哪里),所以,,本例中的fn2(),因为它是执行fn1()创建出来的,所以fn2()的词法环境会保留着fn1的引用(图中Ox456中的[[Environment]]: Ox123),所以当fn2在自己的词法环境中没有找到某个变量时,就会去它外部的词法环境Ox123中去寻找。

    参考:https://zh.javascript.info/closure

  3. 当我们调用fn2()时,返回值是a++,而Ox456的词法环境中并没有变量a,所以会去外部词法环境(Ox123)中找,于是就找到Ox123中的let a = 10

  4. 当调用多次fn2()时,每次访问的都是Ox123中的a变量,所以a变量将在同一位置处增加到11,12等。

词法环境

在上面分析闭包的作用原理时,提到了词法环境的概念,这有点涉及到了js底层的一些概念,我们来一起看一下js的词法环境是个啥。

为了使内容更清晰,这里将分步骤进行讲解。

参考:https://zh.javascript.info/closure

Step 1. 变量

在 JavaScript 中,每个运行的函数,代码块 {...} 以及整个脚本,都有一个被称为 词法环境(Lexical Environment) 的内部(隐藏)的关联对象。

词法环境对象由两部分组成:

  1. 环境记录(Environment Record): 一个存储所有局部变量作为其属性(包括一些其他信息,例如 this 的值)的对象。
  2. 外部词法环境 的引用,与外部代码相关联。

一个“变量”只是 环境记录 这个特殊的内部对象的一个属性。“获取或修改变量”意味着“获取或修改词法环境的一个属性”。

举个例子,这段没有函数的简单的代码中只有一个词法环境:

image-20211124114030634

这就是所谓的与整个脚本相关联的 全局 词法环境。

在上面的图片中,矩形表示环境记录(变量存储),箭头表示外部引用。全局词法环境没有外部引用,所以箭头指向了 null

随着代码开始并继续运行,词法环境发生了变化。

这是更长的代码:

image-20211124114115401

右侧的矩形演示了执行过程中全局词法环境的变化:

  1. 当脚本开始运行,词法环境预先填充了所有声明的变量。
    • 最初,它们处于“未初始化(Uninitialized)”状态。这是一种特殊的内部状态,这意味着引擎知道变量,但是在用 let 声明前,不能引用它。几乎就像变量不存在一样。
  2. 然后 let phrase 定义出现了。它尚未被赋值,因此它的值为 undefined。从这一刻起,我们就可以使用变量了。
  3. phrase 被赋予了一个值。
  4. phrase 的值被修改。

现在看起来都挺简单的,是吧?

  • 变量是特殊内部对象的属性,与当前正在执行的(代码)块/函数/脚本有关。
  • 操作变量实际上是操作该对象的属性。

👨词法环境是一个规范对象

“词法环境”是一个规范对象(specification object):它仅仅是存在于 编程语言规范 中的“理论上”存在的,用于描述事物如何运作的对象。我们无法在代码中获取该对象并直接对其进行操作。

但 JavaScript 引擎同样可以优化它,比如清除未被使用的变量以节省内存和执行其他内部技巧等,但显性行为应该是和上述的无差。

Step 2. 函数声明

一个函数其实也是一个值,就像变量一样。

不同之处在于函数声明的初始化会被立即完成。

当创建了一个词法环境(Lexical Environment)时,函数声明会立即变为即用型函数(不像 let 那样直到声明处才可用)。

这就是为什么我们可以在(函数声明)的定义之前调用函数声明。

例如,这是添加一个函数时全局词法环境的初始状态

image-20211124114950054

正常来说,这种行为仅适用于函数声明,而不适用于我们将函数分配给变量的函数表达式,例如 let say = function(name)...

Step 3. 内部和外部的词法环境

在一个函数运行时,在调用刚开始时,会自动创建一个新的词法环境以存储这个调用的局部变量和参数。

例如,对于 say("John"),它看起来像这样(当前执行位置在箭头标记的那一行上):

image-20211124115136181

在这个函数调用期间,我们有两个词法环境:内部一个(用于函数调用)和外部一个(全局):

  • 内部词法环境与 say 的当前执行相对应。它具有一个单独的属性:name,函数的参数。我们调用的是 say("John"),所以 name 的值为 "John"
  • 外部词法环境是全局词法环境。它具有 phrase 变量和函数本身。

内部词法环境引用了 outer

👩当代码要访问一个变量时 —— 首先会搜索内部词法环境,然后搜索外部环境,然后搜索更外部的环境,以此类推,直到全局词法环境。

如果在任何地方都找不到这个变量,那么在严格模式下就会报错(在非严格模式下,为了向下兼容,给未定义的变量赋值会创建一个全局变量)。

未定义的变量就是没有使用let、const或者var声明的变量,比如a = 10,没有任何前缀,这种变量在非严格模式下会直接挂载到全局对象下面(浏览器端是window,node端是global)。

在这个示例中,搜索过程如下:

  • 对于 name 变量,当 say 中的 alert 试图访问 name 时,会立即在内部词法环境中找到它。
  • 当它试图访问 phrase 时,然而内部没有 phrase,所以它顺着对外部词法环境的引用找到了它。

image-20211124115717176

Step 4. 返回函数

看一个例子:

js
function makeCounter() {
  let count = 0;

  return function() {
    return count++;
  };
}

let counter = makeCounter();

在每次 makeCounter() 调用的开始,都会创建一个新的词法环境对象,以存储该 makeCounter 运行时的变量。

因此,我们有两层嵌套的词法环境,就像上面的示例一样:

image-20211124121441462

不同的是,在执行 makeCounter() 的过程中创建了一个仅占一行的嵌套函数:return count++。我们尚未运行它,仅创建了它。

所有的函数在“诞生”时都会记住创建它们的词法环境。从技术上讲,这里没有什么魔法:所有函数都有名为 [[Environment]] 的隐藏属性,该属性保存了对创建该函数的词法环境的引用。

image-20211124121602390

因此,counter.[[Environment]] 有对 {count: 0} 词法环境的引用。这就是函数记住它创建于何处的方式,与函数被在哪儿调用无关。[[Environment]] 引用在函数创建时被设置并永久保存。

稍后,当调用 counter() 时,会为该调用创建一个新的词法环境,并且其外部词法环境引用获取于 counter.[[Environment]]

image-20211124121726875

现在,当 counter() 中的代码查找 count 变量时,它首先搜索自己的词法环境(为空,因为那里没有局部变量),然后是外部 makeCounter() 的词法环境,并且在哪里找到就在哪里修改。

在变量所在的词法环境中更新变量。

这是执行后的状态:

image-20211124121842622

如果我们调用 counter() 多次,count 变量将在同一位置增加到 23 等。

👴通过本小节对词法环境的学习,对闭包中内部函数为何能访问到外部环境中的变量有了更深刻的理解。

上面这种,内部函数可以访问到外部函数中的变量和参数,即使在其外部函数被返回(寿命终结)了之后也没关系,这样的函数就称之为闭包。

在某些编程语言中,这是不可能的,或者应该以特殊的方式编写函数来实现。但是在在 JavaScript 中,因为有了闭包和词法环境,就出现了这样的效果。如果仔细的去思考,你会发现,其实在 JavaScript 中,所有函数都是天生闭包的

也就是说:JavaScript 中的函数会自动通过隐藏的 [[Environment]] 属性记住创建它们的位置,所以它们都可以访问外部变量。

(不过有个例外,使用new Function()创建的函数,其[[Environment]]会默认指向全局环境,因此,此类函数无法访问外部(outer)变量,只能访问全局变量),new Function()的使用参考另一篇文章 new-function

在面试时,前端开发者通常会被问到“什么是闭包?”,正确的回答应该是闭包的定义,并解释清楚为什么 JavaScript 中的所有函数都是闭包的,以及可能的关于 [[Environment]] 属性和词法环境原理的技术细节。

应用场景

柯里化函数

假如有个获取面积的函数,如下面的代码所示,假如宽总是同一个值,每次都需要重新写一份。

js
// 假设我们有一个求长方形面积的函数
function getArea(width, height) {
    return width * height
}
// 如果我们碰到的长方形的宽总是10
const area1 = getArea(10, 20)
const area2 = getArea(10, 30)
const area3 = getArea(10, 40)

我们可以使用闭包,将函数做一次封装,如下:

js
// 我们可以使用闭包柯里化这个计算面积的函数
function getArea(width) {
    return height => {
        return width * height
    }
}

const getTenWidthArea = getArea(10)
// 之后碰到宽度为10的长方形就可以这样计算面积
const area1 = getTenWidthArea(20)

// 而且如果遇到宽度偶尔变化也可以轻松复用
const getTwentyWidthArea = getArea(20)

柯里化可以避免频繁调用具有相同参数函数,同时又能够轻松的复用。

实现私有方法或变量

js
function funOne(i){
    function funTwo(){
        console.log('数字:' + i);
    }
    return funTwo;
};
var fa = funOne(110);
var fb = funOne(111);
var fc = funOne(112);
fa();       // 输出:数字:110
fb();       // 输出:数字:111
fc();       // 输出:数字:112

其实就是模块的方式, 现代化的打包最终其实就是每个模块的代码都是相互独立的。

立即调用函数 IIFE

在之前,JavaScript 中只有 var 这一种声明变量的方式,并且这种方式声明的变量没有块级作用域,程序员们就发明了一种模仿块级作用域的方法。这种方法被称为“立即调用函数表达式”(immediately-invoked function expressions,IIFE)。

如今,我们不应该再使用 IIFE 了,但是你可以在旧脚本中找到它们。

IIFE 看起来像这样:

js
(function() {

  var message = "Hello";

  console.log(message); // Hello

})();

这里,创建了一个函数表达式并立即调用。因此,代码立即执行并拥有了自己的私有变量。

函数表达式被括号 (function {...}) 包裹起来,因为当 JavaScript 引擎在主代码中遇到 "function" 时,它会把它当成一个函数声明的开始。但函数声明必须有一个函数名,所以这样的代码会导致错误:

js
// 尝试声明并立即调用一个函数
function() { // SyntaxError: Function statements require a function name

  var message = "Hello";

  console.log(message); 

}();

即使我们说:“好吧,那我们加一个名称吧”,但它仍然不工作,因为 JavaScript 不允许立即调用函数声明:

因此,需要使用圆括号把该函数表达式包起来,以告诉 JavaScript,这个函数是在另一个表达式的上下文中创建的,因此它是一个函数表达式:它不需要函数名,可以立即调用。

除了使用括号,还有其他方式可以告诉 JavaScript 在这我们指的是函数表达式:

js
// 创建 IIFE 的方法

(function() {
  console.log("Parentheses around the function");
})();

(function() {
  console.log("Parentheses around the whole thing");
}());

!function() {
  console.log("Bitwise NOT operator starts the expression");
}();

+function() {
  console.log("Unary plus starts the expression");
}();

在上面的所有情况中,我们都声明了一个函数表达式并立即运行它。请再注意一下:如今我们没有理由来编写这样的代码。

缓存一些结果

js
function funParent(){
    let memo = [];
    function funTwo(i){
        memo.push(i);
        console.log(memo.join(','))
    }
    return funTwo;
};

const fn = funParent();

fn(1); // 1
fn(2); // 1,2

总结

  1. 闭包是指:内部函数总能够访问到外部函数的变量和参数,即使外部函数被返回了(寿命终结)之后
  2. 在 JavaScript 中,每个运行的函数,代码块 {...} 以及整个脚本,都有一个被称为 词法环境(Lexical Environment) 的内部(隐藏)的关联对象。对象包括两部分
    1. 环境记录(Environment Record): 一个对象,存储了它所有的局部变量,还包括一些其他信息,例如 this 的值。
    2. 外部词法环境 的引用[[Environment]],与外部代码相关联(函数在创建时,js引擎就默认给绑定上了,我们是访问不到的)。
  3. 因为函数在创建时,会默认绑定外部词法环境[[Environment]](即自己从哪来的),所以js中所有的函数都是天然闭包。但是有一个例外,使用new Function创建的函数,创建时引擎会默认将[[Environment]]绑定在全局,所以它是无法访问到局部变量的。
  4. 因为闭包的这些特性,所以有下面几个应用场景:
    1. 函数柯里化:将函数封装为fn()()()的形式,在某些应用场景上会更灵活。
    2. 实现私有方法或变量:其实就是模块化的最终结果,为了各个模块不相互影响,每个模块实际上都是一个独立的闭包。
    3. 立即调用函数 IIFE:早期没有块级作用域时,为了实现私有变量,程序员发明的模仿块级作用域的方法,(function(){})()
    4. 缓存结果:将计算的结果,先保存在闭包父级函数的某个变量上,在执行闭包函数时,先进行一次判断,如果已经有了计算结果,直接从缓存中拿。

参考

https://zh.javascript.info/closure