头图

闭包是什么

关于闭包的概念似乎说法不一:

  • 说法一:闭包就是那些引用了另一个函数作用域中变量函数,通常是在嵌套函数中实现的。——来自红宝书;
  • 说法二:函数函数内部能访问到的变量(也叫环境)的总和,就是一个闭包——来自这篇文章

来看一段代码:

function outer() {
    let n = 1
    function inner() {
        console.log(n)
    }
    return inner
}
  • 按照说法一,函数inner()是一个闭包;
  • 按照说法二,变量n和函数inner()构成一个闭包,即函数outer()是一个闭包;

其实这两种说法都没错,但是不必纠结于具体的说辞。首先得弄清楚闭包存在的意义,以及闭包解决了什么问题,然后再来看闭包的概念,就自然水到渠成了。

闭包解决了什么问题

先看结论,闭包实现了:

  • 读取函数内部的变量;
  • 将变量保存在内存中;

读取函数内部的变量

首先明确一点,在JS中,函数内部可以访问函数外部的变量,但反之不行,即:函数外部无法访问函数内部的变量。这涉及到JS的作用域链的知识,下文会介绍。

如果对于某些变量,出于安全性考虑,我不想直接暴露在外部,那么就可以把该变量放在一个函数内部,然后暴露一个访问该变量的接口。比如我有一个变量n需要隐藏起来,但是又需要在外部对n进行累加的操作,可以这样实现:

let addFunc = null // addFunc位于全局作用域
function outer() {
    let n = 1 // 将变量n隐藏起来,外部无法直接访问
    function inner() { // 这也是一个用来间接访问n的接口
        console.log(n)
    }
    addFunc = function () { // 用来间接操作n的接口,相当于setter
        n++
    }
    return inner
}

let res = outer()
res() // 1
addFunc()
res() // 2

其中addFunc()方法就是用来操作变量n的接口。其实上方代码中有两个闭包,因为函数inneraddFunc中都使用了局部变量n,所以局部变量ninneraddFunc分别构成一个闭包。
但是为什么可以在外部操作n实现累加呢?既然变量n是属于outer函数作用域的,当该函数执行完(也就是let res = outter()执行完),其作用域就会被清理、内存也随之回收,但是从两次res()打印的结果来看,n并没有被回收,原因就在于闭包的一个最为重要的作用——将变量保存在内存中

将变量保存在内存中

闭包为什么可以将变量保存在内存中呢?首先了解一下JS的垃圾回收机制。不像类C语言需要程序员手动清理内存,JS提供自动的垃圾回收机制。原理就是每隔一段时间会查看是否有需要回收的变量,判别依据就是该变量是否存在于当前执行上下文中以及是否在其他地方被引用
代码中let res = outer()实际上就是将函数inner赋给了一个全局变量res,全局变量只会在程序退出或网页关闭时被回收,而函数inner又引用了函数outer中的变量n,所以outer的上下文就不会随着调用结束而回收。

注意,如果直接通过outer()()的方式来调用函数,并不会形成闭包:

outer()() // 1
addFunc()
outer()() // 1

原因就是:outer()()只是将inner函数执行了,函数执行完其上下文就会被回收。所以let res = outer()这一步是必要的。

闭包和作用域链(重要)

现在你已经知道,闭包就是用来保存一些局部变量的,那么JS是如何将局部变量保存在内存中的?这就必须要理解JS中作用域链的相关知识。

先看一下红宝书中的例子(第四版 10.14):

// 该函数用于比较两个对象的属性值
function createComparisonFunction(propertyName) { // 局部变量propertyName
    return function (object1, object2) {
        let value1 = object1[propertyName] // 匿名函数中引用了propertyName
        let value2 = object2[propertyName]
        if (value1 < value2) {
            return -1
        } else if (value1 > value2) {
            return 1
        } else {
            return 0
        }
    }
}

let compare = createComparisonFunction('name')
let result = compare({ name: 'Nicholas' }, { name: 'Matt' })

很显然,这里局部变量propertyName和返回的匿名函数构成一个闭包。

先说结论:之所以propertyName会被保存在内存中,是因为返回的匿名函数的作用域链包含createComparisonFunction函数的作用域

一般函数的作用域链

先来看一下一般函数(非闭包)的作用域链的形成。

如下函数:

function compare(value1, value2) {
    if (value1 < value2) {
        return -1
    } else if (value1 > value2) {
        return 1
    } else {
        return 0
    }
}

let result = compare(5, 10)

几个概念的区分:上下文、变量对象、活动对象、作用域链

  • 执行上下文(简称“上下文”),包括变量和函数的上下文,决定了可以访问哪些数据及其行为;
  • 每个上下文都有一个关联的变量对象,上下文中定义的所有变量和函数都在这个对象上,其中函数的上下文叫活动对象
  • 函数在调用时会创建自己的上下文,同时会创建其变量对象的一个作用域链。作用域链决定了各级上下文中访问变量和函数的顺序;

调用该函数时,会创建该函数的上下文,以及该函数的作用域链。具体关系如下:

看上去有点绕,其实重点只关注compare函数的作用域链,其作用域链有两个变量对象:全局变量对象和函数的活动对象。当函数执行完毕,其执行上下文会销毁,由于,该函数的活动对象只依赖于其上下文,所以其活动对象也会被销毁,所以活动对象内部保存的变量就会被回收。只要网页还没关闭,全局变量对象就一直存在。这都符合我们一贯的认知。

闭包的作用域链

那么闭包形成的作用域链是怎样的呢?

来看之前的例子,当执行createComparisonFunction函数,即运行以下代码时:

let compare = createComparisonFunction('name')
let result = compare({ name: 'Nicholas' }, { name: 'Matt' })

外层函数createComparisonFunction内层函数compare形成的作用域链:

看上去似乎更绕了,还是把关注点放在两个函数的作用域链上

可以看到两个函数的作用域链有重叠的部分,即:都可以访问到外层函数createComparisonFunction的活动对象(局部变量propertyName),以及全局变量对象。这就是和一般函数作用域链的最大区别

所以这时候可以回顾我们一开始提到的结论:之所以propertyName会被保存在内存中,返回的匿名函数的作用域链包含createComparisonFunction函数的作用域

createComparisonFunction函数执行完毕后,其上下文的作用域链会销毁,但是由于其活动对象被匿名函数的作用域链所引用,而该匿名函数又被全局变量result所引用,这种相互依赖关系导致createComparisonFunction函数的活动对象保留在内存中

要想让该活动对象被回收,避免内存泄漏,需要手动让result = null,这样匿名函数就失去了位于全局上下文的引用,从而让垃圾回收程序将其内存释放掉。

闭包和垃圾回收机制

上文中反复提到JS的垃圾回收机制,那么垃圾回收机制是怎样运行的?如何判断某块内存是否应该被回收呢?下面简单介绍一下JS的垃圾回收机制。

像C语言这样的底层语言一般都有底层的内存管理接口,比如 malloc()free(),申请内存和垃圾回收都是需要手动完成的。相反,JavaScript是在创建变量(对象,字符串等)时自动进行了分配内存,并且在不使用它们时自动释放。

JS中最常用的垃圾回收策略是“标记清理”。垃圾回收程序运行时,会标记内存中的所有变量(表示待删除),如果有在上下文中的变量,或者被在上下文中的变量所引用的变量,则去掉其标记。随后垃圾回收程序清理掉所有带标记的变量,回收内存。

回到闭包上来,很显然垃圾回收程序会将比包中保存的局部变量的标记去掉,也就不会被回收了。

闭包的使用场景

其实在很多地方你肯定见到过闭包,举几个常见的例子。

防抖节流函数

常见的防抖函数如下:

function debounce(fn, wait) {
  let timer = null // 局部变量

  return function() { // 引用了局部变量的函数
    let context = this,
        args = arguments

    if (timer) {
      clearTimeout(timer)
    }
    
    timer = setTimeout(() => {
      fn.apply(context, args)
    }, wait)
  }
}

判别闭包最快的方式就是找到两个要素:局部变量以及引用该局部变量的函数
计时器timer,并不会随着函数debounce调用结束被回收,而是一直位于内存中。
同理,再来看一下节流函数

function throttle(fn, wait) {
  let prev = 0 // 局部变量

  return function() { // 引用了局部变量的函数
    let context = this,
        args = arguments,
        now = Date.now()

    if (now - prev > wait) {
      fn.apply(context, args)
      prev = now
    }
  }
}

上方节流函数中局部变量prev和返回的匿名函数就构成一个闭包。

setTimeout

setTimeout的第一个参数可以是一段可执行的JS代码,也可以是一个函数的引用。如下:

function fn(name){ // 那么是局部变量
  return function(){ // 该匿名函数引用了该局部变量
       console.log(name)
  }
}

let getName = fn('Joe')
setTimeout(getName, 1000)

由于setTimeout中第一个参数传的是函数的引用,所以不能带参数。那么就可以通过定义一个中间函数fn,将本来传给getName的参数传给fn,并让fn返回一个函数作为setTimeout的第一个参数。
在函数fn中,形参name和返回的匿名函数就构成了闭包

Vue源码中的 defineReactive 函数

在学习Vue2响应式原理时你肯定见过defineReactive 函数,其内部就是利用了Object.defineProperty实现了响应式。

// 省略了闭包之外的相关逻辑
function defineReactive(obj, key, value) {
    return Object.defineProperty(obj, key, {
        get() {
            return value;
        },
        set(newVal) {
            value = newVal;
        }
    })
}

局部变量obj, key, value会被保存在内存中。

闭包的面试题

闭包作为前端面试的必考题,屡试不爽。所以遇到这种题目,一是要快速识别出闭包的存在,二是要清除局部变量在内存中的变化。

循环打印数字

如何修改下方代码,使其输出:1 2 3 4 5

for (var i = 1; i <= 5; i++) { 
  setTimeout(function timer() {
    console.log(i) //实际输出5 5 5 5 5
  }, i * 1000)
}

首先弄清楚为什么会输出5 5 5 5 5。这涉及到JS事件循环的知识。

  • 首先,计时器的回调属于宏任务,当代码执行到setTimeout时,其回调会被放入宏任务队列,循环5次,所以宏任务队列中一共有5个回调。而异步队列中的任务需要等待主栈代码运行完才会执行,也就是说这5个回调需要等待for循环执行完毕才会执行;
  • 其次,for循环中的变量i是用var声明的,所以i是一个全局变量。当for循环执行完后,位于全局的i就变成了5。此时才会从异步队列中取出回调,依次执行,所以连续打印的都是5。

该怎么解决呢?

  • 方法一:将var改为let
for (let i = 1; i <= 5; i++) {
    setTimeout(function timer() {
        console.log(i)
    }, i * 1000)
}

实际上,let会形成块级作用域,使得i作为局部变量,只位于for循环内部。所以每一次循环中,局部变量i和函数timer就形成一个闭包,一共是5个闭包。这样,每次循环的i都会被保存下来。

  • 方法二:使用立即执行函数
for (var i = 1; i <= 5; i++) {
    (function(j) { //j是形参
        setTimeout(function timer() { //函数timer和j构成一个闭包
      console.log(j) 
    }, j * 1000) 
    })(i) //i是实参
}

其原理依旧是利用闭包。使用立即执行函数的目的就是:让其在声明完之后就立即执行,且其内部变量不会干扰到外部。每一次的循环中,变量i通过传参的方式传给该立即执行函数的形参j,所以此时局部变量j和函数timer就形成了闭包,变量j得以保存在内存中。

求运行结果

例题一

求下方代码打印结果:

var test = (function (i) {
    return function () {
        console.log(i *= 2)
    }
})(2)
test(5)

答案是:4。

首先,闭包是由局部变量i和内部的匿名函数构成,且在立即执行函数内部。实参2赋给了形参i,所以i = 2被保存在内存中。而当调用匿名函数时,即test(5),参数5不起任何作用,也就是传不传参都一样。

拓展一下,当连续执行两次test()时,打印结果是什么?

打印:4 8

例题二

求下方代码打印结果:

var a = 0, b = 0
function A(a) {
    A = function (b) {
        console.log(a + b++)
    }
    console.log(a++)
}
A(1) 
A(2)

答案是:1 4。

这道题有点绕,因为在A函数内部改变了A的指向。

function A(a){
    // a 和 function(b)构成闭包
    A = function(b){
        console.log(a + b++)
    }
    // a的值变成2,并作为闭包的一部分被保存
    console.log(a++)
}
A(1) 
A(2)

观察A函数,局部变量a和函数function(b){}构成一个闭包,但是并没有返回函数function(b){}啊,如何让局部变量保存在内存中呢?

执行A(1)时,A函数中的语句A = function(b){}改变了指针A的指向,让其指向了函数function(b){},所以函数function(b){}就有了一个位于全局的引用。这就使得局部变量a保存在了内存中。此时先打印:1,然后a自增,内存中的a === 2

执行A(2)时,A已被重写,A = function(b){ console.log(a + b++) },且a === 2,形参b为传进来的参数2,所以打印:4。

复习一下:

console.log(a++)是先打印,后自增;
console.log(++a)是先自增,后打印;

参考链接


前端咸鱼
1 声望0 粉丝

一条渴望艺术与技术兼得的咸鱼。。。