深入js基础:从内存机制、解析机制到执行机制(长文预警)

3,737 阅读43分钟

文章由来

最近有些懒, 加上公司项目较多, 没有来得及更。这次选题比较纠结, 本来想继续围绕webpack, 但无奈在私下和一些同学聊天时, 无意中发现大部分同学对Js运行机制和内存机制并不是很了解。在做了一些分享后, 于是决心梳理下Js的底层基础和原理, 所以有了这篇文章, 主要面向初中级前端同学。

这篇文章能学到什么?

堆,栈,垃圾回收机制,执行环境,执行上下文,执行栈,变量对象,活动对象,词汇环境,调用栈,作用域,变量提升,单线程,多线程,协程,事件循环,浏览器内核线程,事件队列,微任务,宏任务.....

相信还是有相当多一部分同学对上面提及的名词仅仅停留在听过或是了解一点的阶段,本文会一一对上面提到的专业术语进行归纳总结分析,通读此文,相信你的JavaScript基础会更加扎实。

什么是堆?什么是栈?js内存机制是什么?

任何一种语言都有数据类型的分类,某种语言是如何存放和分配这些数据的,这就是内存管理。那栈和堆与内存有什么关联呢? 首先要理解的是栈和堆也就是栈内存和堆内存, 它们都存放于内存中.

堆内存

用于存储Js中的复杂数据类型,当我们创建一个对象或数组或new一个实例的时候,就会在堆内存中开辟一段空间给它,用于存放。

堆内存可以动态地分配内存大小,生存期也不必事先告诉编译器,因为它是在运行时动态分配内存的,但缺点是,由于要在运行时动态分配内存,存取速度较慢。

栈内存

用于存储Js中的简单数据类型及对象的引用指针, 该指针指向的是堆内存中对应的值

栈内存的特点是后进先出, 可以直接存取的,而堆内存是不可以直接存取的内存。对于我们的对象来数就是一种大的数据类型而且是占用空间不定长的类型,所以说对象是放在堆里面的,但对象名称是放在栈里面的,这样通过对象名称就可以使用对象了。

垃圾回收机制

什么是垃圾? 声明了一个变量, 不管是原始还是复杂类型, 都会存储到内存中, 在代码执行完成后, 那些不在会被使用的数据就是所谓的"垃圾", 等待回收.

垃圾回收机制, 其实就是一种如何处理这些"垃圾"数据的机制, 它会自动回收.

它主要运用“标记清除”算法,垃圾回收从全局出发,把那些从“根”部无法触及到的不再引用(引用计数为0)的数据,定义为垃圾数据,进行回收。

什么是无法触及到的数据呢?

var a = 1
var b = 2
var obj = {
  c: 3,
  d: 4
}

上面代码定义了变量a, b, obj, 它们是在全局定义的, 当脚本执行完毕后, 变量a, b, obj的引用计数均为1, 此时垃圾回收并不会回收a, b, obj(因为此时通过window这个根,可以访问到变量a, b的值,以及变量obj的引用),除非手动的将其释放

a = null
b = null
obj = null

更简单的说,如果你声明的变量,不管是原始数据类型还是复杂数据类型,只要是从全局window对象出发,可以访问到的数据,就不会被垃圾回收器回收。

var obj = {
  c: 3,
  d: 4
}
var user = obj
obj = null

上面代码在全局定义了一个对象{c: 3, d: 4}(为了方便表述,我们将这个对象起名为A对象),它被存储在堆内存中,使用变量obj接受A的引用,并定义了user变量,也引用了A对象,之后将obj重新赋值为null, 此时A对象是否会被回收呢?

答案是不会的,上面说过,只要是从全局window对象出发,可以访问到的数据,就不会被垃圾回收器回收。

A对象怎么能从全局访问到呢?即 window.user 引用了 A 对象,所以不会被回收

如果手动设置 user = null, 这时从 window 出发,没有任何一个渠道可以访问到这个 A 对象,那么A对象就会被回收

所以,垃圾回收机制实际上回收的是内存中的数据,而不是变量,但是一个变量指向的数据被回收了,这个变量留着还有什么用呢?垃圾回收器自然也会将无用的变量进行回收

了解了 JavaScript的内存机制, 知道了js中不同变量存储在不同内存中, 那么 JavaScript是如何解析这些代码并执行呢? 我们都知道 JavaScript 的执行是分两种阶段, 预解析和执行, 那其中的机制是什么呢? 别着急, 让我们先聊聊js的执行环境。

JavaScript执行环境和执行栈

执行环境(执行上下文)

Javascript 的执行环境也就是执行上下文, 通俗的理解, 就是 Javascript 的某段代码是在哪个环境运行的, 它的上文是谁? 下文是谁?

为什么要引入执行上下文这个概念呢? 要执行代码, 首先得立个规矩, 你得先让我知道, 我写的某段代码在哪个环境中执行, 能访问到哪些变量, 然后我才能书写正确的逻辑.

那么这里说的环境究竟有哪些呢? JavaScript 中有三种执行上下文类型, 分别是全局执行上下文, 函数执行上下文, Eval 函数执行上下文. 三者的具体描述就不详细展开了, 很多文章都有详细的阐述, 这里只讲两点吧.

  • 全局执行上下文只有一个, 且在浏览器关闭后出栈;
  • 函数执行上下文有多个, 每调用一个函数, 都会创建新的上下文环境.

执行栈(调用栈)

细心的同学发现,全局上下文在浏览器关闭后会出栈。 哦哦,我知道了,这个栈就是栈内存嘛,非也。

这里的栈,是执行上下文栈,简称执行栈或调用栈, 里面保存了代码执行期间创建的所有执行上下文。 既然是栈,就有后进先出的特点。来看一段代码, 以最快速的方式了解下执行栈的执行顺序:

// test.js
var foo = 1
function bar() {
  function baz() {}
  baz()
}
bar()
  • 在执行这段代码时, JS引擎创建全局执行上下文, 并将其推入到执行栈
  • 调用bar, 创建 bar 函数的执行上下文, 推入执行栈, 那么此时 Javascript 执行环境就是 bar 函数的执行环境
  • bar函数内部调用了 baz 函数, 再次创建 baz 上下文, 推入执行栈, 此时 Javascript 执行环境就是 baz 函数的执行环境
  • baz 函数执行完后, 执行栈将 baz 函数的执行上下文弹出, 此时 Javascript 执行环境变为了 bar 函数的执行环境
  • bar 函数执行完后, 执行栈将 bar 函数的执行上下文弹出, 此时 Javascript 执行环境变为了 全局执行环境
  • 我关掉浏览器,执行栈清空。浏览器都关了,要什么执行环境呢?

ok,看到这里你大概知道 JavaScript 执行上下文是如何执行的了,但你可能就要问了, 不是应该先预解析再执行吗?预解析这一步呢?别慌,拍电影有个手法叫倒叙,现在让我们回头再来看看 JavaScript 是如何预解析?要弄明白 JS 的预解析过程,首先得知道执行上下文的创建过程。

执行上下文创建的过程

谈到预解析, 你可能第一个想到的就是变量提升和函数声明的提升, 想想它们为什么会提升,是在什么阶段进行提升的,提升的机制又是怎么的呢?所有的这些问题, 在 理解了 JavaScript 执行上下文创建的过程之后,都会得到相应的解释。

所以你明白为什么本文先讲执行上下文了吧?

在开始讲述创建执行上下文的过程之前, 有必要先了解下:标识符,变量对象,活动对象,词法环境,作用域...

1. 标识符

通俗的讲,在JS中所有可以由我们自主命名的都可以称为是标识符, 诸如变量名、函数名、属性名都属于标识符

2. 变量对象和活动对象

变量对象和活动对象是 ES3 中的概念,我们在程序中声明的变量、函数,解释器是如何并在哪找到我们的定义的数据,答案就是变量对象和活动对象了。

函数内部所有的变量和函数都保存在一个叫做变量对象中(这部分是隐式的),变量对象被调用的时候就称之为活动对象。

变量对象(variable object)下面简称VO 变量对象,就是与执行上下文相关联的特殊对象,它用来在执行上下文中存储变量、函数声明及函数的形参。它是执行上下文的一个属性。

ExecutionContext = { // 执行上下文
    VO: {
        // 上下文变量
    }
}

执行上下文不同,变量对象的初始结构、VO的名称也不同。

  • 全局执行上下文中,变量对象就是全局对象本身,在浏览器对象模型中,全局执行上下文中的变量对象就是 window 对象,是可以直接访问的。
  • 函数的执行上下文-VO是不可直接访问的,它就是所谓的活动对象(函数被调用的时候称之为活动对象)。

活动对象(activation object)下面简称AO活动对象。它存储着函数上下文中创建的变量和Arguments对象。

FunctionExecutionContext = { // 函数执行上下文
    AO = { // 函数活动对象
      arguments: <ArgO>
    }
}

3. 词法环境

词法环境是一种规范类型,代码的词法嵌套结构来定义 标识符 与特定 变量 和 函数 关联关系, 即词法环境定义了标识符(变量名)和实际变量对象的映射关系。和词法环境相关联的有 词法环境组件变量环境组件

一脸懵逼吧?这TMD都是啥?官方定义就是这样,让人知其然不知其所以然。 这里我用平民的语法来表述下什么是词法环境。

这么理解,什么是环境?不就是范围吗?什么是范围?不就是作用域吗?那词法环境不就是词法作用域吗?上解释:

在代码写好的时候,根据代码的书写结构就可以确定变量的作用域,这种作用域就是词法作用域,也就是词法环境。

词法环境是一个大类,你可以把它想象成一个父组件,它有两个子组件:词法环境组件和变量环境组件

3.1. 词法环境组件

词法环境组件中,主要存储的是函数声明和使用letconst声明变量的绑定

3.2. 变量环境组件

变量环境组件中,主要存储的是函数表达式和使用var声明变量的绑定

词法环境组件和变量环境组件内部还有两个组件:

  • 环境记录器:存储变量和函数声明的实际位置(类似于ES3中的变量对象)
  • 外部环境的引用:父级的词法环境(类似于[[Scope]]属性)

4. 作用域

作用域,就是变量能够被引用,函数能够生效的范围及区域。

我们都知道,Js是静态作用域,也是词法作用域,代码一旦书写,就规定了该变量或函数是属于哪一个作用域。

我们也知道,Js分为全局作用域、函数作用域、块级作用域,函数级块级作用域可以访问全局作用域;嵌套函数中,内部函数可以访问外部函数的作用域...可是,为什么可以访问呢?

你可以这么解释:因为js程序书写好的时候,根据代码的书写结构就可以确定变量的作用域,所以局部的可以访问全局的,嵌套函数中,内部的可以访问外部的。

是吗?这个仅仅是表象,以函数作用域为例,实际上:

一个函数在创建的时候,都会创建一个内部属性[[Scope]],其包含着该函数被创建时的父级上下文中的变量对象(事实上,函数是在创建父级执行上下文的过程中被创建的);

该函数在被调用时,函数的执行上下文作用域链,不仅包含着该函数的活动对象AO,也包含着该函数的[[Scope]]属性的拷贝。

var x = 10
function foo() {
    var y = 20
}
foo()
// 伪代码
// 1. 全局执行上下文中的VO
// GlobalContext = {
//    VO: {
//        x: 10,
//        this: window,
//        foo: <reference> to Function foo
//    }
// }
// 2. foo创建后,Scope属性为
// foo.[[Scope]] = {
//    VO: GlobalContext.VO,
//    this: window
// }
// 3. foo调用后,创建执行上下文,创建活动对象AO
// FooContext = {
//    AO: {
//        y: 20,
//        this: window
//    }
// }
// foo执行上下文创建后,该执行上下文的作用域链为:
// FooContext.Scope = {
//    AO: FooContext.AO,
//    VO: GlobalContext.VO(也就是foo.[[Scope]]的拷贝)
// }

上文提到,ES3 和 ES5中,对变量存储的定义完全不同,在ES5中,变量对象的概念已被词法环境模型所取代,所以下面分开讲述下 ES3 和 ES5 中创建上下文的过程。

ES3中执行上下文创建的过程

让我们通过代码及伪代码来具体分析下,ES3中是如何创建上下文的:

var x = 10;
function foo() {
  var y = 20;
  function bar() {
    var z = 30;
    alert(x +  y + z);
  }
  bar();
}
foo(); 
  • 打开浏览器运行该js脚本,首先创建全局执行上下文;
    • 创建变量对象(在这里为全局对象GlobalObject)并进行初始化,此时全局上下文中的变量对象如下:
      GlobalContext = {
          GO: {
              x: undefined,
              this: window,
              foo: <reference> to Function foo
          }
      }
      
    • 函数foo此时已经被创建,所以有了 [[Scope]]属性(函数作用域),其包含着对父级上下文中的变量对象(这里就是全局对象GO)的引用,所以,foo的[[Scope]]如下:
      foo.[[Scope]] = {
          GO: {
              x: undefined, // 此时为预解析阶段,执行阶段会进行赋值
              this: window,
              foo: <reference> to Function foo
          }
      }
      
  • 全局变量对象初始化完毕后, 进入执行阶段
  • 执行阶段先对全局变量对象进行更新赋值(GlobalVO)如下:
    GlobalContext = {
        x: 10,
        this: window,
        foo: <reference> to Function foo
    }
    // 同理,foo函数的[[Scope]]属性也就变成
    //(因为仅仅是对父级变量对象的引用,下文此类情况不再提及)
    // foo.[[Scope]] = {
    //    GO: {
    //        x: 10,
    //        this: window,
    //        foo: <reference> to Function foo
    //    }
    // }
    
  • 调用foo函数(一旦调用函数,就要创建函数执行上下文)
  • 开始创建foo函数执行上下文:首先,初始化变量,并填充到 foo 的活动对象(AO)中;其次,还记得上面创建foo函数时,foo函数内部的[[Scope]]属性吗?foo执行上下文拷贝函数内部[[Scope]]属性保存在上下文中的作用域链中,并将刚刚填充的AO放入执行上下文作用域链的最顶端;此时foo上下文中的活动对象/变量对象如下:
    FooContext = {
        AO: {
            y: undefined, // 预编译阶段不赋值
            this: window,
            arguments: [],
            bar: <reference> to Function bar // 创建了 bar 函数
        },
        GO: {
            x: 10,
            this: window,
            foo: <reference> to Function foo
        }
    }
    
    • 此时,bar函数已经被创建,所以bar函数创建了内部属性[[Scope]],其包含着对父级上下文活动对象/变量对象的引用(在这里为foo函数上下文的活动对象/变量对象)
    bar.[[Scope]] = {
        AO: {
            y: undefined,
            this: window,
            arguments: [],
            bar: <reference> to Function bar
        },
        GO: {
            x: 10,
            this: window,
            foo: <reference> to Function foo
        }
    }
    
  • 执行foo函数, 进行更新赋值操作
    FooContext = {
        AO: {
            y: 20,
            this: window,
            arguments: [],
            bar: <reference> to Function bar
        },
        GO: {
            x: 10,
            this: window,
            foo: <reference> to Function foo
        }
    }
    
  • 调用bar函数
  • 调用前先创建bar函数执行上下文,如何创建呢?同foo一样,首先将 bar 的活动对象放入bar的执行上下文作用域链的顶部;其次,将bar函数内部属性[[Scope]]拷贝至bar函数执行上下文的作用域链中。此时 bar 的上下文如下
    BarContext = {
        AO: {
            z: undefined,
            this: window,
            arguments: []
        },
        AO: {
            y: 20,
            this: window,
            arguments: [],
            bar: <reference> to Function bar
        },
        GO: {
            x: 10,
            this: window,
            foo: <reference> to Function foo
        }
    }
    
  • 执行bar函数, 进行赋值操作
    BarContext = {
        AO: {
            z: 30,
            this: window,
            arguments: []
        },
        AO: {
            y: 20,
            this: window,
            arguments: [],
            bar: <reference> to Function bar
        },
        GO: {
            x: 10,
            this: window,
            foo: <reference> to Function foo
        }
    }
    
  • 在 bar 函数中,执行 alert(x + y + z)
    • x: 在当前执行上下文中 (BarContext)中查找变量x,在GO中找到,为10
    • y: 在当前执行上下文中 (BarContext)中查找变量y,在第二层AO中找到,为20
    • z: 在当前执行上下文中 (BarContext)中查找变量z,在第一层AO中找到,为30
  • bar函数执行完成,其执行上下文弹出执行栈,执行权交还给foo执行上下文
  • foo执行完,其执行上下文弹出执行栈,执行权交还给全局执行上下文
  • 全部代码执行完,关掉浏览器,执行栈清空

再次梳理流程:

  1. 写好功能代码,打开浏览器运行
  2. 创建全局执行上下文,如何创建?(创建上下文的过程,就是js预解析的过程)
  • 创建并用初始值填充变量对象,将变量对象放入全局执行上下文中的VO中,无形中对变量做了提升操作
  • 如果全局代码中有函数,该函数声明已经在上一个步骤中放入变量对象中了,也就是说在全局变量对象中创建了一个函数
  • 在函数内部创建[[Scope]]属性,该[[Scope]]属性包含着父级上下文中的变量对象,这里就是全局变量对象(即使不调用函数,该[[Scope]]也会被创建)
  • [[Scope]]属性会一直保存在函数属性中,直到函数被回收
  1. 全局执行上下文创建完后,开始进入执行阶段
  2. 对全局对象变量中使用 var 声明的变量进行赋值
  3. 遇到函数调用时,首先是创建该函数的执行上下文
  • 创建并用初始值填充该函数的激活对象,将激活对象放入该函数的执行上下文中的AO中
  • 创建该函数执行上下文的作用域链,作用域链由激活对象和该函数的内部[[Scope]]属性组成
  1. 函数执行上下文创建完后,开始执行函数
  • 对变量进行赋值操作
  1. 函数执行完成后,该函数的执行上下文弹出
  2. 回到全局执行上下文
  3. 关闭浏览器,全局上下文弹出

要点总结:

  1. ES3中主要围绕的是变量对象活动对象来创建执行上下文
  2. 每次进入上下文时都会创建并用初始值填充变量对象(变量提升) ,并且其更新发生在代码执行阶段
  3. js的预解析其实就是发生在创建变量对象的过程中,使用初始值填充变量对象;同时,预解析只解析当前上下文中的变量。所以,你写好的程序,运行在浏览器时,只会解析全局变量对象,如果有函数,你不调用,函数内部的代码是不会进行预解析的!
  4. 函数声明会在进入上下文阶段时放入变量/激活对象(VO / AO)中
  5. 函数生命周期分为创建阶段和激活阶段(调用),创建阶段会创建内部属性[[Scope]],包含着对外部变量的引用
  6. 函数上下文的作用域链是在函数调用时创建的,由激活对象和该函数的内部[[Scope]]属性组成
  7. js就是通过作用域链的规则来进行变量查找(准确的说应该是执行上下文的作用域链)

ES5中执行上下文创建的过程

ES5创建执行上下文与ES3过程类似,只是执行上下文中的解构不同,它主要分为3个步骤

  1. 绑定this值
  2. 创建词法环境组件(let和const变量的绑定,函数声明)
  3. 创建变量环境组件(var变量的绑定和函数表达式)

上文说到,词法环境组件和变量环境组件都是词法环境,它们都有环境记录器和对外部环境的引用

实在不好理解,你可以简单的把环境记录器看成是ES3中的变量对象或活动对象,把对外部环境的引用看成执行上下文中的作用域。为什么呢?首先,环境记录器分为两种:声明式环境记录器和对象环境记录器,可以把它们分别对应为活动对象和变量对象;也就是说:

在全局环境中,ES5中用对象环境记录器来存储变量和函数,ES3中用变量对象来存储变量和函数

在函数环境中,ES5中用声明式环境记录器来存储变量和函数,ES3中用活动对象来存储变量和函数

用伪代码来表示ES5中执行上下文如下:

ExecutionContext = {
    ThisBinding = <this value>,
    // 词法环境
    LexicalEnvironment = {
        // 环境记录器,存储着该上下文中通过let const声明的变量,及函数声明
        // 根据上下文类型,可以理解为变量对象或活动对象
        EnvironmentRecord: {},
        // 当前上下文对外部环境引用,及作用域
        outer: <>
    },
    // 变量环境
    VariableEnvironment = {
        // 环境记录器,存储着该上下文中通过var声明的变量,及函数表达式
        // 根据上下文类型,可以理解为变量对象或活动对象
        EnvironmentRecord: {},
        // 当前上下文对外部环境引用,及作用域
        outer: <>
    },
}

除了创建上下文结构的不同,ES3和ES5对于 js 程序的解析和执行过程都是相同的,所以,如果你理解了 es3 对于上下文的创建过程,那么理解 es5 基本也不在话下了。

其实,仔细看上面的代码不难发现,bar 函数就是个闭包,但我们没有进行分析,下面简短的看看,理解下闭包的原理

// 稍微改下代码,将foo函数的返回值设置为 bar 函数
var x = 10;
function foo() {
  var y = 20;
  function bar() {
    var z = 30;
    alert(x + y + z);
  }
  return bar
}
var fn = foo()
fn()

什么是闭包就不用讲了吧?其实如果在全局声明了一个函数,函数内部使用了全局变量,这个函数就是一个闭包,只不过因为是在全局,而全局变量是可以被任何地方引用到的,所以这个闭包有点大,“包”里装的是全局的所有变量;

上面代码中,foo函数就是一个引用了全局变量的闭包函数,当然,如果是全局的话,说它是闭包确实有点奇怪。所以,我们理解的闭包,其实是:在内部函数中,引用了外部函数的变量,那么这个内部函数就是闭包,这个“包”中,装满了外部函数的变量。参考下我们在上面分析 es3 中创建上下文过程中的步骤理解就很容易了

  1. 创建全局上下文,初始化变量,并将初始化后的变量填充到全局上下文的变量对象中;
// 此时全局上下文的变量对象
{
    x: undefined,
    foo: Function foo() {},
    fn: undefined
}
// 这一步中创建了 foo 函数
// 同时创建了foo函数的内部属性[[Scope]]
// [[Scope]]属性就包含了全局变量对象的引用(一个闭包)
  1. 创建完成后,执行代码,进行变量赋值
    • x = 10
    • 调用 foo, 将返回值赋值给 fn 变量
  2. 上一步中调用了 foo 函数,所以要创建 foo 函数执行上下文,这里忽略初始化这一步,直接进入执行阶段
// 执行阶段的 foo 执行上下文如下:
FooContext = {
    AO: { 
        y: 20,
        bar: Function bar() {}
    },
    VO: { // 全局变量对象
        x: 10,
        foo: Function foo() {},
        fn: Function bar() {},
    }
}
// 同理,创建foo上下文时,就创建了 bar 函数
// bar 函数的内部属性[[Scope]]也就创建了,它包含着 foo 上下文中的活动对象和全局变量对象
// 即:
bar.[[Scope]] = {
    AO: { 
        y: 20,
        bar: Function bar() {}
    },
    VO: {
        x: 10,
        foo: Function foo() {},
        fn: Function bar() {},
    }
}
  1. foo函数返回了 bar 函数,foo执行完毕,foo执行上下文出栈,回到全局执行上下文中
  2. 现在上下文是全局执行上下文,所以,函数foo已经不存在了,foo内部的变量 y 和函数 bar 也不存在了,有的仅仅是 bar 函数的函数定义,并且将其赋值给了全局的 fn 变量。
    • 你可能就要问了,y 变量不存在了,为什么 bar 函数还能引用到?这里就是闭包的功劳了
  3. 还记得 bar 函数创建时的 [[Scope]] 属性吗?它有父级函数 foo 的活动对象,也有全局中的变量对象
  4. 所以,在执行 fn 时,先创建了fn的执行上下文(实际上是 bar 函数定义的执行上下文,再次强调,此时bar已经不存在,存在的只是函数定义),该执行上下文将 bar 函数定义的 [[Scope]] 属性拷贝一份到该执行上下文中,然后初始化函数内部的变量 z。
// 此时 fn 的执行上下文如下
FnContext = {
    AO: { // bar函数定义的活动对象
        z: 30
    },
    AO: { // foo函数的活动对象
        y: 20,
        bar: Function bar() {}
    },
    VO: {
        x: 10,
        foo: Function foo() {},
        fn: Function bar() {},
    }
}
  1. 执行阶段,alert(x + y + z),延着当前执行上下文中的作用域链查找变量 x, y, z,以此为10,20,30,进行输出

注意:上面代码中:var fn = foo() 将foo函数的返回值赋值给变量 fn,因为存在引用,即 fn 变量引用了 foo 函数的返回值,这个返回值是 bar 函数的定义,所以即使不调用 fn,函数的定义及其闭包(foo内部的变量)都会一直存在于内存中,不会被垃圾回收机制回收。只有当一个对象或函数没有任何引用变量引用它时,系统的垃圾回收机制才会在核实的时候回收它。所以慎用闭包,以免导致内存泄漏!

给个小建议:如果 es5 中创建执行上下文的过程不理解,可以先看通 es3 中对上下文的创建过程,无非是对变量的存储结构的不同,其原理都是类似的,只要明白,创建过程其实就是解析的过程和作用域链生成的过程就好。

进程与线程

对于进程和线程傻傻分不清?没关系,老规矩,上定义:

  • 进程是操作系统分配资源和调度任务的基本单位
  • 线程是建立在进程上的一次程序运行单位,一个进程上可以有多个线程。

抛开官方枯燥的定义,平民化定义进程与线程,拿钻孔台车举例,钻孔台车分单臂和多臂,上图:

重新定义下进程与线程

  • 进程是你能拿出的钻孔台车的数量,如果你能拿出一台,就是单进程,如果能拿出多台,就是多进程
  • 线程是建立在钻孔台车种类的基础上,你是单臂还是多臂,如果是单臂,就是单线程,多臂就是多线程

这下彻底明白了吧?众所周知,js是单线程语言,也就是单臂,一次只能干一个事;而浏览器是多进程多线程,你可以一次开启多个浏览器标签,这是多进程的体现;每个浏览器标签中(浏览器内核)是多线程,其分为GUI渲染线程、JS引擎线程、事件触发线程、定时触发器线程、异步HTTP请求线程。对于具体的线程,我们下面在将js执行机制时在详细阐述。

协程

这里对协程的介绍,参考了阮一峰老师在ECMAScript 6 入门中对Generator 函数的介绍,其作用是为了更好的控制异步任务的流程,具体送上传送门

因为除了在es6中协程的体现并不是很明显,所以这里不做展开,后期会考虑专门写篇关于协程的文章。

JS的执行机制

js代码要想运行,必须经过预解析和执行两个阶段。

上文中,我们花了很大篇幅去讲js执行上下文及其创建的过程,其实上下文创建的过程,完全可以理解为js的解析机制,在解析完后,上下文创建ok了,此时就需要执行代码了。

你可能要说了,还能怎么执行,按顺序从上到下执行呗。

非也,js代码也有好多种,同步、异步、定时器、绑定事件、ajax请求等等。js的执行机制,规定了代码的执行顺序,从而确保了一个单线程语言,不会因为某段代码执行时间过长而造成阻塞,这种执行机制,就是 js 的事件循环。

其实,在解释事件循环的概念时,很多文章提及到了同步任务异步任务微任务宏任务。结合代码,基本分析如下:

console.log(1) // 同步
setTimeout(function() {// 异步
    console.log(2)
}, 10)
console.log(3) // 同步
// 执行栈中,同步先执行,输出 1
// 异步任务的回调函数加入到任务队列。
// 接着同步任务,输出 3
// 同步任务执行完成,如果异步任务有了结果(10ms过后)
// 将任务队列中异步的回调函数加入到执行栈中执行,输出2
// 结果:1 3 2

可是一旦加入Promise和process.nextTick,局面就不一样了

console.log(1) // 同步
setTimeout(function() {// 异步
    console.log(2)
}, 10)
new Promise((resolve) => {
    console.log('promise') // 同步
    resolve()
}).then(() => {
    console.log('then') // 异步
})
console.log(3) // 同步
// 执行栈中,同步先执行,输出 1
// 异步任务的回调函数加入到任务队列。(console.log(2)和console.log('then'))
// 接着同步任务,输出 promise 和 3
// 同步任务执行完成,如果异步任务有了结果(10ms过后)
// 将任务队列中异步的回调函数加入到执行栈中执行,输出 2 then(队列先进先出)
// 理想结果:1 promise 3 2 then
// 正确输出:1 promise 3 then 2

结果对吗?显然不对,两个异步任务console.log(2)console.log('then')执行顺序错了,队列不是先进先出的吗?2先进去,then后进去,为什么执行结果反过来了?它们都是异步任务,这时单单拿同步和异步来解释已经解释不同了。

所以,很多文章自然引出了微任务宏任务

宏任务:整体代码script,setTimeout,setInterval

微任务:Promise,process.nextTick

执行机制变成:先执行整体代码(宏任务),执行完成后,执行微任务,微任务执行完,第一轮事件循环完毕;开启第二轮:读取任务队列,看是否有宏任务,如果有就执行......

上面代码,因为promise是微任务,所以它优先于setTimeout执行。

确实这么解释并没有错,而且是正确的,这里我并没有对他们的解释进行反驳。但是这样解释带来的疑惑就是:

  1. 任务队列是啥?
  2. 怎么划定宏任务和微任务?
  3. 我怎么知道整体代码script是宏任务,Promise是微任务?全凭记忆?
  4. 异步ajax是微任务还是宏任务?
  5. 用户点击事件的回调是微任务还是宏任务?

这么多疑问,没关系,让我带着你们一一破解。首先有必要先了解下浏览器内核。

浏览器内核线程和事件队列

在介绍进程和线程时我们谈到了浏览器内核是多线程的,它包括如下几个线程:

  • GUI渲染线程:主要用来处理DOM树解析,渲染,重绘(与DOM相关)等
  • JS引擎线程:执行javascript脚本
  • 事件触发线程:负责管理事件队列,并交给 js 引擎线程执行。诸如DOM绑定的事件(onclick,onmouseenter等)、定时器计时结束、请求结束,当满足条件后,对应的回调函数会添加到事件队列(也就是任务队列)中。
    • 事件队列:主要是来存放不同事件(定时器,用户触发的事件,请求事件)的回调函数,当达到了相应的条件(如定时器到时,用户点击按钮,请求完成并响应)后,事件触发线程,会将满足条件的回调函数,添加到 js 引擎线程中的任务队列中等待执行。
  • 定时器线程:setTimeout、setInterval所在的线程,主要对定时器进行计时,定时时间到后,由事件触发线程将回调函数添加到事件队列
  • 异步网络请求线程:在该线程内进行异步请求,请求状态变更后,如果有回调,由事件触发线程将回调处理添加到事件队列

结合浏览器内核,我们来看看什么是微任务和宏任务,这里再一次列出部分文章对微任务宏任务的说明:

宏任务:整体代码script,setTimeout,setInterval

微任务:Promise,process.nextTick

宏任务

首先明确,宏任务和微任务,都是异步任务

所谓的宏任务,就是需要其它线程处理的任务,这里的其它线程包括JS引擎线程事件触发线程定时器线程异步网络请求线程,所以一旦和上面4个线程挂钩,它就是宏任务。我们拿上面列出的宏任务一一解释下:

  • 整体代码script (JS引擎线程)
  • setTimeout,setInterval (定时器线程)
  • ajax异步请求(异步网络请求线程)
  • onclick事件绑定(事件触发线程)

是不是比上面给出的宏任务种类多?所以,记住:凡是涉及到浏览器内核线程的任务,都是宏任务。

微任务

微任务通常来说就是需要在当前任务执行结束后立即执行的任务,比如异步的任务但又不需要 JS引擎线程 处理的任务(除字很关键!)。

首先它是异步的,其次,它不需要诸如事件触发线程定时器线程异步网络请求线程来处理。来看看Promise,process.nextTick,浏览器中有专门的内核线程来处理吗?你可能会说它是由JS引擎线程执行的啊,确实,但它是个异步,所以是微任务。

你可以这么理解:不是宏任务的任务就是微任务。

事件循环

罗嗦一大堆,终于来到事件循环了,有了前面理论的铺垫,事件循环就很好理解了。

再回味下,事件循环就是 Javascript 的执行机制,即,规定了 Javascript 代码执行的流程:

  1. 执行整段 JavaScript 代码,将整段代码中的同步任务放入执行栈中执行(上面说了,这个是宏任务)
  2. 代码中如果有 setTimeoutajax等宏任务,会利用对应的浏览器的内核线程来处理,达到条件(定时器时间达到,请求完成)后,由事件触发线程将其对应的回调加入到事件队列(任务队列)中
  3. 如果有Promise等微任务,加入微任务队列,在执行栈执行完当前的同步任务的之后,从微任务队列中取出微任务,立即执行
  4. 所有的微任务执行完,此时,执行栈中处于闲置状态(注意:本轮事件循环中所有微任务执行完,开启下轮循环)
  5. 以上是第一轮事件循环,以下开始第二轮:
  6. 事件队列将队列中的任务加入到执行栈,按先进先出的顺序执行
  7. 如果此时进入栈中的任务既有同步任务,微任务和宏任务,那先执行同步任务
  8. 再执行所有的微任务
  9. 第二轮事件循环结束,开始第三轮循环:
  10. 执行宏任务...循环往复,直到所有任务执行完毕。

下面用三段代码由浅入深的分析下:

案例一:只有宏任务
console.log(111)
setTimeout(function() {
    console.log(222)
}, 5000)
$.ajax({
    url: '',
    success: function() {
        console.log(333)
    }
})
console.log(444)
  1. 运行代码,先执行整段代码的同步任务console.log(111),输出 111
  2. 遇到 setTimeout,将其交给定时器线程处理
  3. 遇到 ajax请求,将其交给异步网络请求线程处理,
    • 注意:此时事件队列只有一个任务: ajax成功的回调(前提是,这个请求开始到结束响应耗时低于5秒,以下默认不解释)
    • 咦?为什么呢?实际上,前面也说了,setTimeout是在定时器线程中计时,5秒后才放入事件队列中,而此时,ajax执行很快,还不到5秒,所以setTimeout的回调还不会加入到事件队列中
  4. 执行同步代码console.log(444),输出 444
  5. 因为此代码没有微任务,所以第一轮事件循环结束,准备开始第二轮事件循环
  6. 同步代码执行完,此时执行栈为空,要去事件队列中取任务
    • 此时注意下,可能事件队列中还没有任务
    • 因为定时器线程5秒后,才由事件触发线程将其回调函数交给事件队列
    • 同时,异步网络请求线程,在请求完成响应200后,才由事件触发线程将其成功的回调函数交给事件队列
    • 所以,如果请求时间过长,或执行之前的代码时间不足5秒,事件队列都有可能为空
  7. 假设,ajax请求时间为2秒,此时事件队列中就有了ajax成功的回调,取出该回调,输出 333
  8. 5秒后,定时器线程setTimeout的回调加入到事件队列中
  9. 因为执行栈已经为空,所以直接取事件队列中的setTimeout的回调,输出222
  10. 结果:111 444 333 222
  11. 如果ajax请求时间大于定时器的5秒,那么结果是:111 444 222 333
  • 因为不好限制请求耗时,下面将setTimeout的定时均设置为0
  • setTimeout定时设置为0的意思是:运行代码时,一旦运行到setTimeout这行,定时器线程会立即把setTimeout的回调放入事件队列中,一旦执行栈空闲,立即取出执行。
  • 此时,就不存在ajax和setTimeout不好判断的问题了。事件队列中,ajax的回调会一直在setTimeout回调之后
案例二:宏任务和微任务(一)
console.log(111)
setTimeout(function() {
    console.log(222)
}, 0)
new Promise((resolve) => {
    console.log('333')
    resolve()
}).then(() => {
    console.log('444')
})
$.ajax({
    url: '',
    success: function() {
        console.log(555)
    }
})
console.log(666)
  1. 运行代码,先执行整段代码的同步任务console.log(111),输出 111
  2. 遇到 setTimeout,将其交给定时器线程处理,0秒后,才由事件触发线程将其回调函数交给事件队列
  3. 遇到 Promise,Promise为立即执行函数,所以输出 333
  4. 它有一个 then 回调,是个微任务,先不执行
  5. 遇到 ajax请求,将其交给异步网络请求线程处理,请求完成响应200后,由事件触发线程将其成功的回调函数交给事件队列
    • 此时假设ajax请求完成,故现在的事件队列为 ①setTimeout回调,②ajax回调
  6. 执行同步代码console.log(666),输出 666
  7. 同步任务执行完,查看是否有微任务,有微任务,为 then 回调,开始执行,输出 444
  8. 没有其他微任务了,此时执行栈为空,第一轮事件循环结束,准备开始第二轮事件循环
  9. 去事件队列中取任务
  10. 首先取出setTimeout的回调,输出 222
  11. 然后取出ajax的回调,输出 555
  12. 结果:111 333 666 444 222 555
案例三:宏任务和微任务(二)
console.log(111)
new Promise((resolve) => { // promise1
  console.log(222)
  new Promise((resolve) => { // promise2
    console.log(333)
    resolve()
  }).then(() => {
    console.log(444)
  })
  resolve()
}).then(() => {
  console.log(555)
  new Promise((resolve) => { // promise3
    console.log(666)
    resolve()
  }).then(() => {
    console.log(777)
  })
})
setTimeout(function() {
  console.log(888)
}, 0)
console.log(999)
  1. 执行代码,当前执行栈为全局执行上下文栈,执行第一行代码console.log(111),输出 111
  2. 遇到promise1,立即执行,输出 222
  3. promise1内部还包含 promise2,立即执行,输出 333,将 promise2 的then回调加入到微任务队列
  4. promise1 的then回调加入到微任务队列,它在promise2 的then回调之后
  5. 遇到 setTimeout,将其交给定时器线程处理,0秒后,才由事件触发线程将其回调函数交给事件队列
  6. 执行最后一行代码,输出 999
  7. 执行完所有同步任务,执行栈此时没有任务了,有微任务吗?有,微任务队列中有两个微任务,依次取出
  8. 取出第一个微任务,为 promise2 的then回调,加入到执行栈执行,输出 444
  9. 取出第二个微任务,为 promise1 的then回调,加入到执行栈执行,输出 555,此时promise1 的then回调中又包含了 promise3,执行promise3,输出 666,将promise3 的then回调加入到微任务队列
  10. 执行栈任务为空,微任务队列中有任务吗?有,为刚刚加入的 promise3 的then回调
  11. 取出promise3 的then回调,加入到执行栈执行,输出 777
  12. 微任务队列为空了,第一轮事件循环执行完毕,开始第二轮循环,去事件队列中取任务
  13. 执行setTimeout的回调,输出 888,没有其他任务了,事件循环结束
  14. 结果:111 222 333 999 444 555 666 777 888
案例四:宏任务和微任务互相嵌套
console.log(000)
setTimeout(function() {
  console.log(111)
  setTimeout(function() {
    console.log(222)
    new Promise((resolve) => {
      console.log(333)
      resolve()
    }).then(() => {
      console.log(444)
    })
  }, 0)
}, 0)

new Promise((resolve) => {
  console.log(555)
  resolve()
}).then(() => {
  new Promise((resolve) => {
    console.log(666)
    setTimeout(function() {
      resolve()
    }, 0)
  }).then(() => {
    console.log(777)
    setTimeout(function() {
      console.log(888)
    }, 0)
  })
})

$.ajax({
  url: '',
  success: function() {
    console.log(999)
  }
})

console.log(1010)

是不是快疯了?这都是什么玩意,平时写代码谁这么写?确实,这么写容易引起公愤,但相信,如果这段代码你能完整分析,js的执行机制你真的是没问题了。下面开始分析咯,准备好心态~

  1. 执行代码,当前执行栈为全局执行上下文栈,执行第一行代码console.log(000),输出 0
  2. 遇到第一个setTimeout(这里叫它setTimeout1回调),将其交给定时器线程处理,0秒后,才由事件触发线程将其回调函数交给事件队列
    • 事件队列目前有:①setTimeout1回调
  3. 遇到 Promise,立即执行,输出 555,其 then 回调为微任务,先不执行
  4. 遇到ajax,将其交给异步网络请求线程处理,假设该请求耗时10秒,10秒后,由事件触发线程将其成功的回调函数交给事件队列
  5. 执行最后一行同步代码console.log(1010),输出 1010
  6. 同步代码执行完,有微任务吗?有,promise的then回调函数,执行
  7. then回调中又包含一个 Promise,先执行 Promise,输出 666,其 then 回调为微任务,先不执行
  8. 然后看到该Promise还包含setTimeout(这里叫它setTimeout2回调),所以将其交给定时器线程处理,0秒后,才由事件触发线程将其回调函数交给事件队列
    • 事件队列目前有:①setTimeout1回调,②setTimeout2回调
  9. 因为其 resolve 在setTimeout中,故,此轮微任务不执行该 then回调
  10. 还有微任务吗?没了,全局执行上下文栈也为空了。第一轮事件循环结束,准备开始第二轮事件循环
  11. 去事件队列中取任务,事件队列目前有:①setTimeout1回调,②setTimeout2回调
  12. 先取出setTimeout1回调,执行,首先输出 111
  13. 它内部还有 setTimeout(这里叫它setTimeout3回调),将其交给定时器线程处理,0秒后,才由事件触发线程将其回调函数交给事件队列
    • 事件队列目前有:①setTimeout2回调,②setTimeout3回调
  14. 执行完setTimeout,有微任务吗?没有,全局执行上下文栈为空了。第二轮事件循环结束,准备开始第三轮事件循环
  15. 去事件队列中取任务,事件队列目前有:①setTimeout2回调,②setTimeout3回调
  16. 先取出setTimeout2回调,执行,setTimeout2的回调函数是啥呢?还记得第一轮事件循环中的 resolve 吗?
  17. 执行resolve,即promise的then回调,输出 777
  18. 它内部还有 setTimeout(这里叫它setTimeout4回调),将其交给定时器线程处理,0秒后,才由事件触发线程将其回调函数交给事件队列
    • 事件队列目前有:②setTimeout3回调,④setTimeout4回调
  19. 执行完setTimeout2,有微任务吗?没有,全局执行上下文栈为空了。第三轮事件循环结束,准备开始第四轮事件循环
  20. 先取出setTimeout3回调,执行,首先输出 222
  21. setTimeout3内部有 Promise,执行,输出 333
  22. 有微任务吗?有,为promise的then回调,执行,输出 444
  23. 没有微任务了,全局执行上下文栈为空了。第四轮事件循环结束,准备开始第五轮事件循环
  24. 先取出setTimeout4回调,执行,输出 888
  25. 有微任务吗?没有,全局执行上下文栈为空了。第五轮事件循环结束
  26. 10秒过后,事件队列中有了ajax的回调
  27. 全局执行上下文栈此时为空,所以立即取出ajax的回调,执行,输出 999

结果为:0, 555, 1010, 666, 111, 777, 222, 333, 444, 888, 999

如果你没看懂,正常,一是因为代码确实太变态,没人那么写;二确实是我分析的有些罗嗦,没办法了,已经尽最大努力简化分析了。不过真的建议各位同学能仔细分析下它的执行顺序,研究明白了,JS的执行机制就彻底明白了。

个人觉得还是有一点遗憾,没有把JS的执行机制和执行栈相关联进行解释,下面最后一个案例,让我们彻底把JS执行机制和上下文结合起来进行分析。

<script>
console.log(111)
function foo() {
  new Promise(function(resolve) { // Promise1
    console.log(222)
    resolve()
  }).then(function() {
    console.log(333)
  })
  setTimeout(function() { // setTimeout1
    console.log(444)
  }, 0)
}
foo()
new Promise((resolve) => { // Promise2
  console.log(555)
  resolve()
}).then(() => {
  console.log(666)
})
setTimeout(function() { // setTimeout2
  console.log(777)
}, 0)
console.log(888)
</script>
  1. 全部代码进入浏览器运行,此时要创建全局上下文栈,如何创建过程就不重复讲解了。
  2. 创建完成,在全局上下文栈中执行同步任务,输出 111
  3. 遇到函数foo的调用,开始创建foo函数执行上下文栈
    • 此时的执行栈为:[foo函数栈,全局栈]
  4. 在foo函数执行上下文栈中,遇到Promise1,它的第一个参数为立即执行的,而且参数为一个匿名函数
  5. 创建匿名函数执行上下文栈(这里叫它匿名栈1),执行console.log(222)输出 222;遇到调用resolve,注意:这个resolve()是将Promise对象的状态从“未完成”变为“成功”,因为此处Promise1中没有异步任务,所以等console.log(222)执行完,会立即调用resolve。
    • 此时的执行栈为:[匿名栈1,foo函数栈,全局栈]
  6. 调用resolve,创建匿名函数执行上下文栈(这里叫它匿名栈2),此时程序跳到 then 方法,因为Promise的 then 方法是一个微任务,所以此时不会执行,将其放入微任务队列中待执行。
    • 此时的执行栈为:[匿名栈2,匿名栈1,foo函数栈,全局栈]
  7. resolve调用时创建的匿名函数执行上下文栈出栈
    • 此时的执行栈为:[匿名栈1,foo函数栈,全局栈]
  8. resolve调用完后,Promise1中第一个匿名函数没有其他执行的代码,所以匿名栈1也出栈
    • 此时的执行栈为:[foo函数栈,全局栈]
  9. 此时执行上下文栈为 foo 函数栈,foo函数内部还有个 setTimeout,将它(setTimeout1)交给定时器线程处理,0秒后,才由事件触发线程将其回调函数交给事件队列
  10. foo函数执行完毕,foo函数栈出栈
    • 此时的执行栈为:[全局栈]
  11. 回到了全局执行上下文栈中,遇到Promise2,立即执行它的第一个回调函数,创建匿名函数执行上下文栈(匿名栈3)
    • 此时的执行栈为:[匿名栈3,全局栈]
  12. 在当前匿名栈3中,执行输出 555,调用resolve(同上,此处不在详细书写栈的变化),将其 then 回调放入微任务队列
    • 此时微任务队列中为:[Promise1的then,Promise2的then]
  13. 匿名函数执行完,匿名栈3出栈
    • 此时的执行栈为:[全局栈]
  14. 回到全局栈,遇到setTimeout2,交给定时器线程处理,0秒后,才由事件触发线程将其回调函数交给事件队列
    • 事件队列此时为:[setTimeout1回调,setTimeout2回调]
  15. 执行最后一行代码,输出 888
  16. 全局栈中同步代码执行完毕,取出微任务队列执行
    • 微任务队列为[Promise1的then,Promise2的then],秉承先进先出的原则
  17. 先执行Promise1的then回调,同样,创建匿名函数执行上下文栈(匿名栈4),创建完后执行,输出 333
    • 此时的执行栈为:[匿名栈4,全局栈]
  18. 匿名栈4出栈,此时的执行栈为:[全局栈]
  19. 再执行Promise2的then回调,同样,创建匿名函数执行上下文栈(匿名栈5),创建完后执行,输出 666
    • 此时的执行栈为:[匿名栈5,全局栈]
  20. 执行完后,匿名栈5出栈,此时的执行栈为:[全局栈],同时微任务队列为空,第一轮事件循环结束,开始第二轮
  21. 取出事件队列中的任务,事件队列为:[setTimeout1回调,setTimeout2回调],秉承先进先出的原则
  22. 先执行setTimeout1回调,同样,创建匿名函数栈6,执行输出 444,匿名栈6出栈,回到全局栈
  23. 再执行setTimeout2回调,同样,创建匿名函数栈7,执行输出 777,匿名栈7出栈,回到全局栈
  24. 结果:111 222 555 888 333 666 444 777

总结收尾

  • 不管是什么样的代码,有异步也好,鼠标事件也好,将它们分门别类,微任务放入微任务队列,宏任务由对应线程处理,放入事件队列中;
  • 等同步任务执行完,取微任务队列,执行所有微任务;
  • 没有微任务了,执行事件队列中的代码;
  • 微任务队列和事件队列,秉承先进先出的原则;
  • 如果有函数调用,且函数内部有微任务或宏任务,其微任务或宏任务都是在全局执行上下文栈中执行

在回顾下本文涉及到的知识点

堆,栈,垃圾回收机制,执行环境,执行上下文,执行栈,变量对象,活动对象,词汇环境,调用栈,作用域,变量提升,单线程,多线程,协程,事件循环,浏览器内核线程,事件队列,微任务,宏任务.....

如果你耐着性子看到这,可以说是真不容易。我承认确实写的有些罗嗦,罗嗦的原因是,想更多的照顾下初入前端,或基础不扎实的同学。如果你完全掌握了其中的原理,也可以跳跃性的浏览一番,看看我的解释是不是和你的相同,也非常希望各位大佬们能提出宝贵的建议,文中有出现错误的地方,欢迎指出,多多学习交流。