深入理解JavaScript异步机制:事件循环、任务队列与Promise详解

作者:闭包旅人时间:2026-10-25 22:56:55浏览:105

本文深入解析JavaScript异步编程核心原理,从事件循环(Event Loop)的工作机制出发,阐述宏任务与微任务的区别。详细讲解回调函数的局限性及“回调地狱”问题,重点介绍Promise的创建、调度技巧、错误处理方案(try-catch局限性与then/catch/finally用法),以及Promise.all和Promise.race等组合API的实际应用场景与注意事项。

事件循环

首先通过一段伪代码来直观理解事件循环的基本概念。

//eventLoop是一个用作队列的数组
var eventLoop = []
var event;
//永远执行
while(true){
   if(eventLoop.length>0){}
    //拿到队列中的下一个事件
    event = eventLoop.shift();
    //现在,执行下一个事件
    try{
      event()
    }
    catch(err){
     reportError(err)
    }
}

这段简化后的代码展示了一个持续运行的while循环,每一次循环迭代被称为一个“tick”(滴答)。在每个tick中,系统会检查事件队列,如果存在等待处理的事件,就取出一个并执行,这些被执行的函数即为回调函数。

需要明确的是,setTimeout()并未直接将回调函数放入事件循环队列。它的实际作用是启动一个定时器。当定时器超时后,环境会将回调函数推入事件循环队列,等待未来的某个tick将其取出并执行。因此,setTimeout的时间精度有限,它只能保证回调在指定时间之后运行,具体执行时机取决于队列中当前积压的任务数量。例如,若队列中有20个任务,该回调可能需要等待这20个任务执行完毕后才能运行。由于JavaScript是单线程模型,同一时刻只能处理一个事件,这引出了“并发”的概念。

当两个或多个“进程”同时处于执行状态时,即表现为并发。在浏览器中,可能会发出多个网络请求。例如,发出第二个请求时,第一个请求可能已返回响应;发出第三个请求时,第二个请求可能已返回响应。此时,请求2与响应1、请求3与响应2在逻辑上是并发运行的,但它们各自产生的事件仍需在事件循环队列中按顺序依次执行。

更常见的情况是,并发的“进程”之间需要进行数据交互。若存在此类交互,必须对交互过程进行协调,以避免竞态条件(Race Condition)的发生。以下展示了两个并发进程通过隐含的顺序相互影响,但这种顺序有时并不稳定:

var res = [];
function response(data){
    res.push(data)
}
ajax("http://url1",response);
ajax("http://url2",response);

此处有两个Ajax请求调用response函数,但无法确定哪一个会先执行完成。这种执行顺序的不确定性极有可能导致竞态条件Bug。

在ES6中,基于事件循环队列引入了一个新的概念——任务队列(Task Queue,通常指微任务队列),旨在解决这种执行顺序的不确定性问题。

任务队列

理解任务队列的最佳方式是将其视为挂在事件循环每个tick之后的一个子队列。在事件循环的每个tick中,异步操作不会像传统宏任务那样生成一个完整的新事件添加到主事件循环队列末尾,而是将任务添加到当前tick的任务队列末尾。

任务队列的含义是:系统识别到还有额外工作需要处理,但必须在当前tick的其他逻辑执行完毕前立即完成并执行它。

而事件循环机制类似于:只有当当前tick的所有任务(包括任务队列中的任务)执行完毕后,才重新回到队尾排队,准备处理下一个tick。

console.log("A")
setTimeout(()=>{
    console.log("B")
},0)
task(()=>{
console.log("C")
    task(()=>{
      console.log('D')
    })
})

直觉上可能认为输出结果是ABCD,但实际输出为ACDB。这是因为定时器的触发是在所有同步事件队列清空之后才开始的,而任务队列中的任务会在当前tick的同步代码执行完毕后、下一个宏任务开始前执行。

回调

截至目前,回调(Callback)仍是编写和处理JavaScript异步逻辑最常用且最基础的异步模式。

我们可以将大脑类比为单线程运行的事件循环队列,类似于JavaScript引擎的工作方式。以正在撰写博客的写作者为例:脑海中计划按顺序依次完成一系列要点,没有引入终端或非线性的行为干扰。然而,实际上大脑仍在不断切换状态。尽管大脑以异步事件的方式运行,但写作行为本身仍是顺序、同步进行的,“先写这里,再写那里”。

因此,如果同步的大脑计划能很好地映射到同步代码语句,那么大脑在规划异步任务时又是如何运作的呢?

答案是回调。即使脑海中涌现许多事件,如果想到什么就立即执行,这篇博客将无法完成。但在实际执行层面,大脑正是这样运作的:并非真正的多任务并行,而是通过快速的上下文切换来实现。

嵌套回调

listen("click",()=>{
    setTimeout(()=>{
     setTimeout(()=>{
       ajax("http:url",()=>{
          console.log("响应结果")
       })
    },1000)
},1000)
})

你可能非常熟悉这种代码结构:多个函数层层嵌套形成的链式调用,这通常被称为“回调地狱”(Callback Hell)。在大型项目中,这种结构引发的问题远比代码本身的复杂性严重得多。为了解决回调地狱问题,Promise应运而生。

Promise

在Promise中,传入的函数会立即执行。它接收两个参数,在本例中分别称为resolve和reject。前者代表操作完成(成功),后者代表操作被拒绝(失败)。

new Promise((resolve,reject)=>{
   //最终调用resolve还是reject
}).then(
  function(){
   console.log("then")
  }
)

Promise调度技巧

如果两个Promise都已决议,那么p1.then和p2.then应该最终先调用p1的回调,再调用p2的回调。但存在一些微妙的场景需要注意:

p.then(function(){
  p.then(function(){
    console.log("C")
  })
  console.log("A")
})
p.then(function(){
   console.log("B")
})

此处的输出结果是 A B C。

当一个Promise决议后,该Promise上所有通过then注册的回调都会在下一个异步时机点依次被调用。因此,这里的‘C’无法抢占或打断‘B’的执行,这是Promise的标准运作机制。

错误处理

对于大多数开发者而言,最自然的错误处理方式是try...catch结构。遗憾的是,该结构仅适用于同步代码,无法直接用于异步代码模式。

function(){
  setTimeout(()=>{
    bar()
  },1000)
}
try{
  foo()
}catch(err){
   //永远不会到达这里
}

虽然try...catch结构简洁好用,但它无法跨越异步操作生效。因此,catch块无法拦截定时器内部发生的异步错误。

方法1:可以在then(resolve, reject)的第二个回调(即reject处理函数)中处理错误,并通过throw语句传递一个Error对象。

方法2:使用finally捕获。

无论Promise最终状态是完成还是拒绝,在执行完then或catch指定的回调函数之后,都会执行finally方法中指定的回调函数。

function fn(val){
    return new Promise((resolve,reject)=>{
        if(val){
          resolve({name:"111"})
        }else{
          reject("404")
        }
    })
}
//执行函数
fn(true)
   .then(data=>{
     console.log(data) //打印name:111键值对
     return fn(false)
   })
   .catch(e=>{
     console.log(e) //打印404
     return fn(false)
   })
   .finally(()=>{
     console.log("finally") //会打印的!
   })

Promise.all([...])

假设你想同时发送两个请求,并等待它们无论以何种顺序完成后,再发送第三个请求。

Promise.all([p1,p2])
.then(function(){
    return request("http:url3")
})
.then(function(msg){
  console.log(msg)
})

Promise.all接收一个数组作为参数,通常由多个Promise实例组成。从Promise.all调用返回的Promise会收到一个完整消息(msg)。这是一个由数组中所有Promise完成后传入的消息组成的数组,其顺序与输入数组的顺序一致,与完成顺序无关。

此外,只有当数组内所有成员Promise都完成时,Promise.all才算完成。如果其中任何一个Promise被拒绝,Promise.all会立即被拒绝,并丢弃其他已成功Promise的结果。

Promise.race([...])

尽管Promise.all协调多个并发Promise的运行并假设所有都需要完成,但有时你只想响应第一个完成的Promise结果,并直接忽略其他Promise。

这种在Promise中被称为竞态(Race)。

Promise.race()同样接受一个数组作为参数,该数组包含一个或多个Promise。一旦有任何一个Promise成功(resolve),Promise.race()就会完成;一旦有任何一个Promise被拒绝(reject),它就会立即被拒绝。

(注意:如果传入一个空数组,Race将永远不会resolve,因此切勿传递空数组。)

Promise.race([p1,p2])
.then(function(){
//p1和p2其中之一会完成这场竞赛,突破重围
    return request("http:url3")
})
.then(function(msg){
  console.log(msg)
})

由于只有一个Promise能够“取胜”,因此完成值是单个消息,而非像all那样返回一个数组。

all和race的变体

· none([...])

此模式类似于all([...]),但完成和拒绝的情况互换:只有当所有Promise都被拒绝时,它才转化为完成值。

· any([...])

此模式与all([...])类似,但会忽略拒绝状态,因此只需要有一个Promise完成即可,无需全部完成。

· first([...])

此模式类似于与any([...])的竞争,即只要第一个Promise完成,就会忽略后续的任何完成和拒绝。

· last([...])

此模式类似于first([...]),但只有最后一个完成的Promise才会胜出。

无法取消的Promise

一旦创建了一个Promise并为其注册了完成/拒绝的处理函数,若出现某种情况使任务悬而未决,你无法从外部停止其进程。

以上就是一文了解你不知道的JavaScript异步篇的详细内容,更多关于JavaScript异步的资料请关注本站其它相关文章!

作者最新文章