前端异步编程三板斧:从面试题到底层思维

最近在准备面试,刷到了三道经典的异步编程题:sleep、并发调度器、EventEmitter。说实话,这些东西在日常工作中很少会从零手写 ------ 毕竟有 p-limitmitt 这些成熟的轮子。但当我尝试不看答案写一遍时,才发现自己对 Promise 的理解其实还停留在"会用"的层面。

这篇文章是我重新理解这三个模式的学习笔记。与其死记代码,不如搞清楚它们背后的思维模型。

问题的起源

JavaScript 是单线程的,但前端需要处理大量异步操作:网络请求、定时器、用户交互......如何优雅地组织这些异步逻辑,是每个前端都绑不开的话题。

ES6 之前,我们用回调函数;ES6 带来了 Promise;ES7 又有了 async/await。语法在进化,但底层的思维模型其实没变------如何控制异步任务的执行时机和顺序

这三道面试题,恰好覆盖了异步编程的三个核心场景:

graph LR A[异步编程核心模式] --> B[延迟执行] A --> C[并发控制] A --> D[事件通信] B --> B1[sleep] C --> C1[Scheduler] D --> D1[EventEmitter]

核心概念探索

一、sleep:理解 Promise 的本质

1.1 最简实现

javascript 复制代码
// 环境:浏览器 / Node.js
// 场景:延迟执行

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

// 使用
async function demo() {
  console.log('start');
  await sleep(1000);
  console.log('1 second later');
}

就这么几行代码,但它揭示了 Promise 最核心的设计:Promise 是一个状态容器,而 resolve 是改变状态的开关

1.2 拆解思考过程

很多人会卡在"怎么让 await 等待 1 秒"这个问题上。我的理解是,需要换一个角度思考:

错误的思维方式 -> "我要让代码暂停 1 秒" ← 这是命令式思维

正确的思维方式 -> "我要创建一个 Promise,它会在 1 秒后变成 fulfilled 状态" ← 这是声明式思维

Promise 构造函数接收一个 executor 函数,这个函数会立即执行 。executor 的两个参数 resolvereject 是用来改变 Promise 状态的"遥控器"。

javascript 复制代码
// 环境:浏览器 / Node.js
// 场景:理解 Promise executor 的执行时机

const promise = new Promise((resolve, reject) => {
  console.log('executor runs immediately'); // 这行会立即执行
  setTimeout(() => {
    console.log('timeout callback');
    resolve('done'); // 1 秒后,Promise 状态变为 fulfilled
  }, 1000);
});

console.log('after new Promise');

// 输出顺序:
// executor runs immediately
// after new Promise
// timeout callback

1.3 为什么 await 能"暂停"

await 并不是真的让代码暂停------JavaScript 依然是单线程的,不可能真的阻塞。await 的作用是:暂停当前 async 函数的执行,等 Promise 状态改变后再继续

javascript 复制代码
// 环境:浏览器 / Node.js
// 场景:理解 await 的执行机制

async function test() {
  console.log('1');
  await sleep(1000); // 这里 async 函数暂停,但主线程继续执行其他代码
  console.log('2');  // 1 秒后继续执行
}

test();
console.log('3');

// 输出顺序:1, 3, (等待 1 秒), 2

关键洞察await 后面的代码,本质上是被放进了 Promise 的 .then() 回调中。async/await 是 Promise 的语法糖,但这层糖衣让代码看起来像同步的。

二、Scheduler:并发控制的核心思想

2.1 问题场景

假设你要批量上传 100 张图片,如果同时发起 100 个请求,可能会:

  • 浏览器请求并发数限制(Chrome 对同一域名最多 6 个)
  • 服务器压力过大
  • 内存占用飙升

所以我们需要一个"调度器",控制同时进行的任务数量。

2.2 核心实现

javascript 复制代码
// 环境:浏览器 / Node.js
// 场景:限制最大并发数为 2

function Scheduler(maxConcurrent) {
  this.maxConcurrent = maxConcurrent;
  this.running = 0;      // 当前正在执行的任务数
  this.queue = [];       // 等待队列
}

Scheduler.prototype.add = function (task) {
  return new Promise((resolve, reject) => {
    // 把"执行任务"这个动作封装成一个函数
    const run = () => {
      this.running++;
      Promise.resolve()
        .then(() => task())      // 执行任务
        .then(resolve, reject)   // 把任务的结果传递给外部的 Promise
        .finally(() => {
          this.running--;
          // 任务完成后,检查队列中是否有等待的任务
          if (this.queue.length > 0) {
            const next = this.queue.shift();
            next();
          }
        });
    };

    // 决策:立即执行,还是排队等待
    if (this.running < this.maxConcurrent) {
      run();
    } else {
      this.queue.push(run);
    }
  });
};

2.3 逐行拆解

这段代码的精妙之处在于 add 方法返回的 Promise。让我用一个图来表示执行流程:

flowchart TD A[调用 add-task-] --> B{running < max?} B -->|是| C[立即执行 run--] B -->|否| D[push 到 queue] C --> E[running++] E --> F[执行 task--] F --> G[resolve/reject] G --> H[running--] H --> I{queue 有任务?} I -->|是| J[shift 并执行] I -->|否| K[结束] D -.-> |等待其他任务完成| J

关键洞察 1add 返回的 Promise 不是 task() 返回的 Promise,而是一个"包装"过的 Promise。这个包装让我们能控制任务何时开始执行。

关键洞察 2run 函数被存入队列的是函数本身,不是执行结果。这是闭包的典型应用------run 函数"记住"了对应的 resolvereject

2.4 使用示例

javascript 复制代码
// 环境:浏览器 / Node.js
// 场景:模拟批量请求,最多同时 2 个

const scheduler = new Scheduler(2);

function createTask(id, delay) {
  return () => new Promise((resolve) => {
    console.log(`Task ${id} started`);
    setTimeout(() => {
      console.log(`Task ${id} finished`);
      resolve(id);
    }, delay);
  });
}

// 添加 4 个任务
scheduler.add(createTask(1, 1000));
scheduler.add(createTask(2, 500));
scheduler.add(createTask(3, 300));
scheduler.add(createTask(4, 400));

// 输出顺序:
// Task 1 started  (t=0)
// Task 2 started  (t=0)    ← 同时最多 2 个
// Task 2 finished (t=500)
// Task 3 started  (t=500)  ← Task 2 完成后,Task 3 才开始
// Task 3 finished (t=800)
// Task 4 started  (t=800)
// Task 1 finished (t=1000)
// Task 4 finished (t=1200)

三、EventEmitter:发布订阅模式

3.1 为什么需要事件系统

组件之间需要通信,但如果直接互相调用,会导致紧耦合。事件系统提供了一种"松耦合"的通信方式:

scss 复制代码
A 组件 ---(emit 'dataReady')---> EventEmitter ---(notify)---> B 组件
                                                              C 组件

发布者不需要知道谁在监听,订阅者也不需要知道谁在发布。

3.2 核心实现

javascript 复制代码
// 环境:浏览器 / Node.js
// 场景:实现简易事件系统

function EventEmitter() {
  this._events = {}; // 存储结构:{ eventName: [handler1, handler2, ...] }
}

// 订阅事件
EventEmitter.prototype.on = function (event, handler) {
  if (!this._events[event]) {
    this._events[event] = [];
  }
  this._events[event].push(handler);
};

// 取消订阅
EventEmitter.prototype.off = function (event, handler) {
  if (!this._events[event]) return;
  
  if (handler == null) {
    // 没传 handler,清空该事件的所有监听器
    this._events[event] = [];
    return;
  }
  // 过滤掉指定的 handler
  this._events[event] = this._events[event].filter((h) => h !== handler);
};

// 触发事件
EventEmitter.prototype.emit = function (event, ...args) {
  const handlers = this._events[event];
  if (!handlers || handlers.length === 0) return;
  
  handlers.forEach((h) => h.apply(this, args));
};

// 只监听一次
EventEmitter.prototype.once = function (event, handler) {
  // 包装原 handler,执行后自动取消订阅
  const wrap = (...args) => {
    this.off(event, wrap); // 注意:off 的是 wrap,不是 handler
    handler.apply(this, args);
  };
  this.on(event, wrap);
};

3.3 once 的实现技巧

once 的实现有个容易踩的坑:

javascript 复制代码
// 错误实现
EventEmitter.prototype.once = function (event, handler) {
  const wrap = (...args) => {
    this.off(event, handler); // ❌ 错!handler 没有被 on 注册过
    handler.apply(this, args);
  };
  this.on(event, wrap);
};

// 正确实现
EventEmitter.prototype.once = function (event, handler) {
  const wrap = (...args) => {
    this.off(event, wrap); // ✅ 移除的是 wrap
    handler.apply(this, args);
  };
  this.on(event, wrap);
};

关键洞察on(event, wrap) 注册的是 wrap,所以 off 时也要移除 wrap。这是闭包的又一个应用------wrap 函数"记住"了原始的 handler

3.4 使用示例

javascript 复制代码
// 环境:浏览器 / Node.js
// 场景:组件间通信

const emitter = new EventEmitter();

// 订阅
function handleData(data) {
  console.log('Received:', data);
}
emitter.on('data', handleData);

// 只监听一次
emitter.once('ready', () => {
  console.log('System ready!');
});

// 触发
emitter.emit('data', { id: 1 }); // Received: { id: 1 }
emitter.emit('ready');          // System ready!
emitter.emit('ready');          // (无输出,因为 once 只触发一次)

// 取消订阅
emitter.off('data', handleData);
emitter.emit('data', { id: 2 }); // (无输出)

实际场景思考

场景 A:图片批量上传

javascript 复制代码
// 环境:浏览器
// 场景:批量上传图片,限制并发数,显示进度

async function uploadImages(files, maxConcurrent = 3) {
  const scheduler = new Scheduler(maxConcurrent);
  const results = [];
  let completed = 0;
  
  const uploadTasks = files.map((file, index) => {
    return scheduler.add(async () => {
      const result = await uploadSingleImage(file);
      completed++;
      console.log(`Progress: ${completed}/${files.length}`);
      return result;
    });
  });
  
  return Promise.all(uploadTasks);
}

// 模拟单张图片上传
function uploadSingleImage(file) {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve({ name: file.name, url: 'https://...' });
    }, Math.random() * 2000);
  });
}

场景 B:带取消功能的延迟执行

javascript 复制代码
// 环境:浏览器 / Node.js
// 场景:可取消的 sleep

function cancellableSleep(ms) {
  let timeoutId;
  let rejectFn;
  
  const promise = new Promise((resolve, reject) => {
    rejectFn = reject;
    timeoutId = setTimeout(resolve, ms);
  });
  
  promise.cancel = () => {
    clearTimeout(timeoutId);
    rejectFn(new Error('Cancelled'));
  };
  
  return promise;
}

// 使用
const sleepPromise = cancellableSleep(5000);

sleepPromise
  .then(() => console.log('Done'))
  .catch((err) => console.log(err.message));

// 2 秒后取消
setTimeout(() => sleepPromise.cancel(), 2000);
// 输出:Cancelled

场景 C:用 EventEmitter 实现简易状态管理

javascript 复制代码
// 环境:浏览器
// 场景:简易的全局状态管理

function createStore(initialState) {
  const emitter = new EventEmitter();
  let state = initialState;
  
  return {
    getState: () => state,
    
    setState: (newState) => {
      const prevState = state;
      state = { ...state, ...newState };
      emitter.emit('change', state, prevState);
    },
    
    subscribe: (listener) => {
      emitter.on('change', listener);
      // 返回取消订阅的函数
      return () => emitter.off('change', listener);
    }
  };
}

// 使用
const store = createStore({ count: 0 });

const unsubscribe = store.subscribe((state, prevState) => {
  console.log('State changed:', prevState, '->', state);
});

store.setState({ count: 1 }); // State changed: { count: 0 } -> { count: 1 }
store.setState({ count: 2 }); // State changed: { count: 1 } -> { count: 2 }

unsubscribe();
store.setState({ count: 3 }); // (无输出)

小结

这三道面试题,表面上是考手写代码,实际上是在考察对异步编程的理解深度:

题目 考察点 核心思维
sleep Promise 基础 Promise 是状态容器,resolve 是状态开关
Scheduler 异步控制流 用队列 + 计数器实现并发限制
EventEmitter 设计模式 发布订阅解耦组件通信

我的体会是,与其死记代码,不如把每一行代码的"为什么"搞清楚。面试时能讲清楚思路,比默写出一字不差的代码更重要。

参考资料

相关推荐
会联营的陆逊2 小时前
Vite + Vue3 构建优化:CDN 外部化方案
前端·vue.js
毛骗导演2 小时前
对话历史越来越长,OpenClaw 是怎么「压缩」掉的?——深读 Compaction 机制源码
前端·架构
广州华水科技2 小时前
单北斗GNSS变形监测如何在大坝安全中发挥关键作用?
前端
外派叙利亚2 小时前
uniapp 颜色卡条拖动
前端·javascript·uni-app
MichaelJohn2 小时前
qiankun 微前端实战(二):主应用搭建 — 安装、注册与全局状态
前端
ruanCat2 小时前
simple-git-hooks 踩坑实录:钩子装对了却从没触发过,原来是 .git 目录捣的鬼
前端·git·代码规范
兆子龙2 小时前
React Fiber 架构与 Vue 响应式原理深度对比
前端·javascript
用户363858544182 小时前
Query 和 jQuery UI
前端
labixiong2 小时前
React Fiber 架构全景解析(一)
前端·react.js