Categories
程式開發

你以为你真的理解 Closure 吗


闭包(closure),作为前端面试中老生常谈的话题,经久不衰。今天我们就一起来深入理解一下闭包吧!

要理解闭包,首先得理解作用域链。那我们就从作用域开始咯。

作用域(scope)

作用域就是变量或者函数的可访问范围。JavaScript 中有全局作用域、函数作用域以及 ES6 中增加的块级作用域。我们看一下下面的代码:

function bar() {
console.log(name);
}

function foo() {
var name = 'foo';
bar();
}

var name = 'global';

foo();

相信你已经有自己的答案了,这里打印的是 ‘global’;当执行到 bar 函数内部的时候,调用栈的状态如下图:

你以为你真的理解 Closure 吗 1

variable enviroment:变量环境,当声明变量时使用。var 声明的变量(能穿透 if、for 语句)会被存入变量环境。

lexical enviroment:词法环境,当获取变量或者 this 值时使用。let、const 声明的变量会被存入词法环境。函数内部声明的变量与函数内部块声明的变量,存放在不同的内存,可以理解为词法环境也是一个栈型结构。为了方便理解,请参照下面的代码:

function fn() {
var a = 1;
let b = 2;
{
var c = 3;
let b = 4;
let d = 5;
console.log('inner', a, b, c, d); // inner, 1,4,3,5
}
console.log('outer', a, b, c); // outer, 1,2,3
}
fn();

fn 执行时,调用栈情况如下图:

你以为你真的理解 Closure 吗 2

好了,回到我们上面的问题,不知道你有没有疑惑过,bar 函数是在 foo 函数里面调用的,为什么不是打印 ‘foo’ 呢?其实,问题的关键就在于,执行到 bar 函数内部时,是找全局作用域的 name,还是 foo 函数作用域的 name。那要解释清楚这个问题,首先要理解作用域链。

作用域链

每个执行上下文的变量环境中,都包含一个外部引用,指向外部的执行上下文,我们把这个外部引用称为 outer。

当 JavaScript 引擎需要使用一个变量时,会首先在当前环境(即当前执行上下文)去找,如果找不到,会去找 outer 指向的外部执行上下文。当然,如果找到 global 也找不到,那就是 undefined 了。为了方便理解,请参照下图:

你以为你真的理解 Closure 吗 3

至此,我们可以给作用域链下一个定义了:

JavaScript 引擎通过 outer 查找变量的这个链条就被称为 作用域链。

看到这里,你是否会有第二个疑问呢?bar 函数是在 foo 函数内部调用的,为什么 bar 函数的 outer 指向的是全局执行上下文,而不是 foo 函数执行上下文呢?这里就涉及到 词法作用域 的知识了。在 JavaScript 执行过程中,其作用域链是由词法作用域决定的。下面,就来了解一下词法作用域。

词法作用域

词法作用域 就是指作用域是由代码中函数声明的位置来决定的,所以词法作用域是静态的作用域,通过它就能够预测代码在执行过程中如何查找标识符。

那一开始那段代码,它的词法作用域链就是这样的:

foo 函数作用域 -> 全局作用域

bar 函数作用域 -> 全局作用域

根据词法作用域,bar 函数,foo 函数的上级作用域都是全局作用域。所以,当访问 bar 函数内部没有的变量时,就会去全局作用域查找。这也就解释了为什么 bar 函数的 outer 指向的是全局执行上下文而不是 foo 函数执行上下文。

最后,词法作用域是在词法分析阶段就已经确定了,与函数调用没有什么关系。换言之,词法作用域只关心变量、函数声明定义的位置。而动态作用域才关心函数是何处调用的,即函数调用是由运行时(runtime)确定的。

有了这些前置知识之后,我们再来聊一聊 闭包。

闭包(closure)

闭包在 JavaScript 中可以说是史诗级的存在,但在 JavaScript 标准https://www.ecma-international.org/ecma-262/11.0/index.html” 中却找不到有关闭包的定义。这个事情也是很神奇。让我们结合下面这段代码来理解闭包吧!

var name = 'global';
function foo() {
var name = 'foo';
var bar = function() {
console.log(name);
}
return bar;
}
var f = foo();
f();

想必聪明的你已经知道答案了,这里会打印 ‘foo’;不知你是否会有同样的疑问,foo 函数执行完成后就被销毁了呀,那么,是怎么访问到 foo 函数内部的 name 的呢?那我们一起来分析一下调用栈的情况:

你以为你真的理解 Closure 吗 4

当 foo 函数执行完后,bar 函数返回到外部被 f 函数保存,f 函数执行时调用栈的情况如下:

你以为你真的理解 Closure 吗 5

JavaScript 中,根据词法作用域,内部函数总是可以访问外部函数的变量的。当 bar 函数被返回到外部时,即使内部函数已经执行完毕,但内部函数引用外部函数的变量仍然保存在内存中,我们就将这些变量的集合称之为闭包。我们可以在 Chrome devtools 看到闭包的情况,你也可以自己动手去尝试一下。

你以为你真的理解 Closure 吗 6

好了,弄清楚闭包是如何产生的之后,那闭包有哪些用途呢?

可以访问内部函数的变量让变量始终保存在内存

闭包使用不当会导致内存泄漏,所以,在实际开发中正确的使用闭包尤为重要。在使用闭包时,应该时刻记住,如果该闭包不是一直使用,且占用内存又比较大,那么应该设计成局部变量持有的闭包。这样, 在函数执行完毕销毁后,JavaScript 引擎会在下次垃圾回收时判断闭包是否已经不再使用,JavaScript 引擎就会回收这块内存。

好了,介绍到这里,想必你对闭包已经有更深刻的理解了。如果觉得有帮助,或者想帮助到更多的人,欢迎点赞分享~

参考

极客时间 · 浏览器工作原理与实践专栏https://time.geekbang.org/column/article/127495

极客时间 · 重学前端专栏https://time.geekbang.org/column/article/83302

winter · 如何写技术文章方法论