前端基础重建 --- 2.JS中的堆栈内存和变量提升

722 阅读10分钟

写在前面

往期内容

作为一个前端攻城狮,你真的了解浏览器是如何将我们写的 JS 代码运行的吗?了解 ECStack、EC、GO、VO、CO 这些名词代表什么含义并如何作用的吗?你知道我们 JS 文件中的变量是如何定义、赋值的吗?另外什么是变量提升?在新老版本浏览器下有什么区别吗?

这篇文章,我们就深入 JS 底层运行机制,一一分析以上问题吧。

JS 中的堆(Heap)和栈(Stack)

浏览器是如何执行我们写的 JS 代码的呢?

我们编写的 JS 代码都是需要在一个运行环境中运行的,浏览器想要执行 JS 代码,需要提供一个供代码执行的环境。常见的运行环境有:

  • 浏览器引擎
  • node(基于 V8 渲染 JS)
  • webView(V8 引擎)

注意: 以下我们只分析浏览器引擎。


首先我们先来了解几个高大上的名词,并作出分析。

ECStack(Execution Context Stack)执行环境栈

在代码运行时,浏览器会从计算机内存中申请一部分内存,用作代码的运行,这部分内存叫做:

ECStack(Execution Context Stack): 执行环境栈,它是栈内存,遵循FILO(First In Last Out)的方式工作

EC(Execution Context)执行上下文

在代码执行过程中,为了区分全局和函数执行所处的不同作用域(为了保证每个词法作用域下代码的独立性),产生了

**EC(Execution Context):**执行上下文,即代码执行所在的词法作用域

VO(Varibale Object)/ AO(Active Object) 变量对象

在每一个执行上下文中代码执行的时候,都有可能创建一些变量,所以在每一个上下文中(不论是全局还是私有),都会生成一个存储变量的空间,叫做:

**VO(Varibale Object):**变量对象,存放当前上下文中的变量

**AO (Active Object):**变量对象,存放当前私有上下文中的变量

GO(Global Object) 全局对象

浏览器在新建一个页面的时候,会将所有之后可能会被 JS 调用的属性和方法(内置)都存放在一个地方,叫做:

**GO(Global Object):**全局对象,存放所有浏览器后期需要共 JS 调用的属性和方法(内置)

在创建完GO(Global Object)全局对象之后,会放入ECStack,然后在全局创建一个叫做 window 的变量指向GO(Global Object)

以上就是浏览器在代码执行之前,默默做的准备工作。

注:

  1. VOAO区别不是很大,不过全局上下文称为VO(G),私有上下文中的变量对象称为AO(xx)
  2. GOVO/AO没有关系

Heap(堆内存)

在了解堆内存之前,我们先来分析一下 var a = 1这样一件事的过程:

声明(declare):var a;

定义(defined):a = 10;

  • var a = 1 分为三个步骤:
    1. 生成一个值(本例中为 1)
    2. 声明**(declare)**,一个变量存放在当前执行上下文(EC)的变量对象(VO)中
    3. 定义(defined),让变量和值进行关联(实际上是一个指针指向过程)
  • 在生成一个值的过程中,也分为两种不同的情况
    1. 基本类型值直接存储在栈内存中
    2. 引用数据类型值需要先开辟一个堆内存,将内容存储进去后,生成一个 16 进制的地址
    3. 然后会将此堆内存地址放入栈内存中供变量调用

所以堆内存就是存放一系列引用数据类型值的一部分内存

用一段代码分析以上过程

了解了以上这么多定义,可能各位小伙伴们都会比较懵,那我们就通过一段代码分析一下执行的过程,并给出图解。

var x = [23,34]
function fn(y){
  y[0] = 200;
  y = [300];
  y[1] = 400;
  console.log(y)
}
fn(x);
console.log(x)

! 重要提示:以下内容建议先看一遍分析图,然后对照解析文字,重新理解分析图并在脑海中形成整个代码执行逻辑

  1. 代码进入执行环境,生成执行环境栈ECStack。之后生成全局对象GO,放入执行环境栈

  2. 生成全局执行环境EC(G), 在其中生成变量对象VO(G),即全局变量对象,将代码放入 EC(G)中,压入执行环境栈ECStack中执行

  3. EC(G)中代码依次执行(如果存在变量提升进行变量提升,下一部分详解)

    • var x = [23,24]; 上文有分析堆内存的时候有说明var声明定义一个值的步骤,进行以下操作:

      • 生成值。发现值[23,24]为引用类型值,就开辟一块堆内存,我们假定堆内存地址为AAAFFF000
      • 再声明变量x,将其放入当前上下文中的变量对象VO(G)中去。
      • 最后进行定义,将变量x和堆内存地址进行关联。 =====》 x <------> AAAFFF000
    • function fn(y){...}; 遇到函数定义,就会开辟一块函数堆内存,我们假定地址为AAAFFF111。生成该函数堆内存的时候,会进行以下操作:

      • 生成一个[[scope]],其中包含着该函数生成时所处的执行上下文,本例中为EC(G)

      • 对函数的形参进行声明,即声明一个参数y

      • 将函数执行体以字符串的形式放入堆内存中,以待之后函数调用。本例中为:

        y[0] = 200;
        y = [300];
        y[1] = 400;
        console.log(y)
        
      • 以函数名fn为变量名进行声明并定义,放入当前变量对象VO(G)中,以便之后调用

    • fn(x); 执行函数fn

      • 其实就是调用我们存储在堆内存中的AAAFFF111
      • 函数执行时会形成一个私有的执行上下文,我们叫他EC(FN)
      • 在其中生成私有变量对象AO(FN)
      • 生成作用域链scope-chain, 这里解释一下,作用域链中保存了函数的当前作用域及其上级作用域。链的左边是当前作用域,链的右边是上级作用域。本例中当前作用域为:EC(FN),上级作用域为EC(G)。形如:<EC(FN), EC(G)>。有小伙伴可能会问了,这样做有什么用呢? 在当前作用域中取值的时候,如果该变量未定义,则会向作用域链的右侧作用域(也就是上级作用域)继续查找,直到找不到为止。 哦吼吼,是不是有点能 get 作用域链是怎么来的了?
      • 对形参进行定义(赋值),此时的形参变量是私有变量,会放入该执行上下文(EC(FN))的私有变量对象AO(FN)中去
      • 之后这里还会进行变量提升,定义arguments(箭头函数没有),等等一系列操作
      • 将堆内存中存储的函数执行体依次执行
      • 。。。

补充

对象数据类型

由 0 到多组键值对(属性名和属性值)组成的数据。

  • 属性名类型可以是任何基本类型,处理中可以和字符串互通

  • 但是不管怎么说,属性名肯定得是一个值,不能是引用数据类型,且到最后也是转换成字符串处理的

  • for in循环当中 ,获取的属性名都会变为字符串,且无法迭代到属性名为 Symbol 类型的属性

    let obj = {
      x:100
    }
    obj[x] // 把x变量存储的值当做属性名,获取对象的属性值,x不存在 则报错
    obj['x'] // 获取属性名为x的属性值  =》 10
    

变量提升

名词释义:

在当前上下文中(全局/私有/块级), JS 代码自上而下执行之前,浏览器会提前处理一些事情(可以理解为词法解析的一个步骤,词法解析一定发生在代码执行之前),使得我们可以在创建变量代码执行之前使用变量而不报错。

**变量提升:**浏览器会把当前上下文中所有带有VAR/FUNCTION关键字的内容进行提前的声明、定义,此过程发生在代码执行之前。

  • 带 VAR 的只会提前进行声明,默认值为 undefined。当代码运行到真正的声明语句时,已经声明过的变量不会重新进行声明。

  • 带 FUNCTION 的会提前声明定义两个操作,在全局上下文中的变量提升中,func = 函数 函数在这个阶段已经赋值了。

  • 基于VARFUNCTION全局上下文中生命的变量(全局变量),会映射到 GO(全局对象 window)上一份,作为它的属性 。之后有一个修改了,另外一个也会跟着修改

    var a = 12;
    console.log(a) // => 12
    console.log(window.a) // => 12 映射到GO上的属性
    
    window.a = 13;
    console.log(a); // => 13 映射机制导致都进行了修改
    
  • 变量提升的顺序:按照代码执行顺序自上而下进行变量提升,不存在优先级问题。

  • 只有VAR/FUNCTION会发生变量提升(ES6 中的 LET 和 CONST 不会)

!! 新老版本差异:

  1. 全局上下文EC(G)中的变量提升,不论判断条件是否成立,都要进行变量提升
    • 重要细节:条件执行块中 FUNCTION 的 在新版本浏览器中只会提前声明,不会再提前赋值了。
    • 老版本:var a ; func = 函数 新版本:var a; func
    • 这里老版本指 IE10 以下和低版本 Chrome;新版本指新版本现代浏览器
console.log(a, func); // 老版本 undefined function func(){}
console.log(a, func); // 新版本 undefined function func(){}

if(!('a' in window)){ // ’a‘ in window 检测a是否为window的一个属性 !True => false
  var a = 1;
  function func(){}
}
console.log(a)  // => undefined
  1. 新版本中,存在块级作用域,当大括号中出现let/const/function定义的时候,就会形成块级作用域,与其他作用域隔离。

  2. 新版本中,因为要兼容 ES3/ES6, 在遇到function a在全局声明过,在私有作用域下也有声明的情况,浏览器会把私有函数定义那行之前所有对a的操作,映射给全局一份。但是之后的代码和全局没有关系。

一道特别火的变量提升题目

为了更好理解上述描述3,我们来做一道前一段时间非常的面试题

var a = 0;
if(true){
  a = 1;
  function a(){};
  a = 21;
  console.log(a)
}
console.log(a)
  • 老版本输出 21 21 // 老版本浏览器没有块级作用域,所以a一直都是全局下的a,最后被定义为21

  • 新版本输出 21 1

    • 新版本中 由于产生了块级作用域,该作用域下的 a 会被重新声明,跟全局作用域下的 a 隔离起来

    • 在运行到块级作用域function a(){};这一句的时候,参考上文描述 3

    • 浏览器会把私有函数定义那行之前所有对a的操作,映射给全局一份

    • 所以此时全局作用域下的变量a,会被function a(){};定义之前的 a=1映射为 a = 1

    • 所以最终,输出 21 1

写在最后

欢迎访问我的博客fxflying.com