阿里云主机折上折
  • 微信号
您当前的位置:网站首页 > 异步陷阱:Promise、async/await 的迷惑行为

异步陷阱:Promise、async/await 的迷惑行为

作者:陈川 阅读数:13941人阅读 分类: 前端综合

Promise和async/await是现代JavaScript异步编程的核心工具,但它们的某些行为常常让人感到困惑。从微任务队列的优先级到错误处理的隐蔽陷阱,这些特性背后隐藏着许多反直觉的细节。

Promise.resolve的"立即执行"假象

console.log('脚本开始');
Promise.resolve().then(() => console.log('Promise 1'));
setTimeout(() => console.log('setTimeout'), 0);
console.log('脚本结束');

// 输出顺序:
// 脚本开始
// 脚本结束
// Promise 1
// setTimeout

Promise回调会被放入微任务队列,而setTimeout回调进入宏任务队列。事件循环总是先清空微任务队列才会处理宏任务,即使setTimeout的延迟设置为0。这种优先级差异会导致出人意料的执行顺序。

更隐蔽的是,即使Promise已经处于resolved状态,它的then回调仍然会被异步执行:

const p = Promise.resolve(42);
p.then(v => console.log(v)); // 仍然异步执行
console.log('同步代码');

async函数的返回值陷阱

async函数总是返回Promise,但返回值处理有几个特殊场景:

async function foo() {
  return 123; // 等价于return Promise.resolve(123)
}

async function bar() {
  return Promise.resolve(456); // 注意这里会创建双重Promise
}

const result = bar();
result.then(v => console.log(v)); // 输出456,但经历了两次解包

当返回一个thenable对象时,行为会更加复杂:

async function baz() {
  return {
    then(resolve) {
      resolve('手动thenable');
    }
  };
}

baz().then(console.log); // 输出"手动thenable"

错误处理的"黑洞"现象

未捕获的Promise拒绝会导致静默失败:

function riskyOperation() {
  return new Promise((_, reject) => {
    setTimeout(() => reject(new Error('失败')), 1000);
  });
}

// 没有.catch处理
riskyOperation(); // 错误会被默默吞掉

在Node.js环境中,这可能会触发unhandledRejection事件。浏览器控制台通常会显示警告,但不会中断程序执行。

async/await的错误处理也有自己的特点:

async function fetchData() {
  try {
    const res = await fetch('invalid-url');
    return await res.json();
  } catch (e) {
    console.log('捕获到错误:', e);
    throw e; // 如果不重新抛出,外部不会知道发生了错误
  }
}

// 调用方仍需处理错误
fetchData().catch(e => console.log('外部捕获:', e));

Promise链中的值穿透

then回调中返回非Promise值时会发生值穿透:

Promise.resolve(1)
  .then(x => x + 1) // 返回2
  .then(x => { /* 不返回值,相当于返回undefined */ })
  .then(x => console.log(x)); // 输出undefined

但如果在then中返回Promise,则会等待该Promise解决:

Promise.resolve(1)
  .then(x => new Promise(r => setTimeout(() => r(x + 1), 1000)))
  .then(console.log); // 2 (1秒后)

async/await与并行执行

常见的误用是过度序列化异步操作:

// 低效写法
async function slowFetch() {
  const a = await fetch('/api/a');
  const b = await fetch('/api/b'); // 等待a完成才开始
  return [a, b];
}

// 正确并行写法
async function fastFetch() {
  const aPromise = fetch('/api/a');
  const bPromise = fetch('/api/b');
  return Promise.all([aPromise, bPromise]);
}

但并行执行时错误处理需要特别注意:

async function parallelWithError() {
  try {
    const [a, b] = await Promise.all([
      fetch('/api/a'),
      fetch('/api/b').then(() => Promise.reject('故意错误'))
    ]);
  } catch (e) {
    console.log('捕获到错误:', e); // 任何一个Promise拒绝都会触发
  }
}

Promise构造函数的立即执行

Promise构造函数会立即执行执行器函数:

console.log('开始');
new Promise(resolve => {
  console.log('执行器内部');
  resolve();
});
console.log('结束');

// 输出顺序:
// 开始
// 执行器内部
// 结束

这个特性常被用来包装回调API:

function delay(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

delay(1000).then(() => console.log('1秒后'));

then回调的多次调用问题

一个Promise可以附加多个then回调:

const p = Promise.resolve('数据');
p.then(v => console.log('回调1:', v));
p.then(v => console.log('回调2:', v)); // 也会执行

但如果then回调中抛出错误,不会影响其他回调:

const p = Promise.resolve('安全');
p.then(() => { throw new Error('爆炸!') });
p.then(() => console.log('仍然执行')); // 这行会正常执行

async函数的隐式返回

没有return的async函数实际上返回一个resolved为undefined的Promise:

async function noReturn() {
  // 没有return语句
}

noReturn().then(v => console.log(v)); // 输出undefined

即使函数内部抛出错误,返回的也是rejected Promise而非直接抛出:

async function throwsError() {
  throw new Error('内部错误');
}

// 需要在调用处捕获
throwsError().catch(e => console.log('捕获:', e));

Promise.race的早期终止陷阱

Promise.race在第一个Promise敲定时就会返回,但其他Promise仍会继续执行:

function createTask(id, delay) {
  return new Promise(resolve => {
    setTimeout(() => {
      console.log(`任务${id}完成`);
      resolve(id);
    }, delay);
  });
}

Promise.race([
  createTask(1, 1000),
  createTask(2, 2000)
]).then(winner => console.log(`胜者: ${winner}`));

// 输出:
// 任务1完成
// 胜者: 1
// 任务2完成 (仍然会执行)

微任务队列的递归爆炸

微任务的递归添加会导致事件循环被阻塞:

function recursiveMicrotask(count = 0) {
  if (count >= 100000) return;
  Promise.resolve().then(() => {
    console.log(`微任务 ${count}`);
    recursiveMicrotask(count + 1);
  });
}

recursiveMicrotask(); // 浏览器可能会卡死

相比之下,用setTimeout实现的递归是"友好"的:

function recursiveMacrotask(count = 0) {
  if (count >= 100) return;
  setTimeout(() => {
    console.log(`宏任务 ${count}`);
    recursiveMacrotask(count + 1);
  }, 0);
}

recursiveMacrotask(); // 会分时执行

await的非Promise等待

await可以等待任何值,非Promise值会被自动包装:

async function awaitNonPromise() {
  const v = await 42; // 等价于await Promise.resolve(42)
  console.log(v); // 42
}

但等待thenable对象会有特殊行为:

async function awaitThenable() {
  const v = await {
    then(resolve) {
      setTimeout(() => resolve('自定义then'), 100);
    }
  };
  console.log(v); // '自定义then' (100ms后)
}

Promise.allSettled的特殊行为

与Promise.all不同,allSettled永远不会reject:

Promise.allSettled([
  Promise.resolve('成功'),
  Promise.reject('失败')
]).then(results => {
  console.log(results);
  // [
  //   { status: "fulfilled", value: "成功" },
  //   { status: "rejected", reason: "失败" }
  // ]
});

async生成器函数的交互

async生成器函数结合了async和生成器特性:

async function* asyncGen() {
  yield await Promise.resolve(1);
  yield await Promise.resolve(2);
}

(async () => {
  for await (const num of asyncGen()) {
    console.log(num); // 1, 然后2
  }
})();

这种模式在处理流数据时特别有用,但要注意错误传播方式与普通async函数不同。

本站部分内容来自互联网,一切版权均归源网站或源作者所有。

如果侵犯了你的权益请来信告知我们删除。邮箱:cc@cccx.cn

前端川

前端川,陈川的代码茶馆🍵,专治各种不服的Bug退散符💻,日常贩卖秃头警告级的开发心得🛠️,附赠一行代码笑十年的摸鱼宝典🐟,偶尔掉落咖啡杯里泡开的像素级浪漫☕。‌