前言
在 JavaScript 中,同步和异步是代码的执行方式,它们的区别在于代码的执行顺序和是否阻塞程序的执行。
- 同步操作是指代码按顺序执行,会等待上一步操作完成后再执行。
- 异步操作是指代码不会立即返回结果,而是在某个时间点触发回调函数执行。 常见的异步操作包括操作DOM元素,发送 AJAX 请求,读取文件等等。 由于这些操作需要等待一定的时间才能返回结果,因此需要采用异步编程模型来处理这些操作,以避免阻塞程序的执行。 常用的异步编程模型包括回调函数、promise、async/await等。
JS运行机制
JavaScript是一种单线程语言,这意味着它只能在一个主线程上运行。“JS的单线程”指的是JS 引擎线程。由于JavaScript的主要用途是与用户互动,以及操作DOM。这决定了它只能是单线程,否则会带来更复杂的同步问题。 这个线程负责执行JavaScript代码,同时也负责处理浏览器中的用户事件、HTTP请求和其他任务。
JavaScript执行异步任务的机制可以分为宏任务和微任务:
宏任务:在主线程中执行的任务,例如异步代码、计时器回调函数和事件处理函数等。
微任务:优先级高于宏任务的任务,例如Promise、MutationObserver和HTML5的新API等。
渲染进程Renderer的主要线程
GUI渲染线程
-
负责渲染浏览器界面,解析HTML,CSS,构建DOM树和RenderObject树,布局和绘制等
- 解析html代码(HTML代码本质是字符串)转化为浏览器认识的节点,生成DOM树,也就是DOM Tree
- 解析css,生成CSSOM(CSS规则树)
- 把DOM Tree 和CSSOM结合,生成Rendering Tree(渲染树)
-
当我们修改了一些元素的颜色或者背景色,页面就会重绘(Repaint)
-
当我们修改元素的尺寸,页面就会回流(Reflow)
-
当页面需要Repaing和Reflow时GUI线程执行,绘制页面
-
回流(Reflow)比重绘(Repaint)的成本要高,我们要尽量避免Reflow和Repaint
-
GUI渲染线程与JS引擎线程是互斥的
- 当JS引擎执行时GUI线程会被挂起(相当于被冻结了)
- GUI更新会被保存在一个队列中等到JS引擎空闲时立即被执行
JS引擎线程
-
JS引擎线程就是JS内核,负责处理Javascript脚本程序(例如V8引擎)
-
JS引擎线程负责解析Javascript脚本,运行代码
-
JS引擎一直等待着任务队列中任务的到来,然后加以处理
- 浏览器同时只能有一个JS引擎线程在运行JS程序,所以js是单线程运行的
- 一个Tab页(renderer进程)中无论什么时候都只有一个JS线程在运行JS程序
-
GUI渲染线程与JS引擎线程是互斥的,js引擎线程会阻塞GUI渲染线程
- 就是我们常遇到的JS执行时间过长,造成页面的渲染不连贯,导致页面渲染加载阻塞(就是加载慢)
- 例如浏览器渲染的时候遇到
<script>
标签,就会停止GUI的渲染,然后js引擎线程开始工作,执行里面的js代码,等js执行完毕,js引擎线程停止工作,GUI继续渲染下面的内容。 - 所以如果js执行时间太长就会造成页面卡顿的情况
事件触发线程
- 属于浏览器而不是JS引擎,用来控制事件循环,并且管理着一个事件队列(task queue)
- 当js执行碰到事件绑定和一些异步操作(如setTimeOut,也可来自浏览器内核的其他线程,如鼠标点击、AJAX异步请求等),会走事件触发线程将对应的事件添加到对应的线程中(比如定时器操作,便把定时器事件添加到定时器线程),等异步事件有了结果,便把他们的回调操作添加到事件队列,等待js引擎线程空闲时来处理。
- 当对应的事件符合触发条件被触发时,该线程会把事件添加到待处理队列的队尾,等待JS引擎的处理
- 因为JS是单线程,所以这些待处理队列中的事件都得排队等待JS引擎处理
定时触发器线程
- setInterval与setTimeout所在线程
- 浏览器定时计数器并不是由JavaScript引擎计数的(因为JavaScript引擎是单线程的,如果处于阻塞线程状态就会影响记计时的准确)
- 通过单独线程来计时并触发定时(计时完毕后,添加到事件触发线程的事件队列中,等待JS引擎空闲后执行),这个线程就是定时触发器线程,也叫定时器线程
- W3C在HTML标准中规定,规定要求setTimeout中低于4ms的时间间隔算为4ms
异步http请求线程
-
在XMLHttpRequest在连接后是通过浏览器新开一个线程请求
-
将检测到状态变更时,如果设置有回调函数,异步线程就产生状态变更事件,将这个回调再放入事件队列中再由JavaScript引擎执行
-
简单说就是当执行到一个http异步请求时,就把异步请求事件添加到异步请求线程,等收到响应(准确来说应该是http状态变化),再把回调函数添加到事件队列,等待js引擎线程来执行
同步异步
js 的单线程分为: 同步任务、 异步任务
在 js 中存在 js 引擎执行的主线程,另一个是由事件触发管理的任务队列
- 同步任务在主线程上排队执行,只有前一个任务执行完毕,才能顺序执行下一个任务。
- 异步任务不进入主线程而是被js引擎加入到任务队列。一旦执行栈中的所有同步任务执行完毕主线程空闲时,系统就会读取任务队列,将可运行的异步任务添加到执行栈中。前一个任务是否执行完毕不影响下一个任务的执行。
微任务和宏任务都是异步任务,它们都属于一个队列
宿主环境
JavaScript是一种脚本语言,它需要在某个宿主环境中运行。宿主环境是指提供执行环境和相关API的系统或应用程序。不同的宿主环境提供了不同的JavaScript运行环境和API。 以下是一些常见的JavaScript宿主环境:
- Web浏览器 - 最常见的JavaScript宿主环境是Web浏览器,如Google Chrome,Mozilla Firefox,Safari和Microsoft Edge。Web浏览器提供了一个JavaScript环境,可以在网页中执行JavaScript脚本。此外,浏览器还提供了丰富的DOM API和Web API,用于操作HTML和CSS元素,与服务器通信以及访问本地存储。
- Node.js - Node.js是一个基于Chrome V8引擎的JavaScript运行环境,可以在服务器端运行JavaScript代码。它提供了许多内置的API和第三方库,以便在服务器端创建网络应用程序,处理文件和数据库等操作。
- Adobe Acrobat - Adobe Acrobat是一个PDF阅读器和编辑器应用程序,它提供了一个JavaScript环境,可以在PDF文件中执行JavaScript脚本。
- Adobe Photoshop - Adobe Photoshop是一款图像处理软件,它提供了一个JavaScript环境,可以使用JavaScript脚本扩展和自动化Photoshop的功能。
总之,JavaScript需要一个宿主环境才能运行。不同的宿主环境提供了不同的功能和API,可以满足各种需要。
在浏览器环境中,有JS 引擎线程和渲染线程,且两个线程互斥,而在Node环境中,只有JS 线程。
事件循环(Event Loop )
事件轮询
你的程序通常被打断成许多小的代码块,它们一个接一个地在事件轮询队列中执行。而且从技术上说,其他与你的程序没有直接关系的事件也可以穿插在队列中。
一个事件轮询将它的工作打碎成一系列任务并串行地执行它们,不允许并行访问和更改共享的内存。并行与“串行”可能以在不同线程上的事件轮询协作的形式共存。(通过分立线程中彼此合作的时间循环,并行和顺序执行可以共存)
JS到底是怎么运行的呢?
microtask 称为 jobs
===> 微任务, macrotask 称为 task
===> 宏任务
JS引擎常驻于内存中,等待宿主将JS代码或函数传递给它。
也就是等待宿主环境分配宏观任务,反复等待 - 执行即为事件循环。
Event Loop中,每一次循环称为tick,每一次tick的任务如下:
-
执行栈选择最先进入队列的宏任务(一般都是script),执行其同步代码直至结束;
-
检查是否存在微任务,有则会执行至微任务队列为空;
-
如果宿主为浏览器,可能会渲染页面;
-
开始下一轮tick,执行宏任务中的异步代码(setTimeout等回调)。
执行栈
执行栈,也叫调用栈,具有 LIFO(后进先出)结构,用于存储在代码执行期间创建的所有执行上下文
当Javascript引擎开始执行你第一行脚本代码的时候,它就会创建一个全局执行上下文然后将它压到执行栈中
每当引擎碰到一个函数的时候,它就会创建一个函数执行上下文,然后将这个执行上下文压到执行栈中
引擎会执行位于执行栈栈顶的执行上下文(一般是函数执行上下文),当该函数执行结束后,对应的执行上下文就会被弹出,然后控制流程到达执行栈的下一个执行上下文
let a = 'Hello World!';
function first() {
console.log('Inside first function');
second();
console.log('Again inside first function');
}
function second() {
console.log('Inside second function');
}
first();
console.log('Inside Global Execution Context');
转化成图的形式
简单分析一下流程:
- 创建全局上下文请压入执行栈
- first函数被调用,创建函数执行上下文并压入栈
- 执行first函数过程遇到second函数,再创建一个函数执行上下文并压入栈
- second函数执行完毕,对应的函数执行上下文被推出执行栈,执行下一个执行上下文first函数
- first函数执行完毕,对应的函数执行上下文也被推出栈中,然后执行全局上下文
- 所有代码执行完毕,全局上下文也会被推出栈中,程序结束
宏任务 macro
宏任务是由宿主发起的,而微任务由JavaScript自身发起。
每次执行栈执行的代码就是一个宏任务(包括每次从事件队列中获取一个事件回调并放到执行栈中执行)。
由于JS引擎线程和GUI渲染线程是互斥的 关系,浏览器为了能够使宏任务和DOM任务 能够有序的进行,会在一个宏任务执行结束后,在下一个宏任务执行开始前,GUI渲染线程开始工作,对页面进行重新渲染。
宏任务 -> GUI渲染 -> 宏任务 -> ...
宏任务:
- setTimeout
- setInterval
- setImmediate()- node
- requestAnimationFrame ()-浏览器
微任务 mic
可以理解是在当前 宏任务 执行结束后立即执行的任务。也就是说,在当前宏任务执行后,下一个宏任务之前。
所以它的响应速度相比setTimeout(setTimeout是task)会更快,因为无需等渲染。也就是说,在某一个macrotask执行完后,就会将在它执行期间产生的所有microtask都执行完毕(在渲染前)。
宏任务 -> 微任务 -> GUI渲染 -> 宏任务 -> ...
JavaScript本身没有发起异步请求的能力,也就没有微任务的存在。
常见的微任务:
- Promise.then()
- await 行之后的内容
- process.nextTick()
- catch
- finally
- Object.observe
- MutaionObserver
在ES5之后,JavaScript引入了Promise
,这样,不需要浏览器,JavaScript引擎自身也能够发起异步任务了。
浏览器会先执行完一个宏任务,再执行当前执行栈的所有微任务,然后移交GUI渲染,同一个宏任务,全部执行完才会执行渲染,渲染时GUI线程会将所有UI改动优化合并。
图解宏任务、微任务:
所以,总结一下,两者区别为:
外层同步代码先执行,在执行过程中如果有微任务则执行微任务如 promise,微任务(promise)结束后继续执行同步代码,同步代码完成后返回执行promise.then的内容,最后执行宏任务中的异步代码(setTimeout等回调)
JS异步有一个机制,就是遇到宏任务,先执行宏任务,将宏任务放入事件队列,然后再执行微任务,将微任务放入事件队列,但是,这两个队列不是同一个。当你往外拿的时候先从微任务队列里拿这个回调函数,然后再从宏任务的队列中拿宏任务的回调函数,如下图:
- 执行一个宏任务(栈中没有就从事件队列中获取)
- 执行过程中如果遇到微任务,就将它添加到微任务的任务队列中
- 宏任务执行完毕后,立即执行当前微任务队列中的所有微任务(依次执行)
- 当前宏任务执行完毕,开始检查渲染,然后GUI线程接管渲染
- 渲染完毕后,JS线程继续接管,开始下一个宏任务(从事件队列中获取)
宏任务一般包括:整体代码script,setTimeout,setInterval。
微任务:Promise,process.nextTick
例题1:
console.log('start') // 1 宏任务 外层同步代码
setTimeout(() => { //宏任务 ==> 最后执行
console.log('setTimeout') //6
}, 0)
new Promise((resolve) => { //微任务 先执行
console.log('promise') //2
resolve()
})
.then(() => { //执行promise。then 回调
console.log('then1') //4
})
.then(() => {
console.log('then2') //5
})
console.log('end') //3 微任务结束继续执行外层同步代码
//----------------------------结果------------------------------------
promise
end
then1
then2
setTimeout
async
async
是通过Promise
包装异步任务 ,async/await 是 Generator(生成器) 的语法糖。
- async/await自带执行器,不需要手动调用next()就能自动执行下一步
- async函数返回值是Promise对象,而Generator返回的是生成器对象
- await 后面是promise对象,能够返回Promise的resolve/reject的值
async函数内部的异常可以通过 .catch()或者 try/catch来捕获,区别是:
- try/catch 能捕获所有异常,try语句抛出错误后会执行catch语句,try语句内后面的内容不会执行
- catch() 只能捕获异步方法中reject错误,并且catch语句之后的语句会继续执行
setTimeout,setImmediate
setTimeout、setInterval、setImmediate都是JavaScript中常用的定时器方法,但它们有着不同的用途和实现方式:
-
setTimeout()方法是在指定的时间后调用一次函数。
-
setInterval()方法用于按照指定的时间间隔重复执行一个函数,直到被清除为止。
-
setImmediate()则在事件循环中使用了一个特殊的检测点,setImmediate将始终在setTimeout前执行
在Node.js中,setTimeout()和setImmediate()是两种不同的实现方式,其中setTimeout()利用了计时器,而setImmediate()则在事件循环中使用了一个特殊的检测点。
setImmediate
和process.nextTick
为Node环境下常用的方法(IE11支持setImmediate
),所以,后续的分析都基于Node宿主。
Node.js是运行在服务端的js,虽然用到也是V8引擎,但由于服务目的和环境不同,导致了它的API与原生JS有些区别,其Event Loop还要处理一些I/O,比如新的网络连接等,所以与浏览器Event Loop不太一样。
Nodejs 中事件循环的执行顺序如下:
-
timer 阶段: 检查计时器队列,执行setTimeout和setInterval的回调
-
pending callbacks 阶段: 执行延迟到下一个循环迭代的 I/O 回调
-
idle, prepare 阶段: 仅系统内部使用,无需干预直接跳过即可
-
poll 阶段: 检索新的 I/O 事件;执行与 I/O 相关的回调,比如读取文件、接收网络请求等。事实上除了其他几个阶段处理的事情,其他几乎所有的异步都在这个阶段处理。
-
check检测队列阶段: 执行回调函数 setImmediate的回调在这里执行
-
close callbacks 阶段: 执行所有的close事件,比如关闭文件和 socket 连接等。
Node.js事件循环的执行顺序是固定的,且每个阶段都有其特定的用途和执行顺序。熟悉和理解Node.js事件循环的执行顺序对于理解异步编程和防止程序阻塞非常重要。
一般来说,setImmediate
会在setTimeout
之前执行,
这是因为setTimeout()会将回调函数放入计时器队列,该计时器将在下一轮事件循环迭代的 Timer 阶段执行,而不是立即执行。
而 setImmediate() 会将回调函数放入检测队列,并在当前阶段的 I/O 事件的回调函数之后执行。
console.log('outer');
setTimeout(() => {
setTimeout(() => {
console.log('setTimeout');
}, 0);
setImmediate(() => {
console.log('setImmediate');
});
}, 0);
其执行顺序为:
- 外层是一个setTimeout,所以执行它的回调的时候已经在timers阶段了
- 处理里面的setTimeout,因为本次循环的timers正在执行,所以其回调其实加到了下个timers阶段
- 处理里面的setImmediate,将它的回调加入check阶段的队列
- 外层timers阶段执行完,进入pending callbacks,idle, prepare,poll,这几个队列都是空的,所以继续往下
- 到了check阶段,发现了setImmediate的回调,拿出来执行
- 然后是close callbacks,队列是空的,跳过
- 又是timers阶段,执行
console.log('setTimeout')
但是,如果当前执行环境不是timers阶段,就不一定了。。。。
注意:在nodejs中,如果setTimeout()的延迟时间设置为0,它实际上仍需要一定的时间来执行 setTimeout(fn, 0)
会被强制为setTimeout(fn, 1), 将在 1毫秒后执行
。
看看下面的例子:
setTimeout(() => {
console.log('setTimeout');
}, 0);
setImmediate(() => {
console.log('setImmediate');
});
其执行顺序为:
- 外层同步代码一次性全部执行完,遇到异步API就塞到对应的阶段
- 遇到setTimeout,虽然设置的是0毫秒触发,但是被node.js强制改为1毫秒,塞入times阶段
- 遇到setImmediate塞入check阶段
- 同步代码执行完毕,进入Event Loop
- 先进入times阶段,检查当前时间过去了1毫秒没有,如果过了1毫秒,满足setTimeout条件,执行回调,如果没过1毫秒,跳过
- 跳过空的阶段,进入check阶段,执行setImmediate回调
可见,1毫秒是个关键点,如果同步代码执行时间较长,进入Event Loop的时候1毫秒已经过了,setTimeout回调会被执行,如果1毫秒还没到,就先执行了setImmediate。所以在上面的例子中 setTimeout 会在setImmediate 之后执行。
如果将setTimeout()和setImmediate()都放在同一I/O循环中,setImmediate()将始终比setTimeout()先执行。
Promise,process.nextTick
Promise: 在 new Promise 时传入的回调函数为同步代码 ,会立即执行,而.then() .catch()里的为 异步微任务。 在 promise 的 状态发生变更后 将不会再被改变。
而process.nextTick()
为Node环境下的方法,是一个特殊的异步API,其实并不是事件循环的一部分。事实上Node在遇到这个API时,process.nextTick() 函数的回调函数会在当前事件循环迭代结束之前执行,等待回调执行完后才会继续下一个 Event Loop。
- process.nextTick 中的回调是在当前 tick 执行完之后,下一个宏任务执行之前调用。所以process.nextTick() 会在当前 tick 的同一个阶段立即执行。
- process.nextTick() 的优先级非常高,在 Node.js 中,process.nextTick() 会先于 Promise、setTimeout、setImmediate() 之前执行。
所以,process.nextTick
和Promise
同时出现时,肯定是process.nextTick
先执行,原因是process.nextTick
的队列比Promise
队列优先级更高。
Vue中的 vm.$nextTick
vm.$nextTick
接受一个回调函数作为参数,用于将回调延迟到下次DOM更新周期之后执行。
这个API就是基于事件循环实现的。
“下次DOM更新周期”的意思就是下次微任务执行时更新DOM,而vm.$nextTick
就是将回调函数添加到微任务中(在特殊情况下会降级为宏任务)。
因为微任务优先级太高,Vue 2.4版本之后,提供了强制使用宏任务的方法。
- vm.$nextTick优先使用Promise,创建微任务。
- 如果不支持Promise或者强制开启宏任务,那么,会按照如下顺序发起宏任务:
- 优先检测是否支持原生 setImmediate(这是一个高版本 IE 和 Edge 才支持的特性)
- 如果不支持,再去检测是否支持原生的MessageChannel
- 如果也不支持的话就会降级为 setTimeout。
总结
总的来说,JavaScript 异步操作的处理机制,通过将异步操作封装成宏任务或微任务,把不同任务放到对应的异步队列中,并通过事件循环来协调异步操作的执行顺序和时间。
希望各位看到这里的朋友们能有所收获,如果有疑问或是文章有误欢迎大家留言啊 !!!
参考:
- 【Promise面试题】:juejin.cn/post/684490…
- 【js运行机制】:juejin.cn/post/684490…
- 【理解 process.nextTick()】: www.jianshu.com/p/deb8bc589…