用大白话🙌带你掌握闭包

·  阅读 18219
用大白话🙌带你掌握闭包

最近看了一些关于闭包的文章,到后面真正理解闭包时,发现其实大多数文章讲得还是稍微复杂了一点。

其实一开始我以为闭包是个很高大上,很高深莫测的知识点,但其实并不是的,如果光看那描述的很官方的定义以及巨长的闭包文章滚动条的话,一开始确实会被劝退😰。接下来我会尽量简易地结合例子去描述这个知识点,OK,开始吧!

开始之前,如果你对作用域、执行上下文和词法环境等概念还不清晰的话,推荐先阅读:详解JavaScript作用域和作用域链🔗、彻底搞懂作用域、执行上下文、词法环境🔎,否则这篇文章的部分描述可能对你不太友好🤔

定义

首先,先丢出个MDN里对闭包的定义:

一个函数和对其周围状态(lexical environment,词法环境)的引用捆绑在一起(或者说函数被引用包围),这样的组合就是闭包(closure)。也就是说,闭包让你可以在一个内层函数中访问到其外层函数的作用域。在 JavaScript 中,每当创建一个函数,闭包就会在函数创建的同时被创建出来。

OK,看不懂上面那坨东西没关系,先来看一个例子:

示例一:在函数中return一个函数

function makeFunc() {
    var name = "Mozilla"
    function displayName() {
        alert(name)
    }
    return displayName
}

var myFunc = makeFunc() 
myFunc()  // Mozilla
复制代码

var myFunc = makeFunc()中的makeFunc()创建了相应的函数执行上下文,在该上下文中声明了一个局部变量name 并且声明了一个函数displayname,最后返回displayname函数的指针(或引用),即把function displayName(){alert(name);} 返回给了myFunc变量

var myFunc = makeFunc()执行结束后,相应的函数执行上下文就从栈中弹出,一般情况下,其变量也会随之销毁 ,但是myFunc()调用了myFunc,即执行了function displayName(){alert(name);},这里的name引用着makeFunc里的变量name, 所以其变量并不会随着销毁,相当于封装了一个私有变量.

这就是闭包。

OK,抛开具体例子,现在用人话再描述一下闭包的概念:

闭包就是一个函数引用另一个函数内部的变量,因为变量被引用着,所以当另外一个函数执行结束,其相应的执行上下文弹出栈时, 变量并不会被回收,因此可以用来封装一个私有变量。不正当地使用闭包可能会造成内存泄漏。

更严瑾的描述:

闭包是由函数以及声明该函数的词法环境组合而成的。该词法环境包含了这个闭包创建时作用域内的任何局部变量。(同样源自MDN)

OK,再回到上面例子中,函数makeFunc及其词法环境(包含变量name和函数displayName)就称之为一个闭包。词法环境中的name变量被引用着,不会被销毁。

扩展:接下来我们看一个类似但又有点不一样的代码:

function makeAdder(x) {
  return function(y) {
    return x + y
  }
}

var add5 = makeAdder(5)
var add10 = makeAdder(10)

console.log(add5(2))  // 7
console.log(add10(2)) // 12

add5 = null          // 释放对闭包的引用 
console.log(add5(1)) //Uncaught TypeError: add5 is not a function
复制代码

在这个示例中,我们定义了 makeAdder(x) 函数,它接受一个参数 x ,并返回一个新的函数。返回的函数接受一个参数 y,并返回x+y的值。

从本质上讲,makeAdder 是一个函数工厂 — 他创建了将指定的值和它的参数相加求和的函数。在上面的示例中,我们使用函数工厂创建了两个新函数 — 一个将其参数和 5 求和,另一个和 10 求和。

上面add5 和 add10 都是闭包。它们共享相同的函数定义,但是保存了不同的词法环境(变量x)。在 add5 的环境中,x 为 5。而在 add10 中,x 则为 10。

综上,闭包其实也就是一个能够读取其他函数内部变量的函数。

function f1 () {
    var a = 10
    function f2 () {
        alert(a)
    }
    f2()
}
f1()  // 可以获取到局部变量a
复制代码

闭包可以使这些变量的值始终保存在内存中,不被销毁。

function f1 () {
    var a = 10
    function f2 () {
        a++
        console.log(a)
    }
    return f2
}
var f = f1()
f()  // 11
f()  // 12
f()  // 13
-----------------------------------------------------------
function f3 () {
    var a = 10
    a++
    console.log(a)
}
// 每次调用这个函数都会重新在内存中创建变量,执行完之后就会销毁。
f3() // 11
f3() // 11
f3() // 11
-------------------------------------------------------------
function f1 () {
    var a = 10
    return function () {
        a++
        console.log(a)
    }
}
var a1 = f1()
var a2 = f1()

// a1,a2互不干扰
a1() // 11
a1() // 12
a1() // 13

a2() //11
a2() //12

a1() //14
复制代码

示例二:立即执行函数

var add = (function () { 
    var counter = 0; 
    return function () { 
        return counter += 1; 
    } 
})(); 

add(); 
add(); 
add(); // 3
复制代码

上面的立即执行函数,最终把 function () {return counter += 1;} 返回给add变量, 执行add()时就是执行 function () {return counter += 1;} 但是它并没有声明counter这个变量,之前声明的是在立即执行函数里的,当立即执行函数执行完毕, 相应的函数执行上下文就会从调用栈中弹出,其中的变量counter就会被销毁, 但是我们能正常执行add()代码输出3,说明counter变量是没有被销毁的,这也是闭包的一个体现。

示例三:定时器打印问题

为了更好地对每一部分代码进行描述,这里将其分成三段来写:

for(var i = 0; i < 5; i++) {
    setTimeout(() => {
        console.log(i)
    }, i * 1000)
}
复制代码

上面的这段代码,预期是每隔一秒,分别输出 0, 1, 2, 3, 4, 但实际上依次输出的是 5, 5, 5, 5, 5。

上面代码中,变量i是var命令声明的,在全局范围内都有效,所以全局只有一个变量i。每一轮循环,变量i的值都会覆盖上一轮的值。

上面定时器函数的回调会在循环结束时才执行。这个循环的结束条件是 i 不再 < 5,条件首次成立时 i 的值是5,因此,输出显示的是循环结束时 i 的最终值。因此每次输出一个 5来。

for(var i = 0; i < 5; i++) {
    (function(j){
        setTimeout(() => {
            console.log(j)
        }, j * 1000)
    })(i)
}
复制代码

代码改成上面这样,就可以按照我们期望的方式进行工作了。这样修改之后,使用 IIFE(立即执行函数)会为每一轮循环都生成一个新的函数作用域,使得定时器函数的回调可以将新的作用域封闭在每一轮循环内部,每一轮循环内部都会含有一个具有正确值的变量可以访问。

上面的整个立即执行函数就是闭包,变量j就是闭包的一部分,立即执行函数执行结束时,变量j被定时器引用着。

for(let i = 0; i < 5; i++) {
    setTimeout(() => {
        console.log(i)
    }, i * 1000)
}
复制代码

使用 ES6 块级作用域的 let 替换 var 也可以达到我们的目的。

上面代码中,变量i是let声明的,当前的i在本轮循环有效,所以每一次循环的i其实都是一个新的变量,所以最后输出的是0, 1, 2, 3, 4。你可能会问,如果每一轮循环的变量i都是重新声明的,那它怎么知道上一轮循环的值,从而计算出本轮循环的值?这是因为 JavaScript 引擎内部会记住上一轮循环的值,初始化本轮的变量i时,就在上一轮循环的基础上进行计算。

扩展:接下来再来看一段类似的代码:

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

// 使用闭包
var data = []
for (var i = 0; i < 3; i++) {
  data[i] = (function (j) {
        return function () {
            console.log(j)
        }
  })(i)
}
data[0]()
data[1]()
data[2]()
// 0 1 2

// 使用let
var data = []
for (let i = 0; i < 3; i++) {
  data[i] = function () {
    console.log(i)
  }
}
data[0]()
data[1]()
data[2]()
// 0 1 2
复制代码

OK,相信各位小伙伴看到这里应该都没什么问题了。除了以上三个最经典的闭包的案列,接下来让我们再看一下还有哪些场景中也使用了闭包吧

使用闭包的其他几类场景

1、函数作为参数

var a = 'Rocky'
function foo(){
    var a = 'foo'
    function fo(){
        console.log(a)
    }
    return fo
}

function f(p){
    var a = 'f'
    p()
}
f(foo()) // foo
复制代码

先看f(foo())里面的foo(),它执行完之后,里面的变量a没有被回收,因为被fo()引用着,所以foo()与其词法环境就是一个闭包。

2、使用回调函数就是在使用闭包

window.name = 'Rocky'
setTimeout(function timeHandler(){
  console.log(window.name);
}, 100)
复制代码

3、节流与防抖

// 防抖
function debounce(fn, wait = 50) {
    let timer
    return function() {
        if(timer) clearTimeout(timer)
        timer = setTimeout(()=> {
            fn.apply(this, arguments)
        },wait)
    }
}
// 节流
function throttle(fn) {
    let canRun = true
    return function() {
        if(!canRun) return
        canRun = false
        setTimeout( () => {
            fn.apply(this, arguments)
            canRun = true
        }, 1000)
    }
}

复制代码

闭包的内存泄漏

在文章开始我们提到了:不正当地使用闭包可能会造成内存泄漏。

有些同学可能会问,使用闭包不就是会造成内存泄漏吗?为什么说是不正当地使用闭包才会造成内存泄漏呢???

先看以下两个例子:

function fn1(){
  let test = new Array(1000).fill('rocky')
  return function(){
    console.log('Rocky')
  }
}
let fn1Child = fn1()
fn1Child()
复制代码

上例是一个闭包,但是它造成内存泄漏了吗?并没有,因为返回的函数中并没有对 fn1 函数内部的引用,也就是说,函数 fn1 内部的 test 变量完全是可以被回收的,那我们再来看:

function fn2(){
  let test = new Array(1000).fill('rocky')
  return function(){
    console.log(test)
    return test
  }
}
let fn2Child = fn2()
fn2Child()
复制代码

显然它也是闭包,并且因为 return 的函数中存在函数 fn2 中的 test 变量引用,所以 test 并不会被回收,也就造成了内存泄漏。

那么怎样解决呢?

其实在函数调用后,把外部的引用关系置空就好了,如下:

function fn2(){
  let test = new Array(1000).fill('rocky')
  return function(){
    console.log(test)
    return test
  }
}
let fn2Child = fn2()
fn2Child()
fn2Child = null  // 置空
复制代码

综上,我们以后就不要再说闭包会造成内存泄漏啦!

应该说成:不正当地使用闭包可能会造成内存泄漏。

闭包的作用

OK,最后来概括一下闭包的作用(摘自JS 闭包经典使用场景和含闭包必刷题)

  • 保护函数的私有变量不受外部的干扰。形成不销毁的栈内存。
  • 保存,把一些函数内的值保存下来。闭包可以实现方法和属性的私有化

最后,如果文章有任何描述有误的地方,欢迎指出,共同讨论😇

参考

MDN文档:闭包
JS 闭包经典使用场景和含闭包必刷题
深入理解JavaScript闭包之什么是闭包
「硬核JS」你的程序中可能存在内存泄漏

推荐文章

详解JavaScript作用域和作用域链🔗
彻底搞懂作用域、执行上下文、词法环境🔎
精选30+案例与10道题目带你彻底掌握this👊
一文总结Promise/async/await✨【精选输出题与手写题】
图文并茂🌈聊聊原型与原型链

分类:
前端
标签:
收藏成功!
已添加到「」, 点击更改