这篇文章上次修改于 244 天前,可能其部分内容已经发生变化,如有疑问可询问作者。

从一个小问题开始

昨天,我带的一个工作室的实习成员问了这样一个问题:

这个问题我们可以简化一下,其实就是:


  function A() {
    console.log(username);
  }
  
  function B() {
    console.log('Uni');
  }
  
  B();

这位同学想问的是,在这种情况下会不会因为没有声明username而产生报错。

当然这个问题玩过前端的一眼就能看出来,正常输出Uni且不会报错。可是原理是什么呢?这中间到底发生了什么?为什么它不会有任何问题?

这篇文章将会从JS执行机制出发,更深层地探讨JS的基础问题。

声明和提升

首先,由浅入深,我们来回顾一下学习JS变量是遇到的知识,做一个小热身。我们先不讨论ES6的语法特性,这个部分我想留到这个系列的下篇文章进行探讨。

首先我们来看一段代码:

  console.log(username);   // undefined

  var username = "Uni";

这段代码很简单,也就是我们常常说的变量提升。在JS的执行机制里面(这个机制是什么我等等讲)这段代码是这样的:

  var username = undefined;
  
  console.log(username);    // undefined
  
  username = "Uni";

我们再来看一个例子:


  test();      // Uni
  
  function test() {
    console.log("Uni");
  }

这里发生一个函数提升,在JS执行机制的眼里是这样的:

  function test() {
    console.log("Uni");
  }
  
  test();      // Uni

到这里都很简单对吧,我们再给点耐心看看另一个小例子:

  test();

  var test = function() {
      console.log("Uni");
  }

这段代码会产生报错:

报错的类型是TypeError

通过上述例子,其实我更喜欢把提升叫做声明提升,不论是变量提升还是函数提升,它都只有是一个声明的时候才会发生提升。

接下来我们从JS代码执行的过程来看看提升。

JS代码的执行流程

因为是对某一个点的知识进行讨论,与本篇文章讨论的知识无关的细节我会进行掩盖处理。

编译

初学JS的时候,我们大都可能听到:“JavaScript是一门解释型语言。”这样的话。但是JS事实上是一门编译语言,它是存在编译这个阶段的,只不过它的编译与传统的编译语言(譬如C语言)那样有些许不同。

在这里我不先从词法、LHS、RHS这些进行讨论,这些我会留在后面文章讨论作用域的时候拿出来仔细讨论。

我们首先要知道,JS执行流程的第一步是编译。通过编译,会生成两个部分:执行上下文和可执行代码。

执行上下文就是执行一段代码是的运行环境,执行上下文中包括:变量环境和词法环境。

在编译时,JS引擎在变量环境中生成一个对象结构,我们称为变量对象。扫描代码,如果发现var声明的变量,会将其作为变量对象中的一个属性,并且赋予undefined。如果发现一个函数声明(通过function定义)的话,会将其定义存放在堆内存中,同时会在变量对象中创建一个属性,并且该属性的值是指向这个堆内存

比如下面这段代码:

  var name = 'Uni';
  
  console.log(name);
  
  function Fun() {
      var age = 20;
      console.log(age);
  }
  
  Fun();

这一阶段的编译会生成类似于这样的变量对象

  VO = {
      name: undefined,
      Fun: function() {
        var age = 20;
        console.log(age);
      }
  }

而JS引擎会将除了声明以外的代码转化为字节码,也就是我们的可执行代码,一下是抽象写法,并不是实际上的:

  name = 'Uni';
  console.log(name);
  Fun();

执行

细心的同学应该会发现,我刚刚一直在有一重复一个词:当前阶段。这个词不是那么准确,但是能突出我想表达的。JS并不会只编译一次。不会在第一次编译阶段就讲所有的声明提出来。我们从上面的环境变量的例子也能看出来:

  VO = {
      name: undefined,
      Fun: function() {
        var age = 20;
        console.log(age);
      }
  }

我们Fun属性对应的值是存放于堆内存中的,我们可以看到函数内部的代码并没有做处理。

当我们可执行代码按顺序执行到我们的Fun()时,也就是调用Fun函数,JS引擎会找出这段函数代码,进行对这段函数的编译,从而生成这段函数的执行上下文和可执行代码。

调用栈

看到这里,我们可以知道,在一个JS代码开始运行的时候,进行第一次编译,会生成一个执行上下文,这个执行上下文我们叫做:全局执行上下文

而当调用一个函数的时候,函数体内的代码才会被编译,从而创建函数执行上下文。当函数中的可执行代码执行结束后,函数便会被销毁。

这是我们很自然的会想到,如果函数体里还有函数调用呢?那必然是会去找被调用的那个函数体然后进行代码编译(如果没有什么违规写法的话)。所以我们当前函数只有等自己函数体中的函数调用执行完了,它才能被销毁。我们来看段代码清晰一下思路:

function A() {
    console.log('begin');
    B();
    console.log('end');
}

function B() {
    console.log('run');
}

A();

我们按照我们刚刚的思路来分析一下:

1、首先会生成一个全局的执行上下文,执行上下文中的变量对象是:

  VO:
    A : {
        console.log('begin');
        B();
        console.log('end');
    }
   
    
    B : {
        console.log('run');
    }

生成可执行代码并执行。

2、调用我们的函数AA的函数体中的代码进行编译,生成一个函数执行上下文:

  VO(A): {
  
  }

生成函数A的可执行代码,并进行执行。

3、执行到B()时,调用B,对B的函数体进行编译,生成其函数执行上下文,生成其可执行代码并执行。

4、函数B执行结束,函数B销毁,继续执行函数A的可执行代码。

5、函数A执行结束,函数A销毁,回到全局的可执行代码中,发现无可执行代码,销毁全局(这里先忽略事件队列的相关内容)

我们简单模拟了一遍JS引擎的运行机制,那么JS引擎具体是如何管理这些执行上下文的呢?

JS引擎采用了一种名叫栈的数据结构对执行上下文进行管理,而这个结构我们称作调用栈

学过数据结构的同学都知道,栈是一个先进后出的数据结构,它就像一个羽毛球筒,先放进去的我们会压在最底下,拿出来的话是最后才拿出来。

由于缺乏画图工具,我在网上找了一个很形象的图:

所以我们再来看看刚刚那段代码运行时,调用堆栈是怎么样的:

1、全局上下文入栈,被压到栈底

2、调用函数A,函数A的执行上下文被压入栈

3、调用函数B,函数B的执行上下文被压入栈

4、函数B执行完毕,函数B的执行上下文出栈。

5、函数A执行完毕,函数A的执行上下文出栈。

6、全局执行完毕,全局执行上下文出栈。

回到一开始

这个时候我们再来会看一开始那位实习的同学提出的问题,是不是更清晰为什么了?


  function A() {
    console.log(username);
  }
  
  function B() {
    console.log('Uni');
  }
  
  B();

我们函数A的函数体始终没有被编译,所以没有生成可执行代码,自然是不会被执行,所以也不会产生报错。

One more ting

其实这里有个小问题我留下了没有写,关于函数什么和函数表达式的,我打算再下篇深入讲解作用域时通过LHSRHS来进行细讲。

此文章已经同步更新到我的个人微信公众号:想养猫的前端
觉得不错的话,关注一下啦!