# JavaScript 运行机制
# js 单线程
# js 为什么不是多线程的
JavaScript的单线程,与它的用途有关。作为浏览器脚本语言,JavaScript的主要用途是与用户互动,以及操作DOM。这决定了它只能是单线程,否则会带来很复杂的同步问题。比如,假定JavaScript同时有两个线程,一个线程在某个DOM节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程为准? 所以,为了避免复杂性,从一诞生,JavaScript就是单线程,这已经成了这门语言的核心特征,将来也不会改变。
JavaScript语言的一大特点就是单线程,也就是说,同一个时间只能做一件事。
# js 任务队列
首先,js中的任务分为两种,一种是同步任务
,一种是异步任务
- 同步任务是指:在主线程上排队执行的任务,只有前一个任务执行完毕,才能执行后一个任务
- 异步任务是指:不进入主线程、而进入"任务队列"(task queue)的任务,只有"任务队列"通知主线程,某个异步任务可以执行了,该任务才会进入主线程执行
"任务队列"是一个事件的队列(也可以理解成消息的队列),IO设备完成一项任务,就在"任务队列"中添加一个事件,表示相关的异步任务可以进入"执行栈"了。主线程读取"任务队列",就是读取里面有哪些事件。
"任务队列"中的事件,除了IO设备的事件以外,还包括一些用户产生的事件(比如鼠标点击、页面滚动等等)。只要指定过回调函数,这些事件发生时就会进入"任务队列",等待主线程读取。
所谓"回调函数"(callback),就是那些会被主线程挂起来的代码。
异步任务必须指定回调函数,当主线程开始执行异步任务,就是执行对应的回调函数
。
"任务队列"是一个先进先出的数据结构,排在前面的事件,优先被主线程读取。主线程的读取过程基本上是自动的,只要执行栈一清空,"任务队列"上第一位的事件就自动进入主线程。但是,由于存在后文提到的"定时器"功能,主线程首先要检查一下执行时间,某些事件只有到了规定的时间,才能返回主线程
# 同步任务的执行机制
在主线程中存在一个执行栈
,任务一个接着一个
的执行,如果前一个任务比较耗时,那么后面的任务只能等着。
# 异步任务的执行机制
如果异步的任务有了执行的结果,就在任务队列中放置一个事件。一旦主线程执行栈中的全部执行完了时候,系统会自动的读取任务队列。看看任务队列中有什么事件,这些事件会结束等待,转到执行栈中执行。
# 事件循环模型 Event Loop
先看一幅图:
我们可以看到js在运行时会存在一个主线程的运行环境,主要包括了运行堆和运行栈,此外还有External APIs,上文我们已经说过了js的任务会存在同步任务
和异步任务
两种,如果全是同步任务,那么其实就不需要事件循环,直接在运行栈中一步步执行即可。
这里需要解释一下异步任务和事件,在浏览器中执行js时,异步任务指使用了外部的API的任务,也就是浏览器的API,更通俗点就是window.xxx
的方法。这里主要包含三大部分:
- DOM的操作
- ajax请求
- 定时器(setTimeout)
而对于事件,是一个比较抽象的说法,抽象的来理解,IO操作完成是一个事件,用户点击一次鼠标是事件,Ajax完成了是一个事件,一个图片加载完成是一个事件。。。将浏览器的一切操作抽象为一个事件。所以队列里面存放的是一个个事件。
循环的执行步骤: 看出一共分3大块,并且它们之间有联系。
- 所有任务在主线程上执行,形成一个执行栈(execution context stack), 这个执行栈就是图中JS的区域。
- 主线程之外,还存在一个
事件队列
(event queue)。当遇到异步任务时,也就是web api中的这些内容,则先将这个异步任务放入队列,然后继续执行后续的任务。 - 然后主线程继续执行,”执行栈”中的任务执行完毕,系统就会读取”事件队列”,异步任务结束等待状态,从”事件队列”进入执行栈,恢复执行。如果异步任务有回调函数,那么在执行完异常任务后,就执行回调函数里的js代码逻辑。当然也有可能没有回调函数的情况,那么就直接完成。
- 主线程不断重复上面的第三步。
- 队列先进先出,栈先进后出
上图是一个不断循环的过程,称之为轮询。对于整个过程,也称之为event loop
# 宏任务和微任务
根据规范,事件循环是通过任务队列
的机制来进行协调的。一个 Event Loop 中,可以有一个或者多个任务队列(task queue),一个任务队列便是一系列有序任务(task)的集合;每个任务都有一个任务源(task source),源自同一个任务源的 task 必须放到同一个任务队列,从不同源来的则被添加到不同队列。setTimeout/Promise 等API便是任务源,而进入任务队列的是他们指定的具体执行任务。
在事件循环中,每进行一次循环操作称为tick
,每一次tick
的任务处理模型是比较复杂的,但关键步骤如下:
- 在此次 tick 中选择最先进入队列的任务(oldest task),如果有则执行(一次)
- 检查是否存在
Microtasks
,如果存在则不停
地执行,直至清空 Microtasks Queue - 更新 render
- 主线程重复执行上述步骤
可参考如图所示:
# 宏任务
宏任务(macro)task,可以理解是每次执行栈
执行的代码就是一个宏任务(包括每次从事件队列中获取一个事件回调并放到执行栈中执行),浏览器为了能够使得JS内部宏任务与DOM任务能够有序的执行,会在一个宏执行结束后,在下一个宏任务执行开始前,对页面进行重新渲染,流程如下:
宏任务 -> 渲染 -> 宏任务 -> ...
宏任务包括
script(整体代码)
setTimeout
setInterval
I/O
UI交互事件
postMessage
MessageChannel
setImmediate(Node.js 环境)
# 微任务
微任务 microtask,可以理解是在当前task执行结束后立即执行的任务。也就是说,在当前task任务后,下一个task之前,在渲染之前。
所以它的响应速度相比setTimeout(setTimeout是task)会更快,因为无需等渲染。也就是说,在某一个微任务执行完后,就会将在它执行期间产生的所有微任务都执行完毕(在渲染前),流程如下:
1.宏任务 -> 微任务(全部执行完) -> 渲染 -> 2.宏任务 -> 微任务 -> 渲染...
微任务包括
Promise.then
Object.observe
MutationObserver
process.nextTick(node.js中)
# 问题
因为js存在主线程阻塞的问题,如果主线程阻塞,那么即使
setTimeout(()=>'', 1000)
,可能并不是期待中的在一秒后执行,可能远远超出1秒为什么会有微任务的出现
事件循环由宏任务和在执行宏任务期间产生的所有微任务组成。完成当下的宏任务后,会立刻执行所有在此期间入队的微任务。
这种设计是为了给紧急任务一个插队的机会,否则新入队的任务永远被放在队尾。区分了微任务和宏任务后,本轮循环中的微任务实际上就是在插队,这样微任务中所做的状态修改,在下一轮事件循环中也能得到同步