[核心概念] 一文说透浏览器环境下的JS运行机制

465 阅读17分钟

浏览器环境下的JS运行机制

系列开篇

为进入前端的你建立清晰、准确、必要概念和这些概念的之间清晰、准确、必要关联, 让你不管在什么面试中都能淡定从容。没有目录,而是通过概念关联形成了一张知识网络,往下看你就明白了。当你遇到【关联概念】时,可先从括号中的(强/弱)判断简单这个关联是对你正在理解的概念是强相关(得先理解你才能继续往下)还是弱相关(知识拓展)从而提高你的阅读效率。我也会定期更新相关关联概念。

面试题

  • 最多的就是读代码写打印顺序类型的问题
  • 聊聊js运行机制
  • 任务队列、调用栈、事件循环 (Task Queue,Call Stack ,Event Loop)概念解释

本文会说什么?

这篇文章深刻讲下浏览器环境下js的执行机制。

先看下大纲

  • 进程 & 线程
  • 浏览器的多线程
  • 任务队列/回调队列、执行栈、事件循环
  • 宏任务 & 微任务
  • 大厂常见题型详解

逐步拆解分析

引文

我们平时都在写程序,而程序都是按照我们的想像的顺序在运行吗,那么浏览器到底是如何把一张张精彩的网页呈现的呢,还能跟我们用户做很多复杂的交互,这里面有很多程序在不停运行吗?

带着疑惑我们先了解下进程线程的概念

进程与线程

假设我们打开电脑的两个程序,一个是qq音乐,一个是浏览器,qq音乐播放着音乐,我们上网冲浪。那么这两个应用程序是同时运行的吗? 假设浏览器和qq音乐都是只有一个进程的应用程序,我们打开任务管理器,可以看到两个进程,(qq music)(chrome) 你可以强制杀掉进程,这个应用也就关闭了。CPU在运行一个进程时,其他进程处于挂起(pending),每个进程是相互独立的。这些进程单独占有CPU,内存等计算资源。有人说为啥我们能同时运行这么多程序呢?那是因为你是人,人的反应(视觉暂留,听觉暂留)很慢,计算机采用时间片轮转调度来把资源分配给这些进程,你执行一下,我执行一下,只不过这个过程的时间极短,人根本没法感知,就好像所有程序都运行的很流畅一样。

那么我们简单了解进程是什么:

进程是 CPU 资源分配最小单位

这些资源有什么呢,就是我们程序中使用到的内存资源,CPU计算资源,系统资源等等。分配了这些资源之后,我们的程序才能开始初始化,才能执行,否则只能卡住,比如你内存不足时程序未响应(我想你一定遇到过)。

那么线程呢

线程是对于进程的进一步划分,一个进程往往有着多个线程在协同工作。

线程是 CPU 的基本调度单位

简单来说

  • 进程是一个容器。
  • 线程是容器中的工作单位。

或者这么打比方: 进程就像是一家工厂,多个工厂之间是独立存在的。而线程就像是工厂中的那些工人,共享资源协同完成一个大目标

那么 多进程多线程 也很好理解

我们的操作系统是多进程同时运行,按时间片分配执行时间这样保证执行的程序互不干扰。

一个进程能包含多个线程来协同完成目标,比如你一个线程在干I/O操作,(下载操作等耗时长),另一个在播放已经下好的音乐,共同完成目标,这样叫多线程

js 的单线程解释

JavaScript 的单线程是指 JavaScript 引擎是单线程,而浏览器是多线程的。

js的发明初衷是一门网页脚本语言,不想让浏览器太复杂,只是在页面上的DOM交互。多线程需要共享资源、且有可能修改彼此的运行结果。而且不用处理多线程环境中出现的一些复杂情况,比如死锁。[关联概念]

比如浏览器需要渲染 DOM,JavaScript 可以修改 DOM 结构。JavaScript 执行时,浏览器 DOM 渲染停止。如果 JavaScript 引擎线程不是单线程的,那么可以同时执行多段 JavaScript,如果这多段 JavaScript 都操作 DOM,一个修改 DOM,一个删除 DOM,那么就会出现 DOM 冲突。

那有人问了:后一次覆盖前一次不就行了,跟CSS一样,层叠样式表。多线程就是希望程序运行能并行,而我们无法控制哪个线程先执行完,谁后执行完,那你写程序不就根本无法控制流程了吗。

那有人又问了,Web Worker不是允许 js 来创建多线程吗?

普及下: Web Worker 的作用,就是为 JavaScript 创造多线程环境,允许主线程创建 Worker 线程,将一些任务分配给后者运行。在主线程运行的同时,Worker 线程在后台运行,两者互不干扰。等到 Worker 线程完成计算任务,再把结果返回给主线程。这样的好处是,一些计算密集型或高延迟的任务,被 Worker 线程负担了,主线程(通常负责 UI 交互)就会很流畅,不会被阻塞或拖慢。

这个是没错的,但是要注意的是,Web Worker创建的子线程是无法读取主线程所在网页的 DOM 对象,操作DOM的任务需要交给主线程来执行,而且子线程完全受到主线程的控制,因此Web Worker也并没有改变JavaScript单线程的本质。

所以 现代 JavaScript 引擎被设计成了单线程。

同步和异步

那么我们知道 js 是单线程执行之后,我们会想如果所有代码都是同步执行,当我们代码执行到一个非常耗时的操作时,(例如网络请求,setTimeout等),由于单线程,所有任务都需要排队,前一个任务结束,才能执行后一个任务,浏览器只能等待耗时代码的完成,而不能去做其它的事情,我们会发现览器停止渲染,也就是网页看上去卡了,这样体验的网站能看吗。

所以我们把 js 代码的执行 分为同步任务异步任务

  • 同步任务,顾名思义 只有前一个任务执行完毕,才能执行后一个任务
  • 异步任务,举个例子:当同步任务执行到某个 WebAPI 时,会触发异步操作,此时浏览器会单独开线程去处理这些异步任务

不是说好单线程吗?怎么多开线程,注意浏览器是多线程的http请求线程的工作就是主线程执行代码遇到异步操作的时候会把函数交给该线程处理,如果还有回调函数,该线程会把回调函数加入到任务队列的队尾等待执行。

我们再强调下 JavaScript 引擎是被设计成单线程的,而 js 引擎线程只是浏览器众多线程之一。

下面简单举几个同步异步例子

  • 同步(synchronous)
    • 变量声明
    • 马上就能 return 拿到结果的函数
    • if else / while / for 等顺序结构代码
    • console.log 输出等等这些都是同步
  • 异步(asynchronous)
    • setTimeout 和 setInterval 这些 WebAPI
    • Promise
    • nextTick
    • http请求
    • ... 简单来说 如果在函数return时,还不能够得到预期结果,而是需要在将来通过一定的手段得到,那么这个函数就是异步的。

Event Loop

我们接下来讲解浏览器是如何在引擎是单线程的情况下用什么机制来保证用户流畅使用的。

首先回答 Event Loop 是个什么

简单的来说 Event Loop 是一个程序结构,用于等待和分派消息和事件。是浏览器用于协调 JavaScript 引擎单线程运行时不会阻塞的一种机制

先放一张国外人画的js引擎运行图

Js 运行时大致会分为几个部分

  1. Call Stack 执行上下文栈,也叫调用栈/执行栈,(这是我之前的一篇详解执行栈的文章),所有同步任务在主线程上执行,形成一个执行栈,因为 JS 单线程的原因,所以调用栈中每次只能执行一个任务,当遇到的同步任务执行完之后,由任务队列提供任务给调用栈执行。

  2. Task Queue: 任务队列,这是一个专门管理异步状态的容器。所有异步操作的回调,都会暂时被塞入这个队列。所以这个队列有时被称为 Callback Queue 回调队列。只要异步任务有了运行结果,就在"任务队列"之中放置一个事件。

  3. 一旦执行栈中的所有同步任务执行完毕,系统就会读取任务队列,看看里面有哪些事件。那些对应的异步任务,于是结束等待状态进入执行栈,开始执行

  4. 主线程不断重复上面的第三步。

只要主线程的 Call Stack 空了,就会去读取 Task Queue 中获取任务。

主线程从"任务队列"中读取事件,这个过程是循环不断的,所以整个的这种运行机制又称为 Event Loop(事件循环)

tips: 注意数据结构 队列的区别。

  • Call Stack 执行栈 是 栈 后进先出
  • Task Queue 任务队列 是 队列 先进先出

我们来看个例子

console.log('1')
setTimeout(function() {
    console.log('2')
}, 0)
console.log('3')

分析下打印

  1. 打印 '1' 这个没啥说的
  2. 遇到了 WebAPI ( setTimeout ) ,浏览器新开定时器线程处理,执行完成后把回调函数存放到任务队列(回调队列)中。JS 引擎遇到异步任务后不会一直等待其返回结果,而是将这个任务挂起交給其他浏览器线程处理,自己继续执行主线程中的其他任务。这个异步任务执行完毕后,把结果返回给任务队列。被放入的代码不会被立即执行。而是当主线程所有同步任务执行完毕, monitoring process 进程就会把 任务队列 中的第一个回调代码放入主线程。然后主线程执行代码。如此反复。
  3. 打印 '3' 异步 setTimeout 不会阻塞同步代码,因此会首先打印 '3'
  4. 主线程执行完毕后,执行任务队列中第一个回调函数加入主线程执行,打印 '2'

宏任务 & 微任务

上面一套搞下来我们差不多知道浏览器遇到耗时操作就搞成异步任务来防止浏览器被阻塞。那么接下来继续思考。

任务队列 Task Queue 是个消息队列,我们知道,先进先出,那就是说后来的任务都是被加在队尾等到前面的任务执行完了才会被执行。但是如果在执行的过程中突然有重要的数据需要获取,或是说有事件突然需要处理一下,按照队列的先进先出顺序这些是无法得到及时处理的。这个时候就催生了宏任务和微任务,微任务使得一些重要插入的任务得到及时的处理。

所以任务又分为宏任务(Macro Task)微任务(Micro Task),JS 运行时任务队列会分为宏任务队列微任务队列(Micro Task Queue)。我们上面说的 Task Queue 任务队列就是指 宏任务队列(Macro Task Queue)

也举些例子

  • 常见的宏任务
    • script 主代码块
    • setTimeout/setInterval
    • I/O 操作
    • requestAnimationFrame
  • 常见的微任务
    • Promise.then()
    • catch
    • finally
    • Object.observe
    • MutationObserver

我们在图中再加一块 Microtask Queue

我们现在知道了,微任务是为了及时解决一些必要事件而产生的,那么再深一点,宏任务本质可以认为是浏览器多线程间通信的一个消息队列。宏任务的真面目是浏览器派发,与 JS 引擎无关的,参与了 Event Loop 调度的任务。而微任务是在运行宏任务/同步任务的时候产生的,是属于当前任务的,所以它不需要浏览器的支持,直接在 JS 引擎中就被执行了。

完整的事件循环过程

根据上面这张图,我们来看下完整的事件循环过程

  1. 直接执行执行栈中的同步任务,(程序第一轮执行整体 script 脚本),属于宏任务。
  2. 如果有异步任务,产生了宏任务的回调,放入宏任务队列,下轮 EventLoop 执行。如果产生微任务,放入微任务队列。
  3. 执行完当前所有同步代码(宏任务)之后,执行栈为空
  4. 从微任务队列中逐个取出回调任务,放入执行栈中执行,直至所有微任务执行完成。注意:如果在执行微任务的过程中,产生了新的微任务,那么这个微任务会加入到微任务队列的末尾同样会在本次EventLoop周期内被执行。
  5. 当执行完所有微任务后,如果有必要会开始渲染页面。
  6. 把宏任务队列头部的任务取出,放入执行栈,作为宏任务执行,从第 1 步开始,开始下一轮 Event Loop。

如何用

这个部分就来看看各种面试中会问的东西。

例一

分析下面代码

console.log('1')

setTimeout(function() {
  console.log('2')
}, 0)

new Promise((resolve, reject)=>{
  console.log("3")
  resolve()
}).then(()=>{
  // 我们把这个 then 起名 then1
  console.log("4")
  new Promise((resolve, reject)=>{
    resolve();
  }).then(() => {
    // 我们把这个 then 起名 then2
    console.log("6")
  })
})

console.log('7')

我们根据上面了解的知识来分析

  1. 首先直接执行调用栈 console.log('1'),同步打印 '1'
  2. 遇到 WebAPI(setTimeout) 产生宏任务,注册到 (宏)任务队列: [setTimeout],下一轮 Event Loop 再执行
  3. 继续执行遇到 new Promise 构造函数调用(同步),直接输出 '3',然后 resolve, resolve 匹配到 promise 的 then,把then 放到 微任务队列: [then1], 继续当前整体脚本的执行
  4. 遇到最后一行 log,输出 '7',当前执行栈清空
  5. 微任务队列中取出队头任务 then1 进行执行,此时微任务队列为空: [] 有一个 log,输出 '4'
  6. 继续遇到 new Promise 构造函数调用(同步),resolve 匹配到 then2 注册到微任务队列 此时微任务队列为: [then2]
  7. 微任务队列中取出队头任务 then2 进行执行,此时微任务队列为空: [] 有一个 log,输出 '6'
  8. 微任务队列执行完毕,第一轮 Event Loop 结束
  9. 检查宏任务队列 [setTimeout],里面有 setTimeout 定时器宏任务,取出队列头部宏任务放进主线程执行,输出 '2'

结果为 '1' -> '3' -> '7' -> '4' -> '6' -> '2',关键是把几个点讲清楚

  • 遇到异步任务,判断是宏任务还是微任务,分别进各自队列
  • new Promise 这是构造函数调用,new方法的原理我们 this 章节说过一些,这里是同步的,then注册的函数才是异步的
  • 微任务中产生的微任务会继续入微任务队列,在本轮 Event Loop 执行

例二

看下面代码

document.body.addEventListener('click', () => {
  Promise.resolve().then(() => {
    console.log('Micro Task 1')
  })
  console.log('listener 1')
})

document.body.addEventListener('click', () => {
  Promise.resolve().then(() => {
    console.log('Micro Task 2')
  })
  console.log('listener 2')
})
  1. 我们点击网页 body 后,Listener1 这个事件先进到 Call Stack 中,先遇到 Promise,Micro Task 1,进入微任务队列,然后继续执行,输出 listener 1,Listener1 事件出栈,Call Stack 清空了。
  2. 然后进入微任务时间,注意这里并不是先把下一个回调 我们点击网页后,Listener2 入栈,而是执行本轮微任务,输出 Micro Task 1
  3. 然后再执行第二个监听器,过程和第一个相同

所以结果是 listener 1 -> Micro Task 1 -> listener 2 -> Micro Task 2

还没完呢,这是用户单击页面时的情况,如果用 javascript 代码来 click 呢

document.body.addEventListener('click', () => {
  Promise.resolve().then(() => {
    console.log('Micro Task 1')
  })
  console.log('listener 1')
})

document.body.addEventListener('click', () => {
  Promise.resolve().then(() => {
    console.log('Micro Task 2')
  })
  console.log('listener 2')
})

document.body.click() // 注意这里多了这行!
  1. 开始我们的脚本在执行栈中, 我们调用 click Call Stack: [clickScript]
  2. 调度事件,我们执行 Listener1,入执行栈,Call Stack: [Listener1, clickScript],然后我们排队一个微任务,微任务队列: [Micro Task 1],继续执行 输出listener 1,结束Listener1出栈,Call Stack: [clickScript],重点就在这,此时执行栈不为空,因为click() 方法还没返回,所以我们继续执行 Listener2,入栈 Call Stack: [Listener2, clickScript],排队另一个微任务,微任务队列: [Micro Task 2, Micro Task 1],继续执行输出 listener 2,结束Listener2出栈,Call Stack: [clickScript]
  3. 现在所有监听器都执行完毕,click() 返回,Call Stack 清空,Call Stack: []我们可以执行微任务了,入栈,Call Stack: [Micro Task 1],输出 Micro Task 1,再继续类似,输出 Micro Task 2

所以这次结果是 listener 1 -> listener 2 -> Micro Task 1 -> Micro Task 2

都看到这了,不留个赞再走吗 (ˉ▽ ̄~)

其他

浏览器中常驻的线程及工作内容

  • Browser
    • 浏览器的主进程,该进程只有一个
    • 浏览器界面显示,与用户交互
    • 各个页面的管理,创建和销毁其他进程
    • 网络资源的管理,如下载等
  • GUI 渲染线程
    • 每个 Tab 页面都有一个渲染进程,互不影响
    • 绘制页面,解析 HTML、CSS,构建 DOM 树,页面重绘和回流等
    • 与 JS 引擎线程互斥,也就是所谓的 JS 执行阻塞页面更新
  • GPU进程
    • 除了 Chrome 静默地利用 GPU 进行渲染加速,开发人员可能主动地面向 GPU 进行编程。
    • 用于3D绘制,图像编程,动画优化
    • GPU Computing 大规模的计算问题
  • JS 引擎线程
    • 负责准执行准备好待执行的事件,即定时器计数结束,或异步请求成功并正确返回的事件
    • 负责 JS 脚本代码的执行
    • 与 GUI 渲染线程互斥,执行时间过长将阻塞页面的渲染
  • 事件触发线程
    • 负责将准备好的事件交给 JS 引擎线程执行
    • 多个事件加入任务队列的时候需要排队等待(因为JS是单线程)
  • 定时器触发线程
    • 负责执行异步的定时器类的事件,如 setTimeout、setInterval
    • 定时器到时间之后把注册的回调加到任务队列的队尾
    • W3C在HTML标准中规定,规定要求setTimeout中低于 3.7ms 的时间间隔算为 3.7ms, 所以 setTimeout(cb, 0),也是有最短时间的,并不是你想象的 0 ms 或立即执行
  • HTTP 请求线程
    • 负责执行异步请求
    • 主线程执行代码遇到异步请求的时候会把函数交给该线程处理
    • 如果有回调函数,该线程会把回调函数加入到任务队列的队尾等待执行
  • 其他线程(更细节的工作)

继续下去,你总会有收获。 上面这句话给你们,同样也给我自己前进的动力。

我是摩尔,数学专业,做过互联网研发,测试,产品
致力用技术改变别人的生活,用梦想改变自己的生活
关注我,找到自己的互联网思路,踏实地打牢固自己的技术体系
点赞、关注、评论、谢谢
有问题求助可私信 1602111431@qq.com 我会尽可能帮助你

参考