1. JavaScript 的执行机制
单线程意味着所有任务需要排队执行,前面的完成,后面的任务才能执行。因此,如果前面的任务耗时太久,后面的任务就需要一直等,影响用户体验,所以出现了异步的概念。
JavaScript的执行机制是指JavaScript代码在浏览器或Node.js环境中运行时的工作原理。JavaScript是一种单线程、非阻塞、事件驱动的语言,虽然HTML5中JavaScript也支持了多线程webWorker,但是也是不允许操作DOM。其执行机制包括以下几个关键方面:
-
解析:
- JavaScript代码首先会被解析器解析成抽象语法树(AST)。
- 解析器会检查代码是否有语法错误,并将代码转换成可执行的指令。
-
执行上下文(Execution Context):
- 在执行之前,JavaScript引擎会创建一个全局执行上下文(Global Execution Context)。
- 每次调用函数时,都会创建一个新的函数执行上下文(Function Execution Context)。
- 执行上下文包含变量环境、词法环境和this关键字的值。
-
作用域链(Scope Chain):
- JavaScript中的作用域是通过作用域链来管理的。
- 每个执行上下文都有自己的词法环境,其中包含了变量和函数声明。
- 当查找变量或函数时,JavaScript引擎会从当前执行上下文的词法环境开始,逐级向外层作用域查找,直到找到匹配的标识符或达到全局作用域。
-
变量提升(Hoisting):
- JavaScript中的变量和函数声明会被提升到其所在作用域的顶部。
- 这意味着你可以在声明之前使用变量和函数,但它们的实际赋值或执行是在代码中的位置决定的。
-
执行栈(Call Stack):
- 执行上下文以栈的形式管理,这被称为调用栈(Call Stack)。
- 当函数被调用时,其执行上下文被推入调用栈的顶部;当函数执行完成时,该执行上下文被弹出栈。
- 这保证了JavaScript是单线程的,一次只能执行一个任务(函数调用)。
-
事件循环(Event Loop):
- JavaScript运行时环境(浏览器或Node.js)提供了一个事件循环来处理异步操作。
- 异步任务(例如定时器、事件处理程序、HTTP请求等)被放入任务队列(Task Queue)中。
- 当调用栈为空时,事件循环会将任务队列中的任务一个一个地移入调用栈执行。
-
回调函数:
- 异步操作通常使用回调函数来处理。
- 当异步操作完成时,回调函数被添加到任务队列,并在适当的时候执行。
-
Promise和async/await:
- Promise和async/await是用来处理异步操作的现代JavaScript特性,它们提供了更清晰、可读性更好的异步编程方式。
2. 同步任务
代码从上到下按顺序依次执行。
3. 异步任务
浏览器中的事件循环中异步队列有两种:宏任务(macro)队列和微任务(micro)队列。一个宏任务队列,一个微任务队列。
3.1 宏任务
DOM操作导致的事件(例如,元素的变动、事件监听器)、I/O、script、setTimeout、setInterval、用户交互事件(例如点击事件)、postMessage、Ajax、XMLHttpRequest或fetch等网络请求、UI 渲染
3.2 微任务
Promise.then / catch / finally、MutationObserver变动观察器的回调函数、process.nextTick(Node.js 环境)、async/await中的await关键字
执行顺序:
- 同步任务在主进程执行形成一个执行栈,主线程之外,还存在一个“任务队列”,里面存放异步任务。
- 先执行 script 宏任务,也就是先加载整个script,这就算是一个宏任务,但是一定记住:这个相当于执行js代码的操作一般不做讨论,之后微任务优先级一定高于宏任务的优先级。
- 执行所有的微任务(Microtasks)(微任务队列优先级大于宏任务)。
- 从宏任务队列中再取出一个宏任务(Macrotask)执行。
- 执行可能产生的 UI 重渲染。
- 检查是否存在Web worker任务,如果有,则对其进行处理。
- 重复以上步骤,直到所有的任务队列为空。
看一道题:
async function Prom() {
console.log('Y')
await Promise.resolve()
console.log('x')
}
setTimeout(() => {
console.log(1)
Promise.resolve().then(() => {
console.log(2)
})
}, 0)
setTimeout(() => {
console.log(3)
Promise.resolve().then(() => {
console.log(4)
})
}, 0)
Promise.resolve().then(() => {
console.log(5)
})
Promise.resolve().then(() => {
console.log(6)
})
Promise.resolve().then(() => {
console.log(7)
})
Promise.resolve().then(() => {
console.log(8)
})
Prom()
console.log(0)
// 输出结果是: Y 0 5 6 7 8 x 1 2 3 4
分析:
setTimeout
是异步宏任务,放入宏任务队列Promise.then()
是异步微任务,放入微任务队列Prom
同步代码,立刻执行,输出Y
。但遇到await
微任务,再次放入微任务队列,等候。- 执行
console.log
同步代码,输出0
。 - 到目前为止,相当于
script
宏任务执行结束,开始执行所有的微任务。 - 查看微任务队列,依次执行微任务
Promise.then()
,输出5,6,7,8
。 - 对了,不要忘记下一个微任务还有过程3中遇到的
await
微任务,后面的Promise.resolve()
和consonle.log('x')
相当于是Promise.resolve().then(()=>console.log('x'))
,所以然后输出x
。 - 到此,微任务队列执行完,继续执行下一个宏任务队列
setTimeout
,输出1
,然后又遇到Promise
,放入到微任务队列。 - 每次执行完宏任务都需要清空微任务队列,所以执行
Promise
输出 2 。
10.下一个setTimeout
中包含Promise
也是同理,因此依次输出2
和4
。
总结就一句话:
从上到下解析,
- 两个setTimeout放到宏任务队列
- 4个promise放到微任务队列
- 执行Prom,其中X放到微任务队列末尾。所以X在5 6 7 8后面
深入理解
浏览器的宏任务和微任务是指在JavaScript引擎中,任务被分为两种类型:宏任务和微任务。宏任务是由浏览器规定的,例如DOM渲染后触发的setTimeout和setInterval。而微任务是由ES6语法规定的,例如Promise中的then方法。
事件循环又叫做消息循环,是浏览器渲染主线程的工作方式。
在 Chrome 的源码中,它开启一个不会结束的 for 循环,每次循环从消息队列中取出第一个任务执行,而其他线程只需要在合适的时候将任务加入到队列末尾即可。
过去把消息队列简单分为宏队列和微队列,这种说法目前已无法满足复杂的浏览器环境,取而代之的是一种更加灵活多变的处理方式,实际上现在浏览器中有多种不同类型的task。
根据 W3C 官方的解释,每个任务有不同的类型,同类型的任务必须在同一个队列,不同的任务可以属于不同的队列。不同任务队列有不同的优先级,在一次事件循环中,由浏览器自行决定取哪一个队列的任务。但浏览器必须有一个微队列,微队列的任务一定具有最高的优先级,必须优先调度执行。
整体代码的执行顺序:
- 先把所有的代码直接推入执行栈
- 一条一条从上往下执行 遇到同步代码直接执行掉 遇到异步代码 也只是执行一个"打开"任务的标记,而不去“真正”执行
- 直到当前执行栈清空
- 清空之后线程会去任务队列里看是否有待执行的任务。先查看微任务队列 ,如果有就拿到执行栈里执行 ,执行掉再看微任务队列 ,直到微任务队列清空。 再去看宏任务队列 ,把宏任务队列中的第一个函数拿到执行栈里执行, 执行完之后再去看微任务队列 ,重复这个过程。 直到所有代码都执行完毕。 js线程进入闲置状态。
用途:
- 由于微任务具有较高的执行优先级,它们适合用于需要尽快执行的小任务,例如处理异步的状态更新。
- 宏任务适合用于分割较大的、需要较长时间执行的任务,以避免阻塞 UI 更新或其他高优先级的操作。