深入浅出JavaScript执行机制(中)

864 阅读10分钟

前言

接着上篇 深入浅出JavaScript执行机制(上),介绍了 变量提升、JS 代码执行流、调用栈相关概念原理,本文将继续浅讲一下 块级作用域、作用域链、闭包相关工作原理。

作用域(scope)

作用域就是程序中定义变量的区域,它决定了变量的生命周期。通俗的讲就是,作用域就是变量与函数的可访问范围,即作用域控制着变量和函数的可见性和生命周期。

在 ES6 之前,ES 的作用域只有两种:全局作用域和函数作用域, ES6 开始有了块级作用域:

  • 全局作用域 中的对象在任何地方都可以被访问得到,它的生命周期伴随着页面的生命周期。
  • 函数作用域 就是在函数内部定义的变量或者函数,只能在函数内部被访问,函数执行结束后,函数内部定义的变量也会跟着销毁。
  • 块级作用域 代码块内部定义的变量在代码块外部是访问不到的,并且等该代码块中的代码执行完成之后,代码块中定义的变量会被销毁。

代码块:被一对大花括号包裹的一段代码,比如:函数、判断语句、循环语句,甚至单独的一个{} 都可以看作是一个块级作用域。

//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 又是如何在函数级作用域的基础之上,实现对块级作用域的支持呢?

通过以下这段代码的执行来说明一下:

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 引擎对函数进行编译,创建执行上下文:

image.png

可以看出

  • 函数内部通过 var 声明的变量,在编译阶段全都被存放到 变量环境(Variable Enviroment)里面。
  • 通过 let 声明的变量,在编译阶段会被存放到 词法环境(Lexical Environment)中。
  • 在函数的作用域块内部,通过 let 声明的变量并没有被存放到词法环境中。

2、执行代码块 :当执行到代码块里面时,状态如下:

image.png

可以看出:

当进入函数的作用域块时,作用域块中通过 let 声明的变量,会被存放在 词法环境的一个单独的区域 中,这个区域中的变量并不影响作用域块外面的变量。比如在作用域外面声明了变量 b,在该作用域块内部也声明了变量 b,当执行到作用域内部时,它们都是独立的存在。

在词法环境内部,维护了一个小型栈结构,栈底是函数最外层的变量,进入一个作用域块后,就会把该作用域块内部的变量压到栈顶;当作用域执行完成之后,该作用域的信息就会从栈顶弹出,这就是词法环境的结构。这里所讲的变量是指通过 let 或者 const 声明的变量。

3、变量查找:执行到 console.log(a) 时:开始查找变量 a 的值

查找顺序:

  • 沿着词法环境栈顶向下查询,如果在词法环境的某个块中查到,直接返回给 JS 引擎;
  • 如果在词法环境中没找到,继续在变量环境中查找;
image.png

4、作用域块执行结束

当作用域块执行结束之后,其内部定义的变量就会从词法环境的栈顶弹出。

image.png

所以,了解了词法环境的结构和工作机制,上述问题的答案不言而喻了。块级作用域就是通过词法环境的栈结构来实现的,而变量提升是通过变量环境来实现,通过这两者的结合,JS 引擎也就同时支持了变量提升和块级作用域了。

作用域链

引言

看下以下这段代码

var myName = "juejin"
function bar() {
    console.log(myName)
}
function foo() {
    var myName = "zhihu"
    bar()
}
foo()

当这段代码执行到 bar 函数内部时,其调用栈的状态图如下

image.png

如果按照调用栈的顺序来查找 myName 变量,自上而下的查找,首先会查找到 “myName = zhihu”,

但是打印出来的结果是 “juejin”,这就不得不讲到作用域链了。

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

当一段代码使用了一个变量时,JS 引擎首先会在“当前的执行上下文”中查找该变量,如果在当前的变量环境中没有查找到,那么 JS 引擎会继续在 outer 所指向的执行上下文中查找,这个查找的链条就是 作用域链。下图的关系更好的帮助理解:

image.png

foo 函数调用的 bar 函数,为什么 bar 函数的外部引用指向 全局执行上下文,而不指向 foo 函数执行上下文呢?这是由词法作用域决定的。

词法作用域

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

let count = 1
function main () {
 let count = 2
 function foo () {
   let count = 3
   function bar () {
     let count = 4
   }
 }
}

image.png

从图中可以看出,词法作用域就是根据代码的位置来决定的,其中 main 函数包含了 foo 函数,foo 函数中包含了 bar 函数,因为 JavaScript 作用域链是由词法作用域决定的,所以整个词法作用域链的顺序是:bar 函数作用域—>foo 函数作用域—>main 函数作用域—> 全局作用域。

所以引言中那个问题,foo 函数调用的 bar 函数,为什么 bar 函数的外部引用指向 全局执行上下文,而不指向 foo 函数执行上下文呢?这是因为foo 和 bar 的上级作用域都是全局作用域,所以如果 foo 或者 bar 函数使用了一个它们没有定义的变量,那么它们会到全局作用域去查找。

所以,词法作用域是代码编译阶段就决定好的,和函数是怎么调用的没有关系。

闭包

理解了变量环境、词法环境和作用域链等概念,接下来再理解什么是 JavaScript 中的闭包就容易多了。

结合下面一段代码来理解什么是闭包:

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 这行代码时调用栈的情况如下:

image.png

根据词法作用域的规则,内部函数 getName 和 setName 总是可以访问它们的外部函数 foo 中的变量,所以当 innerBar 对象返回给全局变量 bar 时,虽然 foo 函数已经执行结束,但是 getName 和 setName 函数依然可以使用 foo 函数中的变量 myName 和 test1。所以当 foo 函数执行完成之后,其整个调用栈的状态如下图所示:

image.png

从上图看出,,foo 函数执行完成之后,其执行上下文从栈顶弹出了,但是由于返回的 setName 和 getName 方法中使用了 foo 函数内部的变量 myName 和 test1,所以这两个变量依然保存在内存中。我们就可以把这个称为 foo 函数的 闭包

所以,在 JavaScript 中,根据词法作用域的规则,内部函数总是可以访问其外部函数中声明的变量,当通过调用一个外部函数返回一个内部函数后,即使该外部函数已经执行结束了,但是内部函数引用外部函数的变量依然保存在内存中,我们就把这些变量的集合称为闭包。比如外部函数是 foo,那么这些变量的集合就称为 foo 函数的闭包。

闭包可以达到延长变量的生命周期的目的。

闭包的应用:计数器、防抖、节流等

内存模型角度分析闭包(扩展)

首先,在 JavaScript 的执行过程中, 主要有三种类型内存空间,分别是代码空间、栈空间和堆空间。

栈空间和堆空间

栈空间就是 深入浅出JavaScript执行机制(上) 中反复提及的调用栈,是用来存储执行上下文的。

看下这段代码

function foo(){
    var a = "juejin"
    var b = a
    var c = {name:"juejin"}
    var d = c
}
foo()

当执行到第三行时,调用栈状态如下:

截屏2023-09-09 22.33.36.png

原始类型 a 和 b 变量是放在栈空间中的,引用类型是放在堆空间中的。

执行第 4 行代码,右边的值是一个引用类型,JavaScript 引擎并不是直接将该对象存放到变量环境中,而是将它分配到堆空间里面,分配后该对象会有一个在“堆”中的地址,然后再将该数据的地址写进 c 的变量值,最终分配好内存的示意图如下所示:

截屏2023-09-09 22.48.23.png

所以,对象类型是存放在堆空间的,在栈空间中只是保留了对象的引用地址,当 JavaScript 需要访问该数据的时候,是通过栈中的引用地址来访问的,相当于多了一道转手流程。

为什么要分成“栈”和“堆”两个内存空间呢?所有数据直接存放在“栈”中不就可以了吗?

这是因为 JS 引擎需要用栈来维护程序执行期间上下文的状态,如果栈空间大了话,所有的数据都存放在栈空间里面,那么会影响到上下文切换的效率,进而又影响到整个程序的执行效率。所以通常情况下,栈空间都不会设置太大,主要用来存放一些原始类型的小数据。而引用类型的数据占用的空间都比较大,所以这一类数据会被存放到堆中,堆空间很大,能存放很多大的数据。

在 JavaScript 中,原始类型的赋值会完整复制变量值,而引用类型的赋值是复制引用地址。

截屏2023-09-09 22.56.45.png

所以这也是浅拷贝的时候,会同步修改值的原因。

闭包的产生

上面闭包的例子,遇到内部函数 setName,JS 引擎还要对内部函数做一次快速的词法扫描,发现该内部函数引用了 foo 函数中的 myName 变量,由于是内部函数引用了外部函数的变量,所以 JS 引擎判断这是一个闭包,于是在堆空间创建换一个“closure(foo)”的对象,用来保存 myName 变量。

执行到 foo 函数中“return innerBar”语句时的调用栈状态,如下图所示:

截屏2023-09-09 23.07.36.png

从上图可以看出,当执行到 foo 函数时,闭包就产生了;当 foo 函数执行结束之后,返回的 getName 和 setName 方法都引用“closure(foo)”对象,所以即使 foo 函数退出了,“ closure(foo)”依然被其内部的 getName 和 setName 方法引用。所以在下次调用bar.setName或者bar.getName时,创建的执行上下文中就包含了“closure(foo)”。

总的来说,产生闭包的核心有两步:

  • 第一步是需要预扫描内部函数;
  • 第二步是把内部函数引用的外部变量保存到堆中。

闭包的回收

如果引用闭包的函数是一个全局变量,那么闭包会一直存在直到页面关闭;但如果这个闭包以后不再使用的话,就会造成内存泄漏。

如果引用闭包的函数是个局部变量,等函数销毁后,在下次 JS 引擎执行垃圾回收时,判断闭包这块内容如果已经不再被使用了,那么 JS 引擎的垃圾回收器就会回收这块内存。

所以在使用闭包的时候,你要尽量注意一个原则:如果该闭包会一直使用,那么它可以作为全局变量而存在;但如果使用频率不高,而且占用内存又比较大的话,那就尽量让它成为一个局部变量

总结

  • 作用域:是变量与函数的可访问范围,即作用域控制着变量和函数的可见性和生命周期。
  • JS 的变量提升存在变量覆盖、变量污染等设计缺陷,所以 ES6 引入 块级作用域 关键字来解决该问题。
  • 块级作用域就是通过 词法环境 的栈结构来实现的。
  • 作用域链:当一段代码使用了一个变量时,JS 引擎首先会在“当前的执行上下文”中查找该变量,如果在当前的变量环境中没有查找到,那么 JS 引擎会继续在 outer 所指向的执行上下文中查找,这个查找的链条就是作用域。
  • 词法作用域 是代码编译阶段就决定好的,和函数是怎么调用的没有关系。
  • 闭包:一个外部函数返回一个内部函数后,即使该外部函数已经执行结束了,但是内部函数引用外部函数的变量依然保存在内存中,我们就把这些变量的集合称为闭包。