likes
comments
collection
share

Javascript 运行机制

作者站长头像
站长
· 阅读数 34

1. 单线程的JavaScript

JavaScript是单线程的语言这,由它的用途决定的,作为浏览器的脚本语言,主要负责和用户交互,操作DOM。
假如JavaScript是多线程的,有两个线程同时操作一个DOM节点,一个负责删除DOM节点,一个在DOM节点上添加内容,浏览器该以哪个线程为标准呢?
所以,JavaScript的用途决定它只能是单线程的,过去是,将来也不会变。
HTML5的Web Worker允许JavaScript主线程创建多个子线程,但是这些子线程完全受主线程的控制,且不可操作DOM节点,所以JavaScript单线程的本质并没有发生改变。

2. 同步任务和异步任务

JavaScript是单线程语言,就意味着任务需要排队执行,只有前一个执行完成,后一个才可以执行。
如果前一个任务非常耗时呢?比如操作IO设备、网络请求等,后面的任务就会被阻塞,页面就会被卡住,甚至崩溃,用户体验非常差。
如果JavaScript的主线程在遇到这些耗时的任务时,将其挂起,先执行后面的任务,等挂起的任务有结果以后再回头执行,这样就可以解决耗时任务阻塞主线程的问题了。
于是,所有的任务就可以分为两种,同步任务和异步任务,同步任务放在主线程中执行,异步任务被挂起,不进入主线程执行(让主线程阻塞等待),当其有结果了,再放入主线程中执行。

3. 任务队列和Event Loop

3.1 任务队列

任务队列是一个事件队列,也可以理解成消息队列,当挂起的异步任务就绪以后就会在任务队列中放置相应的事件,表示该任务可以进入主线程中执行了。
任务队列中的事件,除了IO设备的事件,还有网络请求,鼠标点击、滚动等,只要为事件指定过回调函数,这些事件发生时就会进入任务队列,等待主线程来读取,然后执行相应的回调函数。
回调函数其实就是被挂起来的异步任务,比如:Ajax请求,请求成功或失败以后执行的回调函数就是异步任务。
任务队列是一个先进先出的数据结构,排在前面的事件,只要主线程一空,就会优先被读取。

3.2 Event Loop

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

4. 宏任务和微任务

异步任务可进一步划分为宏任务和微任务,相应的任务队列也有两种,分别为宏任务队列和微任务队列。

4.1 宏任务

setTimeout、setInterval、setImmediate会产生宏任务

4.2 微任务

requestAnimationFrame、IO、读取数据、交互事件、UI render、Promise.then、MutationObserve、process.nextTick会产生微任务

4.3 浏览器中的JavaScript脚本执行过程

4.3.1 过程描述

a. JavaScript脚本进入主线程, 开始执行
b. 执行过程中如果遇到宏任务和微任务,分别将其挂起,只有当任务就绪时将事件放入相应的任务队列
c. 脚本执行完成,执行栈清空
d. 去微任务队列依次读取事件,并将相应的回调函数放入执行栈运行,如果执行过程中遇到宏任务和微任务,处理方式同 b, 直到微任务队列为空
e. 浏览器执行渲染动作, GUI渲染线程接管,直到渲染结束
f. JS线程接管,去宏任务队列依次读取事件,并将相应的回调函数放入执行栈, 开始下一个宏任务的执行,过程为b -> c -> d -> e -> f, 如此循环
g. 直到执行栈、宏任务队列、微任务队列都为空,脚本执行结束

4.3.2 示例

4.3.2.1 示例一
// 脚本

console.log(1)

setTimeout(() => {
  console.log(2)
}, 0)

const p = new Promise((resolve) => {
  setTimeout(() => {
    console.log(3)
    resolve()
  }, 1000)
  console.log(4)
})

p.then(() => {
  console.log(5)
})

console.log(6)

执行过程

a. 脚本放入执行栈开始实行
b. 执行到console.log(1), 输入1
c. 执行到setTimeout,遇到宏任务,将其挂起,由于延时 0ms,将在 4ms后在宏任务队列产生一个定时事件, 我们叫定时A
d. 程序继续向下执行,执行new Promise(),并运行其参数,遇到第二个定时任务(宏任务),叫它定时B,并将其挂起,执行console.log(4), 输出4
e. 遇到微任务p.then(), 将其挂起
f. 向下执行遇到console.log(6), 输出6
g. 执行栈清空,读取微任务队列,发现为空,因为p.then()含没有就绪,它的就绪依赖与第一个定时任务(定时A)的执行
h. 执行栈为空,微任务队列为空,执行浏览器的渲染动作
i. 读取宏任务队列,读取第一个就绪的宏任务,为定时任务A,将其回调函数放入执行栈开始执行,执行console.log(2), 输入2
j. 执行栈清空,微任务队列为空,渲染
k. 开始执行下一个就绪的宏任务,定时任务B,并将其回调函数放入执行栈执行,执行console.log(3), 输出3,并执行resolve(), p.then()就绪,在微任务队列放入相应的事件
o. 执行栈清空,读取微任务队列,发现不为空,读取第一个就绪的事件,并将其对应的回调函数放入执行栈执行,执行console.log(5), 输出5
p. 执行栈清空,微任务队列为空,渲染,然后发现宏任务队列为空,本次脚本执行彻底结束
输出结果为: 1 4 6 2 3 5
4.3.2.2 示例二
async function async1 () {
  console.log('async1_1')
  await async2()
  console.log('async1_2')
}
async function async2 () {
  console.log('async2')
}
console.log('script start')
setTimeout(() => {
  console.log('setTimeout')
}, 0)
async1()
new Promise(resolve => {
  console.log('promise executor')
  resolve()
}).then(() => {
  console.log('promise then')
})
console.log('script end')

说明

函数前加async,实际上返回的是一个promise,比如这里的async2函数,返回的是一个立即resoved  promise
await会将后面的同步代码执行完成(async2),然后让出线程,将异步任务(Promise.then)挂起,这里的立即resolved promise,所以会在微任务队列添加一个事件,且排在下面的Promise.then之前

输出结果

如果上一个示例看懂了,再饥饿和该示例的说明信息,答案就呼之欲出了:
script start => async1_1 => async2 => promise executor => script end => async1_2 => promise then => setTimeout

4.3.3 外链

外链

4.3.4 总结

如果把JavaScript脚本也当作初始的宏任务,那么JavaScript在浏览器端的执行过程就是这样:
先执行一个宏任务, 然后执行所有的微任务
再执行一个宏任务,然后执行所有的微任务
...
如此反复,执行执行栈和任务队列为空

4.4 node.js中JavaScript脚本的执行过程

JavaScript脚本执行过程在node.js和浏览器中有些不同, 造成这些差异的原因在于,浏览器中只有一个宏任务队列,但是node.js中有好几个宏任务队列,而且这些宏任务队列还有执行的先后顺序,而微任务时穿插在这些宏任务之间执行的

4.4.1 执行顺序

  各个事件类型, 实行顺序自上而下
   ┌───────────────────────┐
┌─>│        timers         │<————— 执行 setTimeout()、setInterval() 的回调
│  └──────────┬────────────┘
|             |<-- 先执行process.nextTick, 再执行MicroTask Queue 的回调
│  ┌──────────┴────────────┐
│  │     pending callbacks │<————— 执行由上一个 Tick 延迟下来的 I/O 回调
│  └──────────┬────────────┘
|             |<-- 先执行process.nextTick, 再执行MicroTask Queue 的回调
│  ┌──────────┴────────────┐
│  │     idle, prepare     │<————— 内部调用(可忽略)
│  └──────────┬────────────┘     
|             |<-- 先执行process.nextTick, 再执行MicroTask Queue 的回调
|             |                   ┌───────────────┐
│  ┌──────────┴────────────┐      │   incoming:   │ - (执行几乎所有的回调,除了 close callbacks 以
|  |                       |      |               |     及 timers 调度的回调和 setImmediate() 调度
|  |         poll          |<-----|   connections,|        的回调,在恰当的时机将会阻塞在此阶段)
│  │                       │      |               │ 
│  └──────────┬────────────┘      │   data, etc.  │ 
│             |                   |               | 
|             |                   └───────────────┘
|             |<-- 先执行process.nextTick, 再执行MicroTask Queue 的回调
|  ┌──────────┴────────────┐      
│  │        check          │<————— setImmediate() 的回调将会在这个阶段执行
│  └──────────┬────────────┘
|             |<-- 先执行process.nextTick, 再执行MicroTask Queue 的回调
│  ┌──────────┴────────────┐
└──┤    close callbacks    │<————— socket.on('close', ...)
   └───────────────────────┘

4.4.2 示例

4.4.2.1 基本示例
console.log(1)

setTimeout(() => {
  console.log('timer1')
  Promise.resolve().then(() => {
    console.log('promise1')
  })
}, 0)

setTimeout(() => {
  console.log('timer2')
  Promise.resolve().then(() => {
    console.log('promise2')
  })
}, 0)

console.log(2)
这段代码在浏览器中的执行结果为:1 2 timer1 promise1 timer2 promise2
在node.js中的执行结果则为:1 2 timer1 timer2 promise1 promise2

4.4.2.2 setTimeout和setImmediate的顺序

它们两个顺序从上图看显而易见,timers队列在check队列执行运行,但是有个前提,事件已经就绪
setTimeout(() => {
  console.log('timeout')
}, 0)

setImmediate(() => {
  console.log('immediate')
})
以上代码在node.js中的运行结果为:immediate timeout,原因如下:
在程序运行时timer事件未就绪,所以第一次去读timer队列时,队列为空,继续向下执行,在check队列读取到了就绪的事件,所以先执行immediate,再执行timeout,因为即使setTimeout的延时时间未 0,但是node.js一般会设置为 1ms, 所以,当node准备Event Loop的时间大于 1ms时,就会先输出timeout,后输出immediate,否则先输出immediate后输出timeout
const fs = require('fs')

// 读取文件
fs.readFile('xx.txt', () => {
  setTimeout(() => {
    console.log('timeout')
  })

  setImmediate(() => {
    console.log('immediate')
  })
})
以上代码的输出顺序一定为:immediate timeout, 原因如下:
setTimeout和setImmediate都写在I/O callback中,意味着处于poll阶段,然后是check阶段,所以,此时无论setTimeout就绪多快(1ms),都会优先执行setImmediate,本质上,从poll阶段开始执行,而不是一个Tick初始阶段。