前端绕不开的难题 | 初识JavaScript闭包

0 阅读5分钟

先打个补丁

要理解JS闭包,你首先要知道作用域链与词法作用域。

作用域链

  • 每一个执行上下文的变量环境中都存在一个 outer 指针,用来指向外部的上下文。
  • 当 V8 在查找一个变量时,在当前执行上下文中没有找到,就会顺着 outer 所指向的那个执行上下文查找,以此类推,直到找到全局为止

词法作用域

这个函数声明在哪,其函数词法声明作用域就在那。词法环境这个空间也可以看作为一个栈

作用域链与词法作用域链实战

来一段代码加深一下印象

function bar(){
    console.log(myName);
}
function foo(){
    var myName = 'x'
    bar()
}
var myName = 'y'
foo()

输出 y,我们来梳理一下这个过程。

  • 运行代码之前,首先要预编译。这个过程中会创建一个全局执行上下文。
  • myName 声明,值从 undifined 后被统一为 you,存放在变量环境中;foo()bar()声明,值为 function(),存放在词法环境中
  • 执行。要调用 foo(),因此产生一个 foo()的执行上下文
  • bar 函数、foo 函数都定义在全局作用域中,因此其词法父级都是全局作用域
  • 所以 bar()输出的 myName 不是从 foo()中得到,而是在其词法父级 全局作用域中得到 y

image.png

要点在于函数声明的位置,其词法外级是什么。全局作用域的词法父级是null

闭包

先给总结:

  1. 一个函数执行完毕后,它的执行上下文会被销毁
  2. 根据作用域的查找规则,内部函数一定可以访问外部函数中的变量。
  3. 当一个外部函数中的内部函数被拿到外部函数之外来执行,哪怕外部函数执行完毕了,被内部函数引用的那部分变量依然需要保留,我们把这部分变量的集合称之为闭包

闭包 = 函数 + 它引用的外部变量的集合

从经典的例子入手

来看一段代码

function foo(){
    var myName = 'zhang'
    var age = 18
    function bar(){
        console.log(myName)
    }
    return bar
}
var baz = foo()
baz()

输出为zhang 这段代码又做了什么?

  1. foo()执行,内部声明了 myName 、 age 、和内部函数 bar
  2. foo()把 bar 返回了出去,赋值给全局变量 baz
  3. foo()执行完毕,它的执行上下文被销毁
  4. 调用 baz(),却输出了 myName 的值

这是怎么回事?这就是上面说的闭包:虽然foo()执行完了,其执行上下文应当被销毁,但 bar 函数引用了其中的一个变量 myName,所以 myName 被保留了下来

闭包怎样形成

  1. 存在函数嵌套(一个函数里面定义了另一个函数)
  2. 内部函数引用了外部函数的变量
  3. 内部函数被带到了外部执行(例如 return 出去、 push 到数组、作为回调)

闭包的三种写法

函数return函数

例如

function createCounter(){
    let count = 0
    return function(){
        count++
        return count
    }
}

const counter = createCounter()
console.log(counter())  
console.log(counter())  
console.log(counter())  

你看这段代码。分别输出了三次 count,不是直接输出,而是将函数的值赋给了一个变量,再调用这个变量,相当于直接调用这个函数的内部函数

每次调用的 counter()操作的都是同一个 count,闭包将其保留下来了

自然会输出

1
2
3

函数作为参数传递

function fn(){
    let msg = 'Hello Closure'
    setTimeout(function(){
        console.log(msg)  
    }, 1000)
}
fn()  

1 秒后输出 Hello Closure

setTimeout 的回调函数被带到了外部执行,但它还可以访问 fn 内部的 msg

经典的面试题,循环中的闭包

var arr = []
for(var i = 1;i<= 5; i++){
    function fn(j){
        arr.push(function(){
        console.log(j);
    })
    }
    fn(i)

    
}

for(let n = 0;n< arr.length;n++){
    arr[n]()
}

面试官想让我用闭包输出1-5 五个数字。如果直接输出。像这样:

var arr = []
for(var i = 1; i <= 5; i++){
    arr.push(function(){
        console.log(i)
    })
}
for(let n = 0; n < arr.length; n++){
    arr[n]()
}

会输出什么?1-5?还是5个5?

是6个6。

  • var i是函数作用域,5次循环共用同一个 i
  • 循环结束时,i = 6
  • 所有的函数引用的都是同一个 i,因此全部输出 6

回到示例的代码,注意看我们采用函数嵌套函数的写法

function fn(j){
        arr.push(function(){
        console.log(j);
    })

这样一来形成了闭包,每次调用都是重新调用内层的 function()函数

function()函数是无名函数,这里不展开讲

当然,你可以用最简单直接的办法,不用古老的 var,for循环内换成let i即可。

注意一个坑

var bar = add()
for(let i = 0; i < 3; i++){
    console.log(bar())   
}

这是同一个闭包,count累加,会输出 1,2,3

for(let i = 0; i < 3; i++){
    console.log(add()()) 
}

而这是每次新建闭包,count充值为0,会输出 1,,1,1

那如果我想用 add()()实现累加,怎么办?可以把 count提到外面:

let count = 0
function add(){
    return function(){
        count++
        return count
    }
}
for(let i = 0; i < 3; i++){
    console.log(add()())  
}

闭包的优缺点

闭包的有点

  1. 定义私有变量,外部无法直接访问闭包内部的变量,只能通过暴露的接口操作
  2. 防止全局变量污染,变量限定在函数内部,不会污染全局命名空间
  3. 实现模块化,将相关功能封装在闭包中,只暴露必要接口。

闭包的缺点

内存泄漏:

正常情况下,一个函数执行完毕后,其执行上下文会被销毁,内部变量被回收,但闭包中的变量因为仍然被引用而无法被销毁,一直占用内存

如果闭包被全局变量引用,那这块内存将永远无法释放

总结

一句话记住闭包

一个函数记住了它外面的变量,即使外面函数执行完了,这些变量依旧存在

闭包的三个必要条件

  1. 外层函数嵌套内存函数
  2. 内层函数引用外层变量
  3. 把内层函数拿出去

###注意事项

  • 非必要不写闭包,防止爆栈、不必要的内存占用
  • 优先使用let,可以避免很多麻烦
  • 理解词法作用域是理解闭包的关键,函数的作用域看定义位置,不看调用位置