JavaScript 作用域链与闭包:从概念到图解

0 阅读8分钟

前言

作用域链和闭包是 JavaScript 中两个紧密相关的知识点:作用域链决定变量的查找顺序,闭包则让函数离开原来的执行环境后,仍然可以访问定义时的外部变量。

学习这两个概念时,只看定义往往比较抽象,所以本文结合调用栈和执行上下文图一次性帮你搞懂这两个概念。

一、作用域链

1. 什么是作用域链

每个执行上下文的环境中都有一个 outer,用来指向外层词法环境。查找变量时,如果在当前环境中没有找到,就会沿着 outer 继续向外查找,直到全局环境。这个查找链条就是作用域链。

可以将查找过程概括为:

当前环境 → 外层环境 → 更外层环境 → 全局环境

作用域链解决的是“变量应该去哪里找”的问题。

2. 结合图1理解作用域链

图1.png 最终输出:1

图中的执行上下文

图右侧是调用栈。程序先执行全局代码,再由全局调用 foo(),最后由 foo() 调用 bar(),因此调用栈从下到上是:

bar 的执行上下文
foo 的执行上下文
全局执行上下文

每个执行上下文中中又分成了变量环境和词法环境。可以先简单理解为:

  • var 具有函数作用域,主要记录在变量环境中;
  • letconst 具有块级作用域,主要记录在词法环境中;
  • outer 负责把当前环境和外层环境连接起来。

为了理解块级作用域,可以把词法环境看作一个栈:从上往下访问

进入 foo 后,函数作用域中先有 test = 2;进入内部的 {} 后,新的块级环境被压到上面,其中保存 test = 3。查找变量总是从最内层开始,因此内层的 test 会遮蔽 foo 函数作用域中的 test = 2

同理,barif 块中有 myName = '柚',它会遮蔽 bar 函数作用域中的 myName = 'yuzumi'。如果在这个代码块中打印 myName,结果会是 '柚'

为什么 test 最终是 1

图1最重要的地方,是区分调用栈和作用域链:

  • 调用栈表示“谁调用了谁”:全局 → foobar
  • 作用域链表示“变量去哪里找”:bar 自身 → 全局。

bar 虽然在 foo 中被调用,却是在全局作用域中定义的。JavaScript 使用词法作用域,函数的外层环境由定义位置决定,而不是由调用位置决定。因此,barouter 指向全局环境,不会指向 foo

执行 console.log(test) 时,查找过程是:

bar 的 if 块:没有 test
        ↓
bar 的函数环境:只有 myName 和 test1,没有 test
        ↓ outer
全局环境:找到 test = 1

foo 中的 test = 3test = 2 虽然都还存在于当前执行过程中,却不在 bar 的作用域链上,所以 bar 无法访问它们。

3. 结论

作用域链由一个个 outer 连接而成。变量查找从当前环境开始,找不到就逐层向外,直到全局环境。函数的 outer 由定义位置决定,不能把调用栈的顺序误当成作用域链的顺序。

二、闭包

1. 闭包的概念

理解闭包需要先明确两个事实:

  1. 函数执行结束后,它的执行上下文会退出调用栈。
  2. 内部函数可以沿作用域链访问外部函数中的变量。当内部函数被带到外部继续使用时,即使外部函数已经执行完毕,被内部函数引用的变量仍然需要保留。

闭包(Closure)是函数与其定义时所处词法环境的组合。

可以把 Closure 想象成函数随身携带的一个“小背包”:里面装着函数之后还需要访问的外层变量。函数即使离开原来的作用域,仍然可以打开这个背包,读取或修改其中的数据。

2. 结合图2理解闭包

图2.png

堆、函数对象与 baz 中的引用

调用 foo() 时,会创建内部函数 bar。从常用的内存模型来看,函数也是对象,函数对象存放在中。图2左下角的 #001 可以理解为堆中 bar 函数对象的引用地址。

return bar 返回的不是函数的执行结果,而是这个函数对象的引用。随后,该引用被赋给全局变量 baz

baz 中保存的引用地址 #001
            ↓
堆中的 bar 函数对象
            ↓ [[Environment]]
Closure“小背包”:myName = 'yuzumi'

因此,baz 中没有存放一份完整的函数代码,它保存的是指向堆中函数对象的引用。#001 只是便于理解的标记,JavaScript 代码不能直接读取真实的内存地址。

函数对象还会通过内部的 [[Environment]] 记录自己定义时的外层词法环境。正是这条关联,让 bar 能够继续访问 Closure 小背包中的 myName

foo 退栈后,为什么还能访问 myName

foo() 执行完毕后,它的执行上下文会退出调用栈,这就是图中红叉表达的含义。但全局变量 baz 仍然引用堆中的 bar 函数对象,bar 又需要访问 myName,所以相关的 Closure 仍然可达,不能被垃圾回收。

图中 Closure 里只有 myName,没有 age,是因为 bar 使用了 myName,却没有使用 age。具体保存方式属于 JavaScript 引擎的实现细节,但后续可能访问的外层变量必须继续有效。

执行 baz() 时,实际调用的是堆中的 barbar 当前的环境中没有 myName,于是通过 [[Environment]] 访问 Closure 小背包,最终找到:

myName = 'yuzumi'

所以 baz() 输出 yuzumi

3. 结论

外层函数执行结束,只表示它的执行上下文退出调用栈,并不意味着内部函数需要的变量也会立即消失。只要 baz 仍然引用堆中的函数对象,函数就可以继续访问它携带的 Closure。

闭包可以保存状态、封装私有数据和减少全局变量污染。不过,闭包会延长相关数据的生命周期;如果不再需要的闭包仍被长期引用,就可能造成额外内存占用甚至内存泄漏。

三、闭包的经典应用

面试官把下面这段代码推到你面前:“先别运行,说说它会输出什么?”

var arr = []

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

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

很简单答案是五个 6。原因是 var 没有块级作用域,数组中的五个函数引用的是同一个 i。等这些函数真正执行时,循环已经结束,i 已经变成了 6

闭包保存的是对变量的访问关系,并不会自动复制函数创建时的变量值。

这时面试官继续追问:“如果我想让它依次输出 1、2、3、4、5,你有几种修改方式?”

别急着只回答“把 var 换成 let”。这里至少有两种改法,而且正好可以检验你是否真正理解了闭包。

解法一:用辅助函数,手动创建五个闭包

第一种思路是给每轮循环创建一个独立的变量环境:

var arr = []

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

// for循环调用数组中每一个函数
for(let k = 0; k < arr.length; k++){
    arr[k]()
}

每次调用 fn(i),都会创建一个新的形参 jfn 中的匿名函数使用了这个 j,因此会形成一个 Closure,并保存对本次 j 的访问能力:

第 1 个函数 → Closure:j = 1
第 2 个函数 → Closure:j = 2
第 3 个函数 → Closure:j = 3
第 4 个函数 → Closure:j = 4
第 5 个函数 → Closure:j = 5

fn(i) 执行结束后,它的执行上下文虽然退出了调用栈,但匿名函数已经被 arr 引用,所以堆中的函数对象和相应的 Closure 仍然可以被访问。最后依次调用这些函数,就会输出 1、2、3、4、5

解法二:把 var 改成 let

在现代 JavaScript 中,更简单的解法是把循环中的 var i 改成 let i

var arr = []

for (let i = 1; i <= 5; i++) {
  arr.push(function() {
    console.log(i)
  })
}

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

for 循环使用 let 声明变量时,每次迭代都会创建一个新的词法绑定。五个函数引用的因此不是同一个 i,而是本轮迭代各自的 i

第 1 个函数 → i = 1
第 2 个函数 → i = 2
第 3 个函数 → i = 3
第 4 个函数 → i = 4
第 5 个函数 → i = 5

所以依次调用数组中的函数时,同样会输出 1、2、3、4、5

如果面试官继续问两种方法有什么区别,可以这样回答:辅助函数是我们主动创建五次函数调用,让五个闭包分别保存不同的 jlet 则由 JavaScript 为每轮迭代自动创建新的词法绑定。前者更能展示闭包原理,后者更加简洁,也是实际开发中更常用的写法。