Skip to content

第 05 章 · 面试题集(异步编程)

异步是面试绝对核心:Promise、async/await、事件循环每场必问。务必能"手写 Promise"。


Q1:什么是事件循环?请说出执行顺序

考察点:JS 运行时机制、宏任务/微任务

标准答案: JS 是单线程语言,事件循环(Event Loop)让它能处理异步任务。流程:

  1. 执行调用栈中所有同步代码
  2. 同步代码执行完后,清空所有微任务(Promise.then、queueMicrotask、MutationObserver)
  3. 浏览器可能进行渲染
  4. 一个宏任务(setTimeout、I/O、UI 事件等)执行
  5. 重复 2-4

通俗理解: 事件循环就像餐厅的服务员,主线程是厨师。同步代码 = 立等的菜先做完;微任务 = VIP 通道,每次插队全清完;宏任务 = 普通通道,VIP 处理完才轮一个。

代码示例

js
console.log('1');
setTimeout(() => console.log('2'), 0);
Promise.resolve().then(() => console.log('3'));
console.log('4');
// 1 → 4 → 3 → 2

加分回答

  • requestAnimationFrame 是帧间任务,在渲染前执行
  • Node.js 事件循环更复杂,分多个阶段,process.nextTick 优先级最高
  • await 后面的代码会进入微任务队列

追问

  • async/await 与事件循环的关系?
  • Node 的 setImmediatesetTimeout(fn, 0) 谁先?(答:在 I/O 回调内 setImmediate 先;其他场景不确定)

Q2:宏任务和微任务的区别?分别有哪些?

考察点:异步任务分类

标准答案

类型API优先级
宏任务setTimeoutsetIntervalMessageChannel、I/O、UI 事件、setImmediate(Node)
微任务Promise.then/catch/finallyqueueMicrotaskMutationObserverprocess.nextTick(Node)

关键规则:每个宏任务执行完后,会清空所有微任务(包括微任务里产生的新微任务),再执行下一个宏任务。

通俗理解: 微任务是 VIP,宏任务是普通顾客。每接待一个普通顾客之间,必须把所有 VIP 全部接待完。

代码示例

js
setTimeout(() => {
  console.log('macro1');
  Promise.resolve().then(() => console.log('micro inside macro'));
}, 0);
setTimeout(() => console.log('macro2'), 0);

// macro1 → micro inside macro → macro2

追问

  • 如果微任务里又添加微任务,会怎样?(答:本轮清空,可能永远卡死,比如 Promise.then(() => Promise.resolve().then(...)) 无限)
  • process.nextTick 比 Promise 优先在哪?

Q3:手写 Promise

考察点:Promise 原理

简化版代码

js
class MyPromise {
  constructor(executor) {
    this.state = 'pending';
    this.value = undefined;
    this.reason = undefined;
    this.onFulfilledCbs = [];
    this.onRejectedCbs = [];

    const resolve = (value) => {
      if (this.state !== 'pending') return;
      this.state = 'fulfilled';
      this.value = value;
      this.onFulfilledCbs.forEach(cb => cb(value));
    };

    const reject = (reason) => {
      if (this.state !== 'pending') return;
      this.state = 'rejected';
      this.reason = reason;
      this.onRejectedCbs.forEach(cb => cb(reason));
    };

    try { executor(resolve, reject); }
    catch (e) { reject(e); }
  }

  then(onFulfilled, onRejected) {
    onFulfilled = typeof onFulfilled === 'function' ? onFulfilled : v => v;
    onRejected = typeof onRejected === 'function' ? onRejected : e => { throw e; };

    return new MyPromise((resolve, reject) => {
      const handle = (cb, val) => {
        queueMicrotask(() => {
          try {
            const x = cb(val);
            x instanceof MyPromise ? x.then(resolve, reject) : resolve(x);
          } catch (e) { reject(e); }
        });
      };

      if (this.state === 'fulfilled') handle(onFulfilled, this.value);
      else if (this.state === 'rejected') handle(onRejected, this.reason);
      else {
        this.onFulfilledCbs.push(v => handle(onFulfilled, v));
        this.onRejectedCbs.push(e => handle(onRejected, e));
      }
    });
  }

  catch(fn) { return this.then(null, fn); }
  finally(fn) {
    return this.then(
      v => MyPromise.resolve(fn()).then(() => v),
      e => MyPromise.resolve(fn()).then(() => { throw e; })
    );
  }

  static resolve(v) { return new MyPromise(r => r(v)); }
  static reject(e) { return new MyPromise((_, r) => r(e)); }
}

加分回答

  • 严格遵循 Promise/A+ 规范需要处理 thenable 对象
  • .then 的回调要异步执行(用 queueMicrotask 或 setTimeout)
  • 状态一旦改变不可逆

追问

  • 手写 Promise.all / race / allSettled?

Q4:Promise.all、race、allSettled、any 区别?

考察点:Promise 静态方法

标准答案

方法何时 resolve何时 reject返回值
all全部 fulfilled任一 rejected(立即)[v1, v2, ...]
race第一个 settle(无论成败)同左第一个 settle 的值
allSettled全部 settle(不管成败)永不[{status, value/reason}]
any任一 fulfilled全部 rejected第一个成功的值

代码示例

js
const p1 = Promise.resolve(1);
const p2 = Promise.reject('err');
const p3 = new Promise(r => setTimeout(() => r(3), 100));

await Promise.all([p1, p2, p3]).catch(e => console.log(e));        // err
await Promise.race([p1, p2, p3]);                                   // 1
await Promise.allSettled([p1, p2, p3]);                             // 三个 settle 结果
await Promise.any([p2, p3]);                                        // 3

通俗理解

  • all:组队打怪,一个挂全队失败
  • race:百米赛跑,第一个冲线就出结果
  • allSettled:全员体检,等所有人体检完
  • any:找一个能用的,找到就行

追问

  • Promise.all 失败后,没完成的请求会被取消吗?(答:不会,需配合 AbortController)
  • 手写一个 Promise.allSettled

Q5:async/await 的本质是什么?

考察点:语法糖原理

标准答案async 函数本质上返回一个 PromiseawaitPromise.then 的语法糖——它会暂停函数执行,等 Promise resolve 后用其值继续。

js
async function f() { return 1; }
// 等价于
function f() { return Promise.resolve(1); }

async function g() {
  const a = await fetch('/a');
  const b = await fetch('/b');
  return [a, b];
}
// 等价于
function g() {
  return fetch('/a').then(a =>
    fetch('/b').then(b => [a, b])
  );
}

加分回答

  • 实现上基于 Generator + Promise(参考 babel 转译产物)
  • await 后续代码进入微任务队列
  • async 函数报错(throw)等同于 reject

追问

  • async 函数里抛错怎么处理?(答:try/catch 或外层 .catch)
  • async/await 比 Promise 好在哪?比 Promise 差在哪?(差:可读性反而易写出串行)

Q6:以下代码输出顺序?

考察点:事件循环综合应用

js
async function async1() {
  console.log('async1 start');
  await async2();
  console.log('async1 end');
}
async function async2() {
  console.log('async2');
}
console.log('script start');
setTimeout(() => console.log('setTimeout'), 0);
async1();
new Promise((resolve) => {
  console.log('promise1');
  resolve();
}).then(() => console.log('promise2'));
console.log('script end');

标准答案

script start
async1 start
async2
promise1
script end
async1 end
promise2
setTimeout

逐步分析

  1. 同步:script start
  2. setTimeout 注册宏任务
  3. 调 async1 → 输出 async1 start,await async2() → 输出 async2,剩余代码进微任务
  4. 创建 Promise → 输出 promise1,then 注册微任务
  5. 同步:script end
  6. 微任务清空:先 async1 end(先注册的),再 promise2
  7. 宏任务:setTimeout

追问

  • 为什么 async1 end 在 promise2 前面?(答:await 进微任务的时机比 Promise.then 早一拍)

Q7:循环中 await 的陷阱

考察点:异步并发控制

问题代码

js
async function bad(urls) {
  const results = [];
  for (const url of urls) {
    results.push(await fetch(url));  // 串行,慢!
  }
  return results;
}

async function badForEach(urls) {
  urls.forEach(async (url) => {
    await fetch(url);  // forEach 不会等!
  });
  // 函数立刻返回,外面以为请求完成了,其实没有
}

正确做法

js
// 并行
async function good(urls) {
  return Promise.all(urls.map(url => fetch(url)));
}

// 限制并发数(防止打挂服务器)
async function asyncPool(limit, tasks) {
  const results = [];
  const executing = [];
  for (const task of tasks) {
    const p = Promise.resolve().then(() => task());
    results.push(p);
    const e = p.then(() => executing.splice(executing.indexOf(e), 1));
    executing.push(e);
    if (executing.length >= limit) await Promise.race(executing);
  }
  return Promise.all(results);
}

追问

  • 怎么实现"按顺序输出但并行请求"?

Q8:Promise 链式调用的值穿透 / 错误冒泡?

考察点:Promise 链规则

示例

js
Promise.resolve(1)
  .then()                          // 跳过,值穿透
  .then()                          // 跳过
  .then(v => console.log(v));     // 1

Promise.reject('err')
  .then(v => console.log('1', v))  // 跳过
  .then(v => console.log('2', v))  // 跳过
  .catch(e => console.log('catch', e));  // catch err

规则

  • .then(undefined, undefined) 时,值/错误会向后传递
  • 错误会一直往后冒泡,直到遇到 .catch.then(_, onReject)
  • .catch 后再 .then,链可以"恢复"成 fulfilled
js
Promise.reject('err')
  .catch(() => 'recovered')
  .then(v => console.log(v));  // 'recovered'

Q9:如何取消一个 Promise?

考察点:AbortController

标准答案: Promise 本身不能取消(一旦发出就到底),但可以通过 AbortController 让底层操作放弃,使 Promise reject。

js
const controller = new AbortController();
fetch('/api', { signal: controller.signal })
  .then(res => res.json())
  .catch(err => {
    if (err.name === 'AbortError') console.log('已取消');
  });

// 3 秒后取消
setTimeout(() => controller.abort(), 3000);

加分回答

  • AbortController 是通用方案,可用于 fetch、addEventListener 等
  • 可以用 Promise.race([fetch, timeoutPromise]) 实现超时
  • React 里要在 useEffect 清理函数中 abort,避免组件卸载后 setState

追问

  • 自定义可取消 Promise 怎么实现?(答:包一层,提供 cancel 方法,内部 reject)

Q10:什么是 unhandledrejection?怎么处理?

考察点:Promise 错误处理、生产环境监控

标准答案: 当一个 Promise 被 reject,但没有 .catch 处理时,浏览器会触发 unhandledrejection 事件。

js
window.addEventListener('unhandledrejection', (event) => {
  console.error('未处理的 Promise 错误:', event.reason);
  event.preventDefault(); // 阻止默认控制台输出
  reportToSentry(event.reason);
});

最佳实践

  • 每个 Promise 链结尾都应该有 .catch
  • async 函数最外层用 try/catch
  • 全局监听 unhandledrejection 用于上报

Q11(加餐):手写 sleep 函数

考察点:Promise + setTimeout

js
const sleep = (ms) => new Promise(r => setTimeout(r, ms));

async function demo() {
  console.log('start');
  await sleep(1000);
  console.log('1s 后');
  await sleep(500);
  console.log('又 500ms 后');
}

追问

  • 实现一个 sleep 但允许取消?

Q12(加餐):手写防抖与节流

考察点:闭包 + 异步

js
// 防抖:N ms 内频繁触发,只执行最后一次
function debounce(fn, delay) {
  let timer = null;
  return function (...args) {
    clearTimeout(timer);
    timer = setTimeout(() => fn.apply(this, args), delay);
  };
}

// 节流:N ms 内只执行一次
function throttle(fn, delay) {
  let last = 0;
  return function (...args) {
    const now = Date.now();
    if (now - last >= delay) {
      last = now;
      fn.apply(this, args);
    }
  };
}

对比

名词含义场景
防抖等用户停下来才触发搜索框联想、resize
节流固定频率触发滚动、按钮防连击