作用域链和闭包 :代码中出现相同的变量,JavaScript引擎是如何选择的?
作用域链和闭包 :代码中出现相同的变量,JavaScript 引擎是如何选择的?
ES6 通过变量环境和词法环境来同时支持变量提升和块级作用域,但是如何通过词法环境和变量环境来查找变量,这其中就涉及到作用域链的概念。
理解作用域链是理解闭包的基础,而闭包在 JavaScript 中几乎无处不在,同时作用域和作用域链还是所有编程语言的基础。
什么是作用域链,并通过作用域链再来了解什么是闭包。
首先来看下面这段代码:
function bar() {
console.log(myName);
}
function foo() {
var myName = " 张祺龙 ";
bar();
}
var myName = " Q7Long ";
foo();
你觉得这段代码中的 bar 函数和 foo 函数打印出来的内容是什么?这就要分析下这两段代码的执行流程。
那么就需要我们通过执行上下文来分析代码的执行流程了。当这段代码执行到 bar 函数内部时,其调用栈的状态图如下所示:
从图中可以看出,全局执行上下文和 foo 函数的执行上下文中都包含变量 myName
,那 bar 函数里面 myName
的值到底该选择哪个呢?
也许好多人的第一反应是按照调用栈的顺序来查找变量,查找方式如下:
- 先查找栈顶是否存在
myName
变量,但是这里没有,所以接着往下查找 foo 函数中的变量。 - 在 foo 函数中查找到了
myName
变量,这时候就使用 foo 函数中的myName
。
如果按照这种方式来查找变量,那么最终执行 bar 函数打印出来的结果就应该是 “张祺龙”
。但实际情况并非如此,如果你试着执行上述代码,你会发现打印出来的结果是 “Q7Long”
。
作用域链
其实在每个执行上下文的变量环境中,都包含了一个外部引用,用来指向外部的执行上下文,我们把这个外部引用称为**outer
**。
当一段代码使用了一个变量时,JavaScript 引擎首先会在“当前的执行上下文”中查找该变量,比如上面那段代码在查找 myName
变量时,如果在当前的变量环境中没有查找到,那么 JavaScript 引擎会继续在 outer
所指向的执行上下文中查找。为了直观理解,可以看下面这张图:
从图中可以看出,bar 函数和 foo 函数的 outer
都是指向全局上下文的,这也就意味着如果在 bar 函数或者 foo 函数中使用了外部变量,那么 JavaScript 引擎会去全局执行上下文中查找。我们把这个查找的链条就称为作用域链。
现在知道了变量是通过作用域链来查找的了,不过还有一个疑问没有解开,foo 函数调用的 bar 函数,那为什么 bar 函数的外部引用是全局执行上下文,而不是 foo 函数的执行上下文?
这就需要知道什么是词法作用域。这是因为在 JavaScript 执行过程中,其作用域链是由词法作用域决定的。
词法作用域
词法作用域就是指作用域是由代码中函数声明的位置来决定的,所以词法作用域是静态的作用域,通过它就能够预测代码在执行过程中如何查找标识符。
从图中可以看出,词法作用域就是根据代码的位置来决定的,其中 main 函数包含了 bar 函数,bar 函数中包含了 foo 函数,因为 JavaScript 作用域链是由词法作用域决定的,所以整个词法作用域链的顺序是:foo 函数作用域—>bar 函数作用域—>main 函数作用域—> 全局作用域。
了解了词法作用域以及 JavaScript 中的作用域链,再回过头来看看上面的那个问题:在开头那段代码中,foo 函数调用了 bar 函数,那为什么 bar 函数的外部引用是全局执行上下文,而不是 foo 函数的执行上下文?
这是因为根据词法作用域,foo 和 bar 的上级作用域都是全局作用域,所以如果 foo 或者 bar 函数使用了一个它们没有定义的变量,那么它们会到全局作用域去查找。也就是说,词法作用域是代码阶段就决定好的,和函数是怎么调用的没有关系。
块级作用域中的变量查找
通过全局作用域和函数级作用域来分析了作用域链,再来看看块级作用域中变量是如何查找的?在编写代码的时候,如果你使用了一个在当前作用域中不存在的变量,这时 JavaScript 引擎就需要按照作用域链在其他作用域中查找该变量。
先看下面这段代码:
function bar() {
var myName = " zql ";
let test1 = 100;
if (1) {
let myName = "Chrome 浏览器 ";
console.log(test);
}
}
function foo() {
var myName = " 张祺龙 ";
let test = 2;
{
let test = 3;
bar();
}
}
var myName = " Q7Long ";
let myAge = 10;
let test = 1;
foo();
要想得出其执行结果,就得站在作用域链和词法环境的角度来分析下其执行过程。
ES6 是支持块级作用域的,当执行到代码块时,如果代码块中有 let
或者 const
声明的变量,那么变量就会存放到该函数的词法环境中。对于上面这段代码,当执行到 bar 函数内部的 if 语句块时,其调用栈的情况如下图所示:
最后输出结果是 1
现在是执行到 bar 函数的 if 语块之内,需要打印出来变量 test
,那么就需要查找到 test 变量的值,其查找过程已经在上图中使用序号 1、2、3、4、5 标记出来了。
下面就来解释下这个过程。
首先是在 bar 函数的执行上下文中查找,但因为 bar 函数的执行上下文中没有定义 test
变量,所以根据词法作用域的规则,下一步就在 bar 函数的外部作用域中查找,也就是全局作用域。
闭包
关于闭包,理解起来可能会是一道坎,特别是在不太熟悉 JavaScript 这门语言的时候,接触闭包很可能会让我们产生一些挫败感,因为你很难通过理解背后的原理来彻底理解闭包,从而导致学习过程中似乎总是似懂非懂。最要命的是,JavaScript 代码中还总是充斥着大量的闭包代码。
但理解了变量环境、词法环境和作用域链等概念,那接下来再理解什么是 JavaScript 中的闭包就容易多了。这里可以结合下面这段代码来理解什么是闭包:
function foo() {
var myName = " Q7Long ";
let test1 = 1;
const test2 = 2;
var innerBar = {
getName: function () {
console.log(test1);
return myName;
},
setName: function (newName) {
myName = newName;
},
};
return innerBar;
}
var bar = foo();
bar.setName(" 张祺龙 ");
bar.getName();
console.log(bar.getName());
当执行到 foo 函数内部的 return innerBar
这行代码时调用栈的情况,可以参考下图:
从上面的代码可以看出,innerBar
是一个对象,包含了 getName
和 setName
的两个方法(通常我们把对象内部的函数称为方法)。这两个方法都是在 foo 函数内部定义的,并且这两个方法内部都使用了 myName
和 test1
两个变量。
根据词法作用域的规则,内部函数 getName
和 setName
总是可以访问它们的外部函数 foo 中的变量,所以当 innerBar
对象返回给全局变量 bar 时,虽然 foo 函数已经执行结束,但是 getName
和 setName
函数依然可以使用 foo 函数中的变量 myName
和 test1
。所以当 foo 函数执行完成之后,其整个调用栈的状态如下图所示:
从上图可以看出,foo 函数执行完成之后,其执行上下文从栈顶弹出了,但是由于返回的 setName
和 getName
方法中使用了 foo 函数内部的变量 myName
和 test1
,所以这两个变量依然保存在内存中。
这像极了 setName
和 getName
方法背的一个专属背包,无论在哪里调用了 setName
和 getName
方法,它们都会背着这个 foo 函数的专属背包。
之所以是专属背包,是因为除了 setName
和 getName
函数之外,其他任何地方都是无法访问该背包的,我们就可以把这个背包称为 foo 函数的闭包。
闭包的定义
在 JavaScript 中,根据词法作用域的规则,内部函数总是可以访问其外部函数中声明的变量,当通过调用一个外部函数返回一个内部函数后,即使该外部函数已经执行结束了,但是内部函数引用外部函数的变量依然保存在内存中,我们就把这些变量的集合称为闭包。比如外部函数是 foo,那么这些变量的集合就称为 foo 函数的闭包。
那这些闭包是如何使用的呢?
当执行到 bar.setName
方法中的 myName = "张祺龙"
这句代码时,JavaScript 引擎会沿着 当前执行上下文–>foo 函数闭包–> 全局执行上下文
的顺序来查找 myName
变量,你可以参考下面的调用栈状态图:
从图中可以看出,setName
的执行上下文中没有 myName
变量,foo 函数的闭包中包含了变量 myName
,所以调用 setName
时,会修改 foo 闭包中的 myName
变量的值。
同样的流程,当调用 bar.getName
的时候,所访问的变量 myName
也是位于 foo 函数闭包中的。
可以通过“开发者工具”来看看闭包的情况,打开 Chrome 的“开发者工具”,在 bar 函数任意地方打上断点,然后刷新页面,可以看到如下内容:
从图中可以看出来,当调用 bar.getName
的时候,右边 Scope
项就体现出了作用域链的情况:Local
就是当前的 getName
函数的作用域,Closure(foo)
是指 foo 函数的闭包,最下面的 Global
就是指全局作用域,从 “Local–>Closure(foo)–>Global”
就是一个完整的作用域链。
可以通过 Scope 来查看实际代码作用域链的情况
闭包是怎么回收的
如果闭包使用不正确,会很容易造成内存泄漏的,关注闭包是如何回收的能让你正确地使用闭包。
通常,如果引用闭包的函数是一个全局变量,那么闭包会一直存在直到页面关闭;但如果这个闭包以后不再使用的话,就会造成内存泄漏。
如果引用闭包的函数是个局部变量,等函数销毁后,在下次 JavaScript 引擎执行垃圾回收时,判断闭包这块内容如果已经不再被使用了,那么 JavaScript 引擎的垃圾回收器就会回收这块内存。
所以在使用闭包的时候,尽量注意一个原则:如果该闭包会一直使用,那么它可以作为全局变量而存在;但如果使用频率不高,而且占用内存又比较大的话,那就尽量让它成为一个局部变量。
转载自:https://juejin.cn/post/7193572191483461688