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

前言

接着上篇 深入浅出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 所指向的执行上下文中查找,这个查找的链条就是作用域。
  • 词法作用域 是代码编译阶段就决定好的,和函数是怎么调用的没有关系。
  • 闭包:一个外部函数返回一个内部函数后,即使该外部函数已经执行结束了,但是内部函数引用外部函数的变量依然保存在内存中,我们就把这些变量的集合称为闭包。
相关推荐
jessezappy17 分钟前
jQuery-Word-Export 使用记录及完整修正文件下载 jquery.wordexport.js
前端·word·jquery·filesaver·word-export
旧林84344 分钟前
第八章 利用CSS制作导航菜单
前端·css
yngsqq1 小时前
c#使用高版本8.0步骤
java·前端·c#
Myli_ing2 小时前
考研倒计时-配色+1
前端·javascript·考研
余道各努力,千里自同风2 小时前
前端 vue 如何区分开发环境
前端·javascript·vue.js
PandaCave2 小时前
vue工程运行、构建、引用环境参数学习记录
javascript·vue.js·学习
软件小伟2 小时前
Vue3+element-plus 实现中英文切换(Vue-i18n组件的使用)
前端·javascript·vue.js
醉の虾2 小时前
Vue3 使用v-for 渲染列表数据后更新
前端·javascript·vue.js
张小小大智慧2 小时前
TypeScript 的发展与基本语法
前端·javascript·typescript
hummhumm2 小时前
第 22 章 - Go语言 测试与基准测试
java·大数据·开发语言·前端·python·golang·log4j