学前端:浏览器堆栈内存,闭包和作用域链

142 阅读8分钟

引言

JS中的函数随处可见,关于函数的执行方式我相信大家都耳熟能详了。大体来说,JS引擎会先把我们的代码转换成机器指令码(其中经历词法解析,生成AST...),然后才能执行。作为一个小白,暂时不打算把精力过多放在编译这一块,今天我们来聊聊编译之后,函数运行时到底发生了什么?

首先,我们创建一个函数的时候,先在开辟堆内存并存储这个函数,然后我们通过对这坨内存的引用来执行函数;函数执行的时候,会形成一个私有上下文,然后经历:初始化作用域链,初始化this,初始化arguments,形参赋值,变量提升,代码执行,出栈释放。

简单地说,函数执行就是上面的几个步骤,但是这里面涉及到很多有意思的东西,这才是我们今天的主题!

我们先明确几个概念:(英语小课堂)

  • ECStack(Execution Context Stack,执行环境栈(所有代码的执行环境)。函数执行时创建的私有上下文都要放到这里压栈,我也看到有的老外叫Call Stack

  • [G]EC ([Global] Execution Conext ),[全局]执行上下文,也就是函数执行时创建的作用域,那个global标识的时全局的作用域,也就是我们打开一个网页就算没有执行任何函数,这个全局上下文就已经存在了(位于ECStack的最底层,且包含全局对象window,指向window的this)参考ECMA规范

  • LexicalEnvironment和VariableEnvironment,一个叫词法环境一个叫变量环境,其实他们本质上做了同一件事,他们用来保存执行上下文中的标识符的映射(就是存了这个作用域内私有的变量),根据 ECMA-262的5.1版本第10.3章节描述,变量环境是某种类型的词法环境。具体区别在于:词法环境存的是let/const声明的变量,变量环境存的是var声明的变量(因为var涉及到变量提升的问题,此特性有坑) 具体解释 (在ES3里这个被称为VO/AO)

    • 词法环境就是描述环境的对象,主要包含两个部分: 1.环境记录(Environment Record) :记录相应环境中的形参,函数声明,变量声明等

    2.对外部环境的引用(out reference)

举个例子:
    let a = 20; 
    const b = 30; 
    var c;
    function fn(e, f) {}
    
下面是当前全局执行上下文对应的伪代码:
    GlobalExectionContext = {         / / 全局上下文
      ThisBinding: <Global Object>,
      LexicalEnvironment: {  
        EnvironmentRecord: {  
          Type: "Object",  
          a: < uninitialized >,       / / let const function 声明的标识符绑定在这里  
          b: < uninitialized >,  
          fn: < func >  
        }  
        outer: <null>                 / / 对外部的引用
      },
      VariableEnvironment: {  
        EnvironmentRecord: {  
          Type: "Object",  
          c: undefined,              / / var 声明的标识符绑定在这里  
        }  
        outer: <null>                / / 对外部的引用
      }  
    }

至此,一些基本的概念已经搞定了,接下来我们再看看更有意思的

Execution Context的2个独立阶段

每个执行上下文都有2个独立的阶段,创建阶段和执行阶段(a Creation phase and an Execution phase)。

  • 创建阶段:这个阶段代码还没开始执行,只是初始化EC环境(变量声明分配undefined值,函数声明完全放入内存)image.png
  • 执行阶段:JS引擎开始逐行执行代码,并把实际的值赋给已在内存中的变量image.png

全局上下文和函数上下文在创建阶段是有一点区别的:

  • 全局EC:1.创建全局对象(window)2.创建this对象 3.给变量和函数设置内存空间 4.给变量声明赋值undefined,把函数声明放入内存
  • 函数EC:1.创建arguments对象(箭头函数没有)2.创建this对象 3.给变量和函数设置内存空间 4.给变量声明赋值undefined,把函数声明放入内存

变量提升(Hoisting

在创建阶段为变量声明分配未定义的默认值的过程称为“提升”,这也就是为什么 var和function声明的变量可以提前使用而不报错。但是,let const 则取消了这个特性,不能提前使用否则会报错(也就是暂时性死区)

作用域链(scope chain)

关于作用域,我们姑且把它看作就是执行上下文吧。在一个作用域内我们可以访问他的私有变量,这是毫无疑问的。那么如果我们访问了一个当前作用域内不存在的变量该怎么办呢?答案很简单,JS引擎会向当前作用域的上级作用域逐级查找,最上层的作用域就是全局执行上下文,最终还是找不到会报错。

那么上级作用域是如何确定的呢?其实在函数创建的时候就已经确定了,不论函数在哪里执行,他的上级作用域就是函数被创建的作用域。其实作用域链指的就是这个逐级查找的过程

function a(){
    let aa='a的aa'
    return function(){
        console.log(aa)
    }
}
let aa='全局的aa'
let b = a()

b() =>输出的是 'a的aa'

闭包(closure)

首先,我们来看看MDN的定义,一个函数和对其周围状态(lexical environment,词法环境)的引用捆绑在一起(或者说函数被引用包围),这样的组合就是闭包closure

看起来很高大上,有没有!其实很简单,正常情况下,一个函数执行上下文在经过创建阶段和执行阶段之后就会出栈销毁,那么他包含的所有东西也都没有了。但是,如果JS引擎发现当前上下文内存在着被外部环境引用的堆内存,那么这个上下文就不会被销毁。

this

关于上下文中的this对象,它是一个动态变化的值,侧面反映了函数的调用情况(就像函数的形参,只有当调用函数并传递实参后,我们才知道那个形参的具体值)

this指向汇总:

    1. DOM元素的事件绑定,this指向元素本身

    2. 函数执行

    • 普通函数执行,前面有点this就是谁 ;没有点就是window(严格模式里,是undefined)
    • 匿名函数执行,
      • 函数表达式:同普通函数,或事件绑定;
      • 自执行函数:this 一般是window/undefined,执行函数创建的EC有可能不被销毁,但是存放函数代码的堆内存一定销毁
      • 回调函数 :一般是 window/undefined, 如果做了特殊处理,则遵循特殊处理)

    1. new 执行的构造函数内 this指新创建的对象

    2. 块级上下文,箭头函数没有this,运行时向上寻找

    3. Function.prototype上的 call,apply,bind可以改变函数的this指向

let const var

虽然let const var 都可以声明变量,但是了解他们的具体差异还是很重要的。

    1. let和const的功能几乎一致,最大的区别在于 const 声明的变量不能重新赋值

    2. var造成变量提升,let没有

    3. let不可以重复声明同一个变量(AST解析的时候不会通过,所有代码都不会执行

    4. 在全局上下文中,var 声明的变量会放在window内

    5. let造成暂时性死区:使用let声明变量之前,该变量都不可用。语法上称为“暂时性死区”(temporal dead zone,简称TDZ),也意味着typeof不再是一个百分之百安全的操作。

    6. 块级作用域,在 { } 内使用let / const / function(除了对象和函数的),都会产生块级上下文

特殊情况

  1. 函数的形参赋值默认值,函数体内部有 let const var 声明了变量,那么函数执行的时候会生成2个上下文 image.png 这里的Local和Block是2个独立的上下文,Local进行了初始化this,初始化arguments,形参赋值(所以,他的x是5,y是fn{x=2});Block进行了变量提升,代码执行(由于Block具有和Local同名的变量x,Block会把x的值copy过来,所以代码还没执行时,x就是5)
  1. function造成的块级作用域
如下,function在{}内声明函数foo会在创建一个块级作用域,且在上级作用域和块级作用域都进行变量提升
每次执行到函数声明时,都会把foo在块级作用域的值同步给上级作用域

{
    function foo() {1}  在代码执行到这时,变量提升已经完成,foo的值是foo{2},同步给上级上下文

    foo = 1;            修改了块级作用域foo的值

    function foo() {2}  再次同步foo的值给上级上下文,(变量提升时,会覆盖之前的 foo(){1})
}
console.log(foo);       foo=1

参考文献

[1] ui.dev/ultimate-gu…
[2] ui.dev/this-keywor…
[3] ui.dev/var-let-con…