不懂就问 | 浏览器如何运行 JavaScript(EventLoop)

1,148 阅读10分钟

浏览器如何运行 JavaScript

需要铺垫的知识点:执行环境 + 执行栈

执行环境(Execution Context)

执行环境是函数被调用时所在的环境。执行环境中存储了函数执行时相关的所有事物。当我们在函数内访问某一变量时,这个变量其实也是该函数执行环境提供的。因为执行环境是不能直接被访问的(不能被访问代表我们在函数内部不能使用任何变量),所以ECMA标准在函数被调用时,会构造一个能够被访问的对象——执行环境对象(Execution Context Object),这个对象上包含三个属性:变量对象、作用域链、this的值。

这三个属性的作用:

  1. 变量对象:提供对「与执行环境相关的变量和函数」的访问和存储的一个对象,也就是当前作用域。(Variable Object 下文简称VO)

在全局执行环境中,VO 就是全局对象。在函数执行环境中,因为 VO 不能被直接访问的,这是会提供一个活动对象(Activation Object)简称 AO 来扮演 VO 的角色。AO 在进入函数执行环境的那一刻被创建。 函数接收到的参数,存储在变量对象的arguments属性上。 函数内部定义的变量和函数,都会在变量对象上拥有一个同名属性,变量的属性值为变量值,函数的属性值为该函数的指针。

  1. 作用域链:当我们在函数内部访问某一变量时,就会在作用域链上进行查找。查找顺序是从作用域链顶部到最外层作用域也就是全局作用域,当找到变量或者找到全局作用域为止。

作用域是用来查找标识符的一种规则。决定了在当前函数内声明的标识符的可作用范围。 如果发生作用域嵌套,则会形成作用域链。作用域链包括当前作用域(也就是当前执行环境的变量对象)加上外层作用域链。 每个函数对象的作用域链都被存储在其内部属性[[Scope]]上,作用域链上的外部作用域是通过复制外部函数的[[Scope]]属性构成的。 即Scope Chain = [AO].concat([[Scope]])

  1. this的值:因为函数可以在不同的执行环境被执行,所以 JS 设计出了 this 的概念,用于指向真正运行的执行环境。

当函数作为某个对象的属性调用时,this指向那个属性。当函数自主调用时,this指向undefined,在非严格模式下,this又指向全局对象,在浏览器中则是window对象。

执行环境分为三种:

  1. 全局执行环境(Global Execution Context)
    全局执行环境是JS代码一被加载时,就会生成的默认执行环境。是最外围最大的执行环境,有且仅有一个。浏览器在加载 JS 代码时,指定window为全局执行环境的变量对象,所有变量和函数都是定义在window 对象上的某个属性。 全局执行环境是一直存在的,直到浏览器关闭窗口后,才会被销毁。
  2. 函数执行环境(Functional Execution Context)
    函数执行环境又叫做局部执行环境,顾名思义,就是 JS 引擎在识别到有函数被调用,即会创建出的一个函数执行环境,可以有多个。当一个函数被执行完毕时,它所在的执行环境就会被销毁。
  3. Eval 执行环境
    在 eval 函数中的执行环境。

执行栈(Execution Stack)

浏览器用 JS 引擎执行 JS 代码,而 JS 引擎构建出执行栈用来记录程序运行情况。执行栈遵循栈数据结构,先进先出,每当遇到一个函数调用,就会创建出其执行环境压入执行栈栈顶,当函数执行完成后将其推出执行栈。保证执行流按照执行栈的顺序有序执行。

需要铺垫的知识点:浏览器的多个线程

JS 是单线程

JS 是单线程语言意味着只会有一条线程在执行 JS,一次也只会执行一个 JS 任务,其余任务都需要排队等待上一个任务执行完毕才能执行。

同步 & 异步

同步:每个任务按照顺序进行执行,必须等待前一个任务执行完毕,后一个任务才可以开始。

异步:可以将一个任务分成两段,先执行第一段,然后执行其他任务,等做好了准备,再来执行第二段。

为什么 JS 要设定为单线程

单线程的特点就是同一时间只能做一件事情,而 JS 是被作为浏览器脚本语言使用的,主任务是提供用户与页面交互的能力。设想一下如果浏览器是多线程,有两个线程同时在对一个 dom 进行操作,这时浏览器就会不知道以哪个线程为准。

为什么需要异步任务

我们知道,浏览器上有很多操作是很耗时的,比如请求数据、加载媒体文件等,如果都是同步任务,则需要等待一个一个耗时操作的完成,而相对次要的耗时操作其实不应该让用户有等待的感觉,用户体验会非常的不好。所以会通过一些其他线程来实现异步的形式。

浏览器运行 JS 时涉及到的几个线程

  1. JS 引擎线程:用于执行 JS 代码,JS 引擎有多个线程,由一个主线程和 n 个其他线程配合一起工作。主线程用于执行当前执行栈内的任务。
  2. 事件触发线程:用来存放异步事件,每一个异步事件触发后,都会交给事件触发线程管理,形成一个任务队列。
  3. 定时任务线程:用于管理设定的定时任务,到达设定时间后,会将定时任务的回调函数推到事件触发线程管理的任务队列上。

浏览器运行 JS 的工作流程

相当于执行栈与以上三个线程的工作流程

  1. 浏览器加载 JS 代码后,JS 引擎主线程会构建出一个执行栈,用于主线程读取当前可执行任务。
  2. 执行流按行读取JS代码,每遇到一个函数调用便会创建出一个新的执行环境并将其压入执行栈栈顶。
  3. 主线程读取执行栈栈顶的任务进行执行,执行流进入函数内部开始执行,执行完成后将执行环境做出栈操作,执行流回到下一个执行环境开始执行。
  4. 主线程只会执行同步任务代码,当遇到异步事件时会将其托管给对应的其他线程代为执行,当异步事件满足执行回调的条件时,会将其回调函数放入事件触发线程管理的任务队列的末尾,当可执行栈内为空时,主线程才会去读取任务队列列头的任务压入执行栈进行执行。JS 的工作流程就是反复上述执行过程。

主线程在执行栈和任务队列进行反复轮询的过程就是我们常说的 JS 执行机制 —— 事件循环(Event Loop)。

微任务 & 宏任务

JS 又把异步任务分为了2类:宏任务 + 微任务

所有异步任务一开始都会被汇总到一个事件列表(Event Table)中,当满足塞入事件队列(Event Queue)的条件时(比如异步请求完成、定时任务到达时间等),会将事件从 Event Table 中取出,并按照该异步任务类型将其回调函数放入到宏任务事件队列微任务事件队列中,JS 引擎存在monitoring process进程,会持续不断的检查主线程执行栈是否为空,一旦为空读取事件队列时,会优先读取微任务事件队列上的所有事件,并压入执行栈开始执行,而后执行栈又为空时再读取宏任务事件队列列头的第一个任务压入执行栈,开启第二轮事件循环

在执行机制上二者的区别:

  1. 微任务处理优先级高于宏任务。
  2. 读取微任务事件队列时会一下子读取一整个队列,而读取宏任务事件队列时只会取出第一个任务压入执行栈执行。

常见的一些异步任务分类

  1. 宏任务(Marco Task):script中的代码、setTimeoutsetInterval
  2. 微任务(Micro Task):Promise

运行机制流程图

任务队列里有啥?

Javascript 任务分为同步任务和异步任务,同步任务是前一个任务结束后一个任务才能开始。异步任务则不用等前一个任务完成就可以开始。

而异步任务又包括异步请求、异步回调(dom操作的回调、定时任务的回调)等,这些任务都会被放置在任务队列中。

对于异步请求和定时任务会先交给浏览器代为处理,等到处理完毕后将异步任务的回调函数推入任务队列的末尾,等待主线程读取。

async/await

讲到主线程和执行栈,就有一个不得不提的内容 async/await

async函数会返回一个Promise对象,便于回调函数管理,await是一个运算符,用于组成表达式,await xxx的计算结果取决于await它等待的东西,也就是xxx。如果它等待的不是一个Promise,那么它的计算结果就是它等待的东西。

await等待的是一个Promise时,它会对当前await后面声明的代码进行阻塞,直到Promise返回后才会继续执行后续代码。

当执行流遇到await functionXX(): Promise<any>时,因为发生了函数调用,所以functionXX会被压入执行栈,执行流会进入到函数内部,将return Promise之外的代码先执行一遍,Promise相关的异步操作会交由浏览器执行。

举个定时任务的例子

当我们设定了一个 10s 的定时任务,浏览器定时触发线程中的计数器在 10s 后准时的将定时任务的回调函数添加到任务队列末尾。

到这一步都是很正常的,但是这个被添加进消息队列的回调函数什么时候会被读取呢?

只有当主线程执行完所有任务后才会依次读取任务队列中的任务。

也就是说虽然浏览器按时的将定时任务发送到了任务队列中,但真正被主线程读取的时间可能超过了设定的时间。这也就是为什么有些定时任务被执行的时间和设定时间不一致的原因。



写在最后

本篇内容以理论为主,后续还会开一篇内容专门用代码做例子分析。本文的全部内容,纯本人理解后手敲的文字,有不对的地方,欢迎指出和纠正,感谢阅读 👋🏻