JavaScript执行机制一

JavaScript 的执行顺序

对于人的直观感受而言,一串 JavaScript 的代码执行逻辑应该是一行一行的执行的,那么 JavaScript 的执行是否是一行一行的执行的呢?先看一个例子:

function fn() {
  console.log('fn1');
}
fn(); // fn2

function fn() {
  console.log('fn2');
}
fn(); // fn2
复制代码

两次调用函数的结果都是 fn2,为什么是这样的?先不管为什么,再看一个例子:

showName();
console.log(myName); // undefined
var myName = "Jackson";

function showName() {
  console.log("showName函数被执行"); // showName函数被执行
}
复制代码

假设 JavaScript 引擎执行的代码是一行一行的执行,那么执行到第一行代码就会报错,但是实际上却调用了 showName 函数。执行第二行代码,在前面并没有声明 myName 变量,但是打印出来的结果却是 undefined,说明 myName 是声明了,未定义。这说明 JavaScript 引擎执行代码并不是一行一行的执行的。
JavaScript 引擎是如何执行一段代码的呢?

变量提升

在了解 JavaScript 代码是如何执行之前,需要先了解什么是变量提升。
首先看看 JavaScript 的声明和赋值。

变量的声明和赋值:

function fn() {
  console.log(myName);
}
var myName = "Jackson";
复制代码

上面代码中变量可以拆解成 2 个部分,声明和复制,函数因为没有赋值操作,就是完整的声明,如下:

var myName = undefined; // 声明
function fn() {
  console.log(myName);
}
myName = "Jackson"; // 赋值
复制代码

到这里了解了声明和赋值后,再来看看变量提升。

变量提升是指 JavaScript 在执行的过程中,JavaScript 引擎会把变量声明的部分和函数声明的部分提升到代码开头的行为,变量提升后会给默认值为 undefined。

结合变量提升的概念,模拟一下下面的代码:

showName();
console.log(myname)
var myName = "Jackson";

function showName() {
  console.log("showName函数被执行");
}

/**
  变量提升部分
*/
var myName = undefined;
function showName() {
  console.log('showName函数被执行');
}

/**
  可执行部分
*/
showName();
console.log(myname)
myName = 'Jackson';

复制代码

通过这段代码的模拟,我们知道函数和变量在执行代码之前已经被提升到了代码开头。变量的提升意味着从物理层面上把代码移到了最前面,正如上面模拟的一样,变量和函数在执行的过程中,并不会改变位置,而是在编译阶段被 JavaScript 引擎放入了内存中。故一段 JavaScript 的执行的大致流程如下:

  1. 编译阶段:对变量和函数进行变量提升操作,如下:
/**
  变量提升部分
*/
var myName = undefined;
function showName() {
  console.log('showName函数被执行');
}
复制代码
  1. 执行代码阶段:一行一行的执行代码
/**
  可执行部分
*/
showName();
console.log(myname)
myName = 'Jackson';
复制代码

JavaScript 代码经过编译后会分为两个部分:执行上下文和可执行代码。执行上下文分为:全局执行上下文、函数执行上下文、eval 执行上下文;

执行上下文是 JavaScript 执行一段代码时的运行环境。具体可以看下图:

execution_context1.jpg

上图中执行上下文中存放一个变量环境的对象(Viriable Environment),该对象中保存了变量提升的内容,如 myName 和 showName 函数,当然执行上下文还有其他的对象,如块级作用域中的变量存放在词法环境;每一个执行上下文都存放有一个外部引用 outer,存放于外部环境;以及每一个执行上下文中都存放有一个 this。这些后面会详细讲解,这儿就不多说了。

变量环境中存放的对象可简单表示为如下结构:

ViriableEnvironment:
  myName -> undefined,
  showName ->function : {console.log(myname)
复制代码

在执行阶段,JavaScript 引擎对可执行代码,按照顺序进行一行一行的执行。显示对声明的 myName 进行赋值操作,然后执行 showName 方法,变量环境如下所示:

ViriableEnvironment:
  myName -> 'Jackson',
  showName ->function : {console.log(myname)
复制代码

到这里已经明白了 JavaScript 执行代码的机制,首先 JavaScript 引擎会对代码进行编译,编译阶段会有变量提升的部分,存放于变量环境,然后是代码执行部分。那么如果有两个相同的变量和函数 JavaScript 会如何执行呢?结合本文的第一个例子,后面的代码会覆盖前面的代码。

调用栈

上面提到过,一段代码经过编译并创建执行上下文,分为三种情况:

  1. 全局执行上下文:在执行 JavaScript 全局代码的时候,会编译全局代码创建全局执行上下文,在整个页面的生存周期内,只有一份全局执行上下文;
  2. 函数执行上下文:在调用一个函数时,会编译函数内部代码,并创建函数执行上下文,在函数执行结束后,创建的函数执行上下文会被销毁;
  3. 当使用 eval 函数时,eval 代码会被编译,并创建 eval 执行上下文;

明确了三种执行上下文的情况,在谈谈什么是调用栈,调用栈是一种栈(后进先出)的数据结构。调用栈是用来管理函数调用关系的数据结构。如下 JavaScript 代码,执行上下文是如何创建并存储的呢?

var a = 1;
function fn1(b, c) {
  var a = 5;
  console.log(a);
  return a + b + c;
}
function fn2(b, c) {
  var d = 10;
  res = fn1(b, c);

  return res + d;
}

fn2(2, 3);
复制代码

上面的代码声明了一个变量 a,函数 fn1、fn2,在全局范围内调用 fn2,fn2 中调用 fn1,在整个代码的执行过程中调用栈是如何执行的呢?下面会一步一步分析,调用栈的变化情况。

  1. 创建全局上下文,并将其压入栈底,如图:

call_stack1.jpg
从图中可以看出来,变量 a,函数 fn1 和 fn2 都保存到了全局上下文的变量环境对象中。需要注意的是,函数调用伴随的值传递的过程。全局执行上下文压入栈后,JavaScript 开始执行全局代码,首先将执行 a = 2 的赋值操作。

  1. 调用 fn2 方法,JavaScript 引擎会编译该函数,创建执行上下文,并压入调用栈中,如下图所示

call_stack2.jpg
函数 fn2 执行上下文后,便开始执行函数 fn2 中的代码,将 fn2 中的 10 赋值给 d,即 d = 10;

  1. 调用 fn1 方法,JavaScript 引擎会编译该函数,创建执行上下文,并压入调用栈中,如下图所示

call_stack3.jpg
函数 fn1 执行上下文入栈后,便开始执行 fn1 中的代码,将 fn1 中的 5 赋值给 a,即 a = 5;并执行 a + b + c,返回给 fn2 函数中的调用 res 调用方。

  1. fn1 执行完成后,fn1 执行上下文出栈,如图

call_stack4.jpg
fn1 执行完成后,会将返回值赋值给 fn2 中的 res,即 res = 10

  1. fn2 执行完成后,fn2 执行上下文出栈,如图

call_stack5.jpg
fn2 执行完成后,将返回值 res + d 的结果返回,因全局环境没有变量接收,故函数 fn2 执行上下文出栈。到此,调用栈中只剩下全局执行上下文,将全局执行上下文的代码执行完成,结束。

  1. 栈溢出
    调用栈是存在大小的,当栈的空间满后,就会存在栈溢出的情况,如下代码
function stackOverflow(n) {
  if (n <= 0) {
    return 1;
  }
  return n * stackOverflow(n - 1);
}
stackOverflow(50000);

// 输出: Uncaught RangeError: Maximum call stack size exceeded
复制代码

上面函数是计算阶乘使用,当递归调用 5 万次,即每一次执行函数都会产生一个执行上下文,存入调用栈,我使用的时 chrome81 版本浏览器,调用栈的大小为12540,故超出调用栈的大小会出现栈溢出的情况。
那么如何解决呢?
这里有两种方法:

  1. 将每次执行的结果存入宏任务队列中,借助 setTimeout;
  2. 通过尾递归的方式处理,但是很多浏览器不兼容,尾递归方式无意义,不如直接循环;

块级作用域

由于 JavaScript 的变量提升,造成很多不符合直观感受的代码,

var myName = "Jackson";

if (true) {
  var myName = "Monchic";
}
console.log(myName);

// 输出: Monchic
复制代码

正常的逻辑应该是输出Jackson的,但是因为变量提升的关系,输出的是Monchic,这不得不说是 JavaScript 的设计缺陷,在 es6 中提出了块级作用域,解决了因变量提升造成很多直觉不一致的代码。

块级作用域是使用大括号包裹的一段代码,比如函数、判断语句、循环语句、单独块等,如下:

//if块
if (1) {
}

//while块
while (1) {}

//函数块
function foo() {}

//for循环块
for (let i = 0; i < 100; i++) {}

//单独一个块
{
}
复制代码

了解块级作用域,通过 let 改写下代码

let myName = "Jackson";

if (true) {
  let myName = "Monchic";
}
console.log(myName);

// 输出: Jackson
复制代码

结果即是我们预期的结果。
我们知道,通过 var 声明的变量是存放到执行上下文中的变量环境中的,那么通过 let 和 const 声明的变量存放到哪里呢?

JavaScript 引擎如何支持块级作用域?

我们可以通过 let 和 const 声明块级作用域,在一段代码中,使用的有 let、const 和 var,JavaScript 引擎能同时支持变量提升和块级作用域,那么 JavaScript 引擎是如何同时支持的呢?
首先看一段代码:

function fn() {
  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);
}
fn();
复制代码

结合前面将的执行上下文的思路,执行上下文的思想来分析内存中的存储情况,块级作用域是存放在执行上下文中的词法环境中,它是一个小型的栈结构。

  1. 编译并创建执行上下文,如图

block1.jpg
分析上图:

  • 通过 var 声明的变量 a 和 c 存放到执行上下文中的变量环境中
  • 通过 let 声明的变量 b 存放到执行上下文中的词法环境中
  1. 编译块级作用域代码
    当执行块级作用域中代码时,变量环境中的 a 的值被设置成 1,词法环境中的变量 b 被设置成 2,同时对块级作用域中的代码进行编译,如图所示

block2.jpg

分析上图:

  • 块级内部通过 let 声明了变量 b,这个区域的变量 b 并不会影响外部的变量 b,在词法环境入栈,当执行完成后再出栈;
  • 块级内部通过 var 声明了变量 c,编译时会对 c 变量提升,在变量环境中存放 c = undefined,当执行代码的时候变量 c 会被设置成 4;
  • 块级内部通过 let 声明了变量 d,编译变量 d 时,编译后的在赐福环境入栈,然执行时,变量 c 会被设置成 5;

block3.jpg
在词法环境中,会维护一个小型的栈结构,栈底是最外层通过 let 或 const 声明的变量,进入一个作用域后,会把该作用域内部通过 let 或 const 声明的变量存入栈顶,当作用域执行完成会从栈顶弹出。
当执行到 console.log(a)时,JavaScript 引擎会先从词法环境中查找变量,如果找到就返回给 JavaScript 引擎,如果没找到会到变量环境中继续查找。如下图所示

block4.jpg

块级作用域就是通过词法环境的栈结构来实现的,而变量提升是通过变量环境来实现,通过这两者的结合,JavaScript 引擎也就同时支持了变量提升和块级作用域了。

总结:

  1. JavaScript 代码在执行的过程中,需要先做变量提升,之所以如此,是因为 JavaScript 代码再执行前需要进行编译
  2. 在编译阶段,变量和函数会被存放到变量环境中,变量的默认值为 undefined;块级作用域会被存放到词法环境中;
  3. 在编译阶段,存在两个相同的函数,后面的会覆盖前面的函数;
  4. 调用函数时,JavaScript 引擎维护了一个栈的数据结构(调用栈),每次调用函数都会将函数的执行上下文压入调用栈,然后 JavaScript 引擎开始执行函数代码;
  5. 当函数执行完毕后,JavaScript 引擎会将该函数的执行上下文弹出栈;
  6. 当分配的栈控件被占满时,会引发栈溢出的情况;

在最后一个例子中,我们在查找变量时,先从词法环境中查找,找不到会到变量环境中查找,直到找了为止,这个查找的逻辑底层是怎么实现的呢?this 是怎么定义的?闭包产生的原因?等等问题,JavaScript 引擎是工作的,请关注下一节《JavaScript 运行机制二》

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享