Skip to content

JavaScript 异步编程:让代码不再"干等"

引言:为什么需要异步编程?

想象一下,你正在餐厅点餐。如果餐厅采用"同步"方式工作,那么服务员接待你后,要一直站在你旁边等你吃完才能去服务下一位顾客,这显然很低效。正确的做法是——服务员点完你的单就去服务其他顾客,等厨房准备好你的食物后再通知你,这就是"异步"的思想。

在前端开发中,JavaScript 默认是单线程执行的,如果所有操作都同步进行,那么当遇到耗时操作(如网络请求、文件读写)时,整个页面就会"卡住",用户体验极差。异步编程正是解决这个问题的关键技术,它让 JavaScript 能够在执行耗时任务的同时,继续响应用户的其他操作,提升应用的性能和体验。

异步基础

同步 vs 异步:一个生活化的类比

同步操作:就像你打电话给朋友,必须等待对方接听并完成对话后,才能继续做其他事情。

异步操作:更像是你发送一条短信,发完后你可以立即去做别的事,等朋友回复时你会收到通知。

javascript
// 同步操作示例
console.log("开始");
const result = someTimeConsumingFunction(); // 这里会阻塞代码执行
console.log("结果:", result);
console.log("结束");

// 异步操作示例
console.log("开始");
someTimeConsumingFunction(function(result) {
  // 这是一个回调函数,会在耗时操作完成后执行
  console.log("结果:", result);
});
console.log("结束"); // 这行会在获得结果之前执行

回调函数:异步操作的基础

回调函数是异步编程的基础,它就像是你委托朋友办事后留下的联系方式——"事情办完了给我打电话"。

javascript
// 使用回调函数处理异步操作
function loadData(url, callback) {
  // 模拟网络请求
  setTimeout(() => {
    const data = { name: "张三", age: 25 };
    callback(data); // 数据准备好后,调用回调函数
  }, 1000);
}

// 使用loadData函数
console.log("开始获取数据...");
loadData("https://api.example.com/user", function(userData) {
  console.log("数据获取成功:", userData);
});
console.log("请求已发出,等待数据返回...");

事件循环:JavaScript 异步的心脏

事件循环是 JavaScript 实现异步的核心机制,它就像是一个永不停歇的管家,不断检查是否有任务需要执行。

想象你有一个待办事项清单:

  1. 主清单(调用栈):按顺序完成的工作
  2. 附加清单(任务队列):临时加入的工作

管家(事件循环)会先处理完主清单上的所有工作,然后才查看附加清单,把上面的工作依次加入主清单处理。

javascript
console.log("第一步");

// setTimeout是一个异步API,它的回调函数会被放入任务队列
setTimeout(() => {
  console.log("第三步"); // 这会最后执行
}, 0);

console.log("第二步");

// 执行顺序: "第一步" -> "第二步" -> "第三步"

任务队列:事件的候车室

JavaScript 中的任务队列有两种:

  • 宏任务队列(MacroTask):存放setTimeout、setInterval、I/O等操作的回调
  • 微任务队列(MicroTask):存放Promise回调、process.nextTick等

微任务总是比宏任务先执行,就像是VIP通道一样。

javascript
console.log("开始");

setTimeout(() => {
  console.log("宏任务执行"); // 第四步执行
}, 0);

Promise.resolve().then(() => {
  console.log("微任务执行"); // 第三步执行
});

console.log("结束");

// 执行顺序: "开始" -> "结束" -> "微任务执行" -> "宏任务执行"

Promise:异步操作的承诺

Promise 基础:承诺终会兑现(或拒绝)

Promise 就像是餐厅给你的取餐票据,它代表一个"承诺"——未来一定会给你一个结果,可能是成功(食物准备好了),也可能是失败(食材用完了)。

Promise 有三种状态:

  • pending(等待中):初始状态
  • fulfilled(已兑现):操作成功
  • rejected(已拒绝):操作失败
javascript
// 创建一个Promise
const promise = new Promise((resolve, reject) => {
  // 模拟异步操作
  setTimeout(() => {
    const success = true;
    if (success) {
      resolve("操作成功!"); // 承诺兑现
    } else {
      reject("出错了!"); // 承诺被拒
    }
  }, 1000);
});

// 使用Promise
promise
  .then(result => {
    console.log(result); // 当Promise兑现时执行
  })
  .catch(error => {
    console.log(error); // 当Promise被拒时执行
  });

Promise 链式调用:优雅地处理异步流程

Promise 的优势之一是可以链式调用,避免了回调地狱(回调函数层层嵌套)的问题。

javascript
// 回调地狱
getData(function(a) {
  getMoreData(a, function(b) {
    getEvenMoreData(b, function(c) {
      getEvenMoreData(c, function(d) {
        getFinalData(d, function(result) {
          console.log("最终结果:", result);
        });
      });
    });
  });
});

// 使用Promise的链式调用
getData()
  .then(a => getMoreData(a))
  .then(b => getEvenMoreData(b))
  .then(c => getEvenMoreData(c))
  .then(d => getFinalData(d))
  .then(result => {
    console.log("最终结果:", result);
  })
  .catch(error => {
    console.log("出错了:", error);
  });

Promise 常用方法:协调多个异步操作

Promise 提供了多种静态方法来处理多个异步操作:

javascript
// Promise.all:等待所有Promise都成功
// 就像等待所有朋友到齐后才开始聚餐
Promise.all([
  fetch('/api/users'),
  fetch('/api/products'),
  fetch('/api/orders')
])
.then(([users, products, orders]) => {
  console.log('所有数据都已获取到');
})
.catch(error => {
  console.log('至少有一个请求失败了');
});

// Promise.race:采用第一个完成的结果
// 像是多个朋友帮你找同一个东西,谁先找到用谁的
Promise.race([
  fetch('/api/server1'),
  fetch('/api/server2')
])
.then(result => {
  console.log('有一个服务器响应了');
});

// Promise.allSettled:等待所有Promise都完成(无论成功失败)
Promise.allSettled([
  fetch('/api/users'),
  fetch('/api/products')
])
.then(results => {
  // results是一个数组,包含每个Promise的结果
  results.forEach(result => {
    if (result.status === 'fulfilled') {
      console.log('成功:', result.value);
    } else {
      console.log('失败:', result.reason);
    }
  });
});

Promise 错误处理:不让错误无声无息

Promise 提供了优雅的错误处理机制,使用.catch()捕获链式调用中的任何错误。

javascript
fetchUserData()
  .then(user => {
    if (!user.isActive) {
      throw new Error('用户已禁用'); // 抛出错误会被catch捕获
    }
    return fetchUserPosts(user.id);
  })
  .then(posts => {
    console.log('用户帖子:', posts);
  })
  .catch(error => {
    console.log('处理出错:', error.message);
    // 这里可以统一处理所有错误
  })
  .finally(() => {
    // 无论成功失败,都会执行
    console.log('操作完成,清理资源');
  });

async/await:更优雅的异步处理

语法特性:让异步代码看起来像同步代码

async/await 是 Promise 的语法糖,它让异步代码的编写和阅读更接近同步代码,大大提高了可读性。

javascript
// 使用Promise的写法
function getUserData() {
  return fetch('/api/user')
    .then(response => response.json())
    .then(user => {
      return fetch(`/api/posts?userId=${user.id}`);
    })
    .then(response => response.json());
}

// 使用async/await的写法
async function getUserData() {
  const userResponse = await fetch('/api/user');
  const user = await userResponse.json();
  const postsResponse = await fetch(`/api/posts?userId=${user.id}`);
  return await postsResponse.json();
}

// 调用
getUserData()
  .then(posts => console.log(posts))
  .catch(error => console.log(error));

错误处理:直观的 try/catch

async/await 可以使用传统的 try/catch 进行错误处理,更符合直觉。

javascript
async function getUserData() {
  try {
    const userResponse = await fetch('/api/user');
    const user = await userResponse.json();
    const postsResponse = await fetch(`/api/posts?userId=${user.id}`);
    const posts = await postsResponse.json();
    return posts;
  } catch (error) {
    console.log('获取数据出错:', error.message);
    // 可以返回默认值或重新抛出错误
    return [];
  }
}

并发控制:同时发起多个异步请求

async/await 并不意味着要串行执行所有异步操作,你可以结合 Promise.all 实现并发。

javascript
async function getUsersAndProducts() {
  try {
    // 同时发起两个请求
    const [usersResponse, productsResponse] = await Promise.all([
      fetch('/api/users'),
      fetch('/api/products')
    ]);
    
    // 同时解析两个响应
    const [users, products] = await Promise.all([
      usersResponse.json(),
      productsResponse.json()
    ]);
    
    return { users, products };
  } catch (error) {
    console.log('出错了:', error);
    return { users: [], products: [] };
  }
}

最佳实践:让代码更健壮

  1. 永远不要忘记错误处理
javascript
// 不好的实践
async function getData() {
  const response = await fetch('/api/data');
  const data = await response.json();
  return data;
}

// 好的实践
async function getData() {
  try {
    const response = await fetch('/api/data');
    if (!response.ok) {
      throw new Error(`HTTP错误! 状态码: ${response.status}`);
    }
    const data = await response.json();
    return data;
  } catch (error) {
    console.error('获取数据失败:', error);
    throw error; // 重新抛出或返回默认值
  }
}
  1. 注意 async 函数总是返回 Promise
javascript
// 这看起来返回一个数字
async function getValue() {
  return 42;
}

// 实际上返回的是Promise
getValue().then(value => console.log(value)); // 输出 42

异步应用

网络请求:前端最常见的异步场景

javascript
// 封装一个通用的API请求函数
async function apiRequest(url, method = 'GET', data = null) {
  try {
    const options = {
      method,
      headers: {
        'Content-Type': 'application/json'
      }
    };
    
    if (data) {
      options.body = JSON.stringify(data);
    }
    
    const response = await fetch(url, options);
    
    if (!response.ok) {
      throw new Error(`请求失败: ${response.status}`);
    }
    
    return await response.json();
  } catch (error) {
    console.error('API请求错误:', error);
    throw error;
  }
}

// 使用示例
async function getUserProfile(userId) {
  try {
    const user = await apiRequest(`/api/users/${userId}`);
    const posts = await apiRequest(`/api/users/${userId}/posts`);
    return { user, posts };
  } catch (error) {
    console.log('获取用户资料失败');
    return null;
  }
}

定时器:延迟执行和周期执行

javascript
// 将setTimeout封装为Promise
function delay(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

// 使用
async function showMessage() {
  console.log('正在加载...');
  await delay(2000); // 等待2秒
  console.log('数据加载完成!');
}

// 封装一个可取消的周期执行函数
function createInterval(callback, ms) {
  const intervalId = setInterval(callback, ms);
  return function cancel() {
    clearInterval(intervalId);
  };
}

// 使用
const stopCounting = createInterval(() => {
  console.log('计数器: ' + new Date().toLocaleTimeString());
}, 1000);

// 5秒后停止
setTimeout(() => {
  stopCounting();
  console.log('计数器已停止');
}, 5000);

动画处理:平滑的视觉效果

javascript
// 使用requestAnimationFrame实现平滑动画
function animateElement(element, targetPosition, duration) {
  const startPosition = element.offsetLeft;
  const distance = targetPosition - startPosition;
  const startTime = performance.now();
  
  function step(currentTime) {
    const elapsed = currentTime - startTime;
    const progress = Math.min(elapsed / duration, 1);
    const easeProgress = Math.pow(progress, 2); // 简单的缓动函数
    
    element.style.left = startPosition + distance * easeProgress + 'px';
    
    if (progress < 1) {
      requestAnimationFrame(step);
    }
  }
  
  requestAnimationFrame(step);
}

// 使用示例
// HTML: <div id="box" style="position: absolute; left: 0; width: 50px; height: 50px; background: red;"></div>
const box = document.getElementById('box');
animateElement(box, 300, 1000); // 在1秒内移动到左侧300px的位置

常见错误与注意事项

1. 忘记处理 Promise 的错误

javascript
// 错误示范:没有错误处理
fetch('/api/data')
  .then(response => response.json())
  .then(data => console.log(data));
  // 如果发生错误,会被吞掉,不易调试

// 正确做法
fetch('/api/data')
  .then(response => response.json())
  .then(data => console.log(data))
  .catch(error => console.error('获取数据出错:', error));

2. 在循环中错误使用异步函数

javascript
// 错误示范:这不会按顺序执行
async function processItems(items) {
  items.forEach(async (item) => {
    const result = await processItem(item);
    console.log(result); // 这些日志不会按顺序打印
  });
  console.log('所有项目处理完成'); // 这会在所有异步操作开始前就执行
}

// 正确做法:使用for...of循环
async function processItems(items) {
  for (const item of items) {
    const result = await processItem(item);
    console.log(result); // 会按顺序打印
  }
  console.log('所有项目处理完成'); // 所有异步操作完成后才会执行
}

// 或者使用Promise.all处理并行操作
async function processItemsParallel(items) {
  const promises = items.map(item => processItem(item));
  const results = await Promise.all(promises);
  console.log(results);
  console.log('所有项目处理完成');
}

3. 忘记 await async 函数的结果

javascript
// 错误示范
async function getUserData() {
  const userData = getUserFromAPI(); // 忘记了await
  console.log(userData); // 输出的是Promise对象,不是实际数据
}

// 正确做法
async function getUserData() {
  const userData = await getUserFromAPI();
  console.log(userData); // 现在是实际数据
}

4. 没有正确处理并发限制

javascript
// 错误示范:同时发起大量请求可能导致性能问题
async function fetchAllUserProfiles(userIds) {
  return Promise.all(userIds.map(id => fetchUserProfile(id)));
}

// 正确做法:限制并发请求数量
async function fetchAllUserProfilesWithLimit(userIds, concurrency = 3) {
  const results = [];
  const chunks = [];
  
  // 将用户ID分成多个小块
  for (let i = 0; i < userIds.length; i += concurrency) {
    chunks.push(userIds.slice(i, i + concurrency));
  }
  
  // 一次处理一个小块
  for (const chunk of chunks) {
    const chunkResults = await Promise.all(
      chunk.map(id => fetchUserProfile(id))
    );
    results.push(...chunkResults);
  }
  
  return results;
}

总结与拓展

JavaScript 异步编程是前端开发中不可或缺的一部分,掌握它能让你的应用更加流畅、响应迅速。我们从最基本的回调函数出发,经过 Promise 的改进,到最终的 async/await 语法糖,异步编程的方式不断进化,使得代码更易于编写和维护。

要成为异步编程的高手,建议你:

  1. 打牢基础:深入理解事件循环和任务队列机制
  2. 熟练掌握 Promise:它是所有现代异步编程的基础
  3. 优雅使用 async/await:让代码更易读,但不要忘记它背后的 Promise 原理
  4. 实践中学习:尝试重构现有的回调函数代码为 Promise 或 async/await

拓展阅读

  1. MDN JavaScript 异步编程指南
  2. You Don't Know JS: 异步与性能
  3. Jake Archibald: 事件循环可视化解析

记住,异步编程可能一开始有些难以理解,但通过不断实践,你一定能掌握这个强大的工具,让你的应用如丝般顺滑!

注:本文档会持续更新,欢迎关注!

用❤️分享前端技术 fedev.wiki