“异步”不异步?从 fetch 到 async/await,我拨开了 JavaScript 的面纱

61 阅读4分钟

“异步”不异步?从 fetch 到 async/await,我拨开了 JavaScript 的面纱

本文带你深入掘金一线大厂面试官最爱问的异步编程底层逻辑——从 Promise 到 async/await,不止会用,更要懂它为什么能“等”。


引子:你以为你写的 async 是同步?

const main = async () => {
  const res = await fetch('https://api.github.com/users/shunwuyu/repos');
  console.log(res);
  console.log(111);
  const data = await res.json();
  console.log(data);
}
main();

这段代码看起来像“同步”,但真的是同步吗?
如果你在面试中只说:“await 让异步变同步”,恭喜你——离挂掉不远了

今天我们就以这段看似简单的代码为引子,层层拆解 async/await 背后的执行机制、事件循环调度、Promise 封装原理,并结合 Node.js 中的文件读取场景,打通浏览器与服务端异步模型的认知壁垒。


一、从回调地狱到 async/await:异步演进简史

1. 回调函数(Callback):信任危机的开始

fs.readFile('./1.html', 'utf-8', (err, data) => {
  if (err) return console.error(err);
  console.log(data);
});

问题:回调嵌套深、错误处理分散、控制流难以追踪。这就是著名的“回调地狱”。

2. Promise:给异步一个“契约”

const p = new Promise((resolve, reject) => {
  fs.readFile('./1.html', 'utf-8', (err, data) => {
    err ? reject(err) : resolve(data);
  });
});

p.then(data => console.log(data));

Promise 的核心思想是:将异步操作的结果封装成一个对象,通过 .then() 链式消费。但它仍有痛点:

  • 语法冗长
  • 错误需每个 .catch() 单独处理
  • 无法直观表达“顺序依赖”

3. async/await:语法糖?还是革命?

const html = await p;
console.log(html);

表面是糖,底层是 Promise + Generator 的精妙组合
ES2017(ES8)引入的 async/await,本质是 基于 Promise 的语法糖,但它极大提升了代码可读性与心智模型一致性。

✅ 关键认知:async 函数永远返回一个 Promise。即使你 return 42,也会被包装成 Promise.resolve(42)


二、逐行解析:你的 await 到底干了什么?

回到开头的浏览器代码:

const res = await fetch('https://api.github.com/users/shunwuyu/repos');

Step 1: fetch 返回的是什么?

fetch() 返回一个 Promise。注意:此时网络请求已经发出,但响应尚未到达。

Step 2: await 做了什么?

  • 暂停当前 async 函数的执行(不是整个线程!
  • 将后续代码(console.log(res) 及之后)注册为该 Promise 的 .then() 回调
  • 控制权交还给事件循环(Event Loop)

🧠 面试高频点:await 不会阻塞主线程!它只是让当前函数“暂停”,其他任务(如用户点击、定时器)仍可执行。

Step 3: res.json() 为何也要 await

因为 res.json() 也返回一个 Promise!HTTP 响应体是流(stream),.json() 是异步解析过程。

// 错误写法 ❌
const data = res.json(); // data 是 Promise,不是对象!

// 正确写法 ✅
const data = await res.json();

💡 设计哲学:所有可能涉及 I/O 的操作都应是异步的,避免阻塞渲染或事件处理。


三、Node.js 场景:自定义 Promise 包装 fs

这段代码非常典型:

const p = new Promise((resolve, reject) => {
  fs.readFile('./1.html', 'utf-8', (err, data) => {
    err ? reject(err) : resolve(data);
  });
});

为什么需要手动包装?

因为 fs.readFileNode.js 的回调风格 API,不属于 Promise 规范。为了能用 await,必须将其“Promise 化”。

更优雅的写法:util.promisify

Node.js 内置工具帮你自动转换:

import { promisify } from 'util';
import { readFile } from 'fs';

const readFileAsync = promisify(readFile);
const html = await readFileAsync('./1.html', 'utf-8');

✅ 最佳实践:优先使用原生 Promise API 或 promisify,避免手动写 Promise 构造器(易出错,如忘记 reject)。


四、深入底层:async/await 与事件循环

很多人以为 await 是“等待”,其实它是 事件循环的调度指令

考虑以下代码:

async function foo() {
  console.log('A');
  await Promise.resolve();
  console.log('B');
}

foo();
console.log('C');

输出顺序:A → C → B

为什么?

  • await Promise.resolve() 相当于 Promise.resolve().then(() => console.log('B'))
  • .then() 回调被放入 微任务队列(Microtask Queue)
  • 当前同步代码(console.log('C'))执行完后,才清空微任务队列

🔥 面试必考:宏任务 vs 微任务setTimeout 是宏任务,Promise.then / await 是微任务。


五、延伸思考:性能与错误处理

1. 并行 vs 串行

// 串行(慢)
const user = await fetchUser();
const posts = await fetchPosts();

// 并行(快)
const [user, posts] = await Promise.all([
  fetchUser(),
  fetchPosts()
]);

⚠️ 注意:Promise.all 任一 reject 会整体失败,可用 Promise.allSettled 替代。

2. 错误处理:try/catch 是你的朋友

try {
  const res = await fetch(url);
  if (!res.ok) throw new Error('Network error');
  const data = await res.json();
} catch (err) {
  console.error('Fetch failed:', err);
}

✅ 统一错误入口,比 .catch() 更符合直觉。


六、高频面试题关联

面试题本文覆盖点
async/await 原理?基于 Promise + Generator,语法糖
await 会阻塞吗?不阻塞主线程,仅暂停当前函数
如何实现 sleepawait new Promise(r => setTimeout(r, ms))
微任务有哪些?Promise.then, await, queueMicrotask
如何将回调转 Promise?手动包装 or util.promisify

结语:别再把 async 当“同步”用了

async/await 的伟大之处,在于用同步的写法表达异步的逻辑,但它从未改变 JavaScript 单线程、非阻塞的本质。

真正的大厂工程师,不仅会写 await,更清楚:

  • 它如何与事件循环协作
  • 它在 V8 引擎中如何被编译
  • 它与 Promise 的等价转换关系
  • 它在性能优化中的取舍

下次面试,当被问“await 底层怎么实现的?”,你可以微微一笑:“要不,我们聊聊 Generator 和状态机?”