JavaScript高级深入浅出:关于闭包的一切

867 阅读7分钟

介绍

本文是 JavaScript 高级深入浅出系列的第五篇,详细介绍了 JS 中的闭包相关知识点

正文

1. JS 中函数是一等公民

与 Java 等面向对象的语言不同,在 JS 中函数是一等公民,函数的使用是非常灵活的,并且函数可以作为参数甚至返回值来使用。高阶函数:把一个函数作为参数或者作为返回值,那么此函数就是一个高阶函数

function foo(fn) {
    console.log('before execute fn')
    fn()
}

function bar() {
    console.log('bar')
}

// 函数作为参数使用
foo(bar)

一个例子:

function calc(num1, num2, calcFn) {
    console.log(calcFn(num1, num2))
}

function add(num1, num2) {
    return num1 + num2
}

function sub(num1, num2) {
    return num1 - num2
}

calc(1, 2, add)  // 3
calc(3, 1, sub)  // 2

函数作为返回值的例子:

function foo() {
    function bar() {
        console.log('bar is running')
    }
    return bar
}

let baz = foo() // 或者直接: foo()()

baz()  // bar is running
function adderFactory(count) {
    return (num) => {
        return count + num
    }
}

let add10 = adderFactory(10)

console.log(add10(5))   // 15
console.log(add10(20))  // 30

大家是不是觉得很怪异,adderFactory中的 count 的值在add10阶段就已经执行完毕,照理说已经弹出 ECStack 了,后续使用add10函数时,内部的 AO 是找不到count这个值的,但是为什么使用起来还是能接收到呢?正是因为闭包

2. 一些常用的高阶函数

数组中的高阶函数

// 筛选
let nums = [1, 5, 10, 20, 12, 25]
newNums = nums.filter(item => item % 2 === 0)
console.log(newNums)  // [ 10, 20, 12 ]
// 映射
newNums = nums.map(item => item * 10)
console.log(newNums)  // [ 10, 50, 100, 200, 120, 250 ]
// 遍历
nums.forEach(item => console.log(item))
// 查找
console.log(nums.find(item => item === 20))  // 20
// 查找索引
console.log(nums.findIndex(item => item === 20))  // 3
// 累加
console.log(nums.reduce((prev, curr) => prev + curr, 0))  // 73,如果运算结果是 NaN 返回第二个参数,这里是 0 

3. JS 中闭包的定义

在计算器科学中闭包的定义:

闭包(Closure),又称词法闭包(Lexical Closure)或函数闭包(Function Closure)

是在支持头等函数的编程语言中,实现词法绑定的一种技术

闭包在实现上是一个结构体,他存储了一个函数和一个关联的环境(相当于一个符号查找表)

闭包跟函数的最大区别在于,当捕捉闭包时,它的自由变量会在捕捉时被确定,这样即便脱离了捕捉时的上下文,它照样运行

闭包的概念最早出自于 60 年代,有 Scheme 程序所实现。这也就说通了为什么 JS 中有闭包:

  • JS 的设计大量借鉴了 Scheme

MDN 对于 JS 闭包的解释:

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

3.1 闭包在内存中的表现

function foo() {
  var name = 'foo'
  function bar() {
    console.log('bar & name is', name)
  }
  return bar
}

var fn = foo()

fn()
  1. JS 引擎内部 ECStack 创建全局执行上下文 GEC,GEC 创建 GO,GO 中包含foo: 内存引用地址0xaafn: undefined

第一步

  1. 执行代码, 遇到函数执行,开始全量解析函数,创建 FEC,创建 AO,AO中包括 name 和 bar

第二步

  1. 执行 foo 函数,为 foo 的 AO 中的 name赋值,返回bar(其实就是返回了引用地址),至此,FOO的 FEC 弹出 ECStack,fn 获得 bar 函数的引用地址 0xb00

第3步

  1. 执行 fn 函数,其实就是执行了 bar 函数,打印 name。创建 fn 的 FEC 和 AO,执行代码。检测到访问name,本 AO 中找不到 name,就根据 FEC 的父级作用域链去找上级作用域,这里找到了 foo 的 AO。为什么是 foo ? 大家一定要记得一个原则:在解析函数时(词法解析阶段)就已经获取到了此函数的作用域

在执行bar函数的时候,本应销毁的父级作用域foo的变量仍能获取到,说明:

  • foo函数体是一个闭包,闭包由两部分组成:函数+可以访问的自由变量。闭包函数 bar 中的自由变量就是 foo 函数的name。因此后面 bar 就可以访问到 name 的值
  • 简单来理解就是,JS 中的一个函数,如果有访问上层作用域的变量,那么它就是一个闭包,从狭义的角度来说,即便这个函数可以访问上层作用域的变量但是没有访问过,这个函数就不算是一个闭包

4. 闭包的内存泄漏

闭包虽然很好用,但是存在一个致命的问题:所有的自由变量都不会自动销毁,在第四篇中我们说到,程序需要使用完要主动释放内存,这样才能充分利用内存。而闭包不会自动销毁内存,就会导致内存泄漏。那么为什么自由变量不会自动销毁?

function foo() {
    var name = "foo"
    function bar() {
        console.log(name)
    }
    return bar
}
var fn = foo()
fn()
  1. 开始解析代码,执行上下文栈创建全局执行上下文,GO 中填充foo: 0x100(内存地址)fn: undefined

第一步

  1. 开始执行代码,遇到foo()开始全量解析foo函数。创建 foo 的函数执行上下文压入执行上下文栈中,创建 foo 的 AO。AO 中填充 name: undefinedbar:0x200(内存地址)

第二步

  1. 开始执行函数内的代码,将name赋值为foo,将 bar 返回(其实就是返回内存地址)
  2. foo函数执行完毕,弹出执行上下文栈,此函数执行上下文销毁。注意:此时 foo 所属的 AO 并不会销毁,因为根据标记清除原则,根对象中的 fn 属性关联了 bar 储存空间,bar 储存空间关联了 foo 的 AO

第四步

  1. fn 接收到 foo 函数返回的内存地址,将 GO 中的 fn 的值更新为 bar 的储存空间的内存地址
  2. 执行 fn 函数,找到 bar 储存空间,其实就是执行 bar 函数,创建属于 bar 的函数上下文,并创建数据 bar 的 AO

第六步

  1. 打印name,从 bar 的 AO 中找不到 name,就根据 bar 的函数执行上下文的 scope chain 找到parent scope,根据 bar 的 parent scope 找到 foo 的 AO,最终从 foo 的 AO 中找到 name ,打印"foo"
  2. fn 函数也就是 bar 函数执行完毕,弹出 ECStack,当前 bar 所属函数执行上下文销毁,bar 的 AO 因为没有引用关系也就销毁掉了

第八步

  1. fn 函数执行完毕,如果只会执行一次 fn,那么就不应该保留 bar 和 foo 的 AO。但是因为 fn 对于 bar 储存空间一直有引用关系,bar 储存空间和 foo 的 AO 就一直不被销毁,最终造成内存泄漏

4.1 解决内存泄漏

将闭包函数在使用后赋值 null,上文例子中

fn = null  // 解决闭包内存泄漏

fn 为 null 后,将不再关联 bar 的储存空间,那么就会触发 GC 机制,将 bar 储存空间和 foo 的 AO回收掉

解决内存泄漏

5. 闭包空闲变量的销毁

function foo() {
    var name = "foo"
    var age = 18
    function bar() {
        console.log(name)
    }
    return bar
}
var fn = foo()
fn()

上面示例代码中,foo 函数中声明的变量age在整体的代码中并没有被引用,虽然 ECMA 规范了这里的 age 不能销毁掉,但是 JS 引擎为了优化性能,一般情况下会将没有用到的变量删除掉,所以这里的age就会从内存中删掉

总结

本文中,你学到了 2 个知识点

什么是高级函数

在 JS 中函数是一等公民,函数可以做为函数的参数和返回值。将函数作为参数或返回值的函数叫做高级函数。同时我们也列举了一些数组中的高级函数

关于闭包

我们了解了闭包的官方定义,从第一步开始一步一步拆解闭包。同时我们也了解了为什么闭包会存在内存泄漏以及如何解决。最后我们认识到了 JS 引擎对于闭包中空闲变量的处理