前言
接着上篇 深入浅出JavaScript执行机制(上),介绍了 变量提升、JS 代码执行流、调用栈相关概念原理,本文将继续浅讲一下 块级作用域、作用域链、闭包相关工作原理。
作用域(scope)
作用域就是程序中定义变量的区域,它决定了变量的生命周期。通俗的讲就是,作用域就是变量与函数的可访问范围,即作用域控制着变量和函数的可见性和生命周期。
在 ES6 之前,ES 的作用域只有两种:全局作用域和函数作用域, ES6 开始有了块级作用域:
- 全局作用域 中的对象在任何地方都可以被访问得到,它的生命周期伴随着页面的生命周期。
- 函数作用域 就是在函数内部定义的变量或者函数,只能在函数内部被访问,函数执行结束后,函数内部定义的变量也会跟着销毁。
- 块级作用域 代码块内部定义的变量在代码块外部是访问不到的,并且等该代码块中的代码执行完成之后,代码块中定义的变量会被销毁。
代码块:被一对大花括号包裹的一段代码,比如:函数、判断语句、循环语句,甚至单独的一个{} 都可以看作是一个块级作用域。
js
//if块
if(1){}
//while块
while(1){}
//函数块
function foo(){}
//for循环块
for(let i = 0; i<100; i++){}
//单独一个块
{}
变量提升带来的问题
在没有块级作用域之前,变量统一被提升,这也直接导致了函数中的变量无论是在哪里声明的,在编译阶段都会被提取到执行上下文的变量环境中,所以这些变量在整个函数体内部的任何地方都是能被访问的。
存在以下问题:
- 变量容易被覆盖掉:函数内声明的变量会直接覆盖全局作用域的变量;
- 本应销毁的变量没有被销毁:比如:for 循环中的 i,循环结束之后,i 并没有被销毁;
这与其他支持 块级作用域 的语言表现不一致,容易产生误解。
块级作用域
所以,ES6 引入了 let 和 const 关键字,从而使 JavaScript 也能像其他语言一样拥有了块级作用域。
let 关键字声明的变量是可以被改变的,而使用 const 声明的变量其值是不可以被改变的,const 经常用来声明常量。两者都可以生成块级作用域。
JS 是如何支持块级作用域的
JS 引擎是通过变量环境实现函数级作用域的,那么 ES6 又是如何在函数级作用域的基础之上,实现对块级作用域的支持呢?
通过以下这段代码的执行来说明一下:
js
function foo(){
var a = 1
let b = 2
{
let b = 3
var c = 4
let d = 5
console.log(a)
console.log(b)
}
console.log(b)
console.log(c)
console.log(d)
}
foo()
1、 编译 :JS 引擎对函数进行编译,创建执行上下文:
可以看出
- 函数内部通过 var 声明的变量,在编译阶段全都被存放到 变量环境(Variable Enviroment)里面。
- 通过 let 声明的变量,在编译阶段会被存放到 词法环境(Lexical Environment)中。
- 在函数的作用域块内部,通过 let 声明的变量并没有被存放到词法环境中。
2、执行代码块 :当执行到代码块里面时,状态如下:
可以看出:
当进入函数的作用域块时,作用域块中通过 let 声明的变量,会被存放在 词法环境的一个单独的区域 中,这个区域中的变量并不影响作用域块外面的变量。比如在作用域外面声明了变量 b,在该作用域块内部也声明了变量 b,当执行到作用域内部时,它们都是独立的存在。
在词法环境内部,维护了一个小型栈结构,栈底是函数最外层的变量,进入一个作用域块后,就会把该作用域块内部的变量压到栈顶;当作用域执行完成之后,该作用域的信息就会从栈顶弹出,这就是词法环境的结构。这里所讲的变量是指通过 let 或者 const 声明的变量。
3、变量查找:执行到 console.log(a) 时:开始查找变量 a 的值
查找顺序:
- 沿着词法环境栈顶向下查询,如果在词法环境的某个块中查到,直接返回给 JS 引擎;
- 如果在词法环境中没找到,继续在变量环境中查找;
4、作用域块执行结束
当作用域块执行结束之后,其内部定义的变量就会从词法环境的栈顶弹出。
所以,了解了词法环境的结构和工作机制,上述问题的答案不言而喻了。块级作用域就是通过词法环境的栈结构来实现的,而变量提升是通过变量环境来实现,通过这两者的结合,JS 引擎也就同时支持了变量提升和块级作用域了。
作用域链
引言
看下以下这段代码
js
var myName = "juejin"
function bar() {
console.log(myName)
}
function foo() {
var myName = "zhihu"
bar()
}
foo()
当这段代码执行到 bar 函数内部时,其调用栈的状态图如下
如果按照调用栈的顺序来查找 myName 变量,自上而下的查找,首先会查找到 "myName = zhihu",
但是打印出来的结果是 "juejin",这就不得不讲到作用域链了。
在每个执行上下文的变量环境中,都包含了一个外部引用,用来指向外部的执行上下文,我们把这个外部引用称为 outer。
当一段代码使用了一个变量时,JS 引擎首先会在"当前的执行上下文"中查找该变量,如果在当前的变量环境中没有查找到,那么 JS 引擎会继续在 outer 所指向的执行上下文中查找,这个查找的链条就是 作用域链。下图的关系更好的帮助理解:
foo 函数调用的 bar 函数,为什么 bar 函数的外部引用指向 全局执行上下文,而不指向 foo 函数执行上下文呢?这是由词法作用域决定的。
词法作用域
词法作用域 就是指作用域是由代码中函数 声明的位置 来决定的,所以词法作用域是静态的作用域,通过它就能够预测代码在执行过程中如何查找标识符。
js
let count = 1
function main () {
let count = 2
function foo () {
let count = 3
function bar () {
let count = 4
}
}
}
从图中可以看出,词法作用域就是根据代码的位置来决定的,其中 main 函数包含了 foo 函数,foo 函数中包含了 bar 函数,因为 JavaScript 作用域链是由词法作用域决定的,所以整个词法作用域链的顺序是:bar 函数作用域--->foo 函数作用域--->main 函数作用域---> 全局作用域。
所以引言中那个问题,foo 函数调用的 bar 函数,为什么 bar 函数的外部引用指向 全局执行上下文,而不指向 foo 函数执行上下文呢?这是因为foo 和 bar 的上级作用域都是全局作用域,所以如果 foo 或者 bar 函数使用了一个它们没有定义的变量,那么它们会到全局作用域去查找。
所以,词法作用域是代码编译阶段就决定好的,和函数是怎么调用的没有关系。
闭包
理解了变量环境、词法环境和作用域链等概念,接下来再理解什么是 JavaScript 中的闭包就容易多了。
结合下面一段代码来理解什么是闭包:
js
function foo() {
var myName = "zhihu"
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("juejin")
bar.getName()
console.log(bar.getName())
执行到 foo 函数内部的 return innerBar 这行代码时调用栈的情况如下:
根据词法作用域的规则,内部函数 getName 和 setName 总是可以访问它们的外部函数 foo 中的变量,所以当 innerBar 对象返回给全局变量 bar 时,虽然 foo 函数已经执行结束,但是 getName 和 setName 函数依然可以使用 foo 函数中的变量 myName 和 test1。所以当 foo 函数执行完成之后,其整个调用栈的状态如下图所示:
从上图看出,,foo 函数执行完成之后,其执行上下文从栈顶弹出了,但是由于返回的 setName 和 getName 方法中使用了 foo 函数内部的变量 myName 和 test1,所以这两个变量依然保存在内存中。我们就可以把这个称为 foo 函数的 闭包。
所以,在 JavaScript 中,根据词法作用域的规则,内部函数总是可以访问其外部函数中声明的变量,当通过调用一个外部函数返回一个内部函数后,即使该外部函数已经执行结束了,但是内部函数引用外部函数的变量依然保存在内存中,我们就把这些变量的集合称为闭包。比如外部函数是 foo,那么这些变量的集合就称为 foo 函数的闭包。
闭包可以达到延长变量的生命周期的目的。
闭包的应用:计数器、防抖、节流等
闭包的回收
如果引用闭包的函数是一个全局变量,那么闭包会一直存在直到页面关闭;但如果这个闭包以后不再使用的话,就会造成内存泄漏。
如果引用闭包的函数是个局部变量,等函数销毁后,在下次 JS 引擎执行垃圾回收时,判断闭包这块内容如果已经不再被使用了,那么 JS 引擎的垃圾回收器就会回收这块内存。
所以在使用闭包的时候,你要尽量注意一个原则:如果该闭包会一直使用,那么它可以作为全局变量而存在;但如果使用频率不高,而且占用内存又比较大的话,那就尽量让它成为一个局部变量。
总结
- 作用域:是变量与函数的可访问范围,即作用域控制着变量和函数的可见性和生命周期。
- JS 的变量提升存在变量覆盖、变量污染等设计缺陷,所以 ES6 引入 块级作用域 关键字来解决该问题。
- 块级作用域就是通过 词法环境 的栈结构来实现的。
- 作用域链:当一段代码使用了一个变量时,JS 引擎首先会在"当前的执行上下文"中查找该变量,如果在当前的变量环境中没有查找到,那么 JS 引擎会继续在 outer 所指向的执行上下文中查找,这个查找的链条就是作用域。
- 词法作用域 是代码编译阶段就决定好的,和函数是怎么调用的没有关系。
- 闭包:一个外部函数返回一个内部函数后,即使该外部函数已经执行结束了,但是内部函数引用外部函数的变量依然保存在内存中,我们就把这些变量的集合称为闭包。