JavaScript系列03-异步编程全解析
本文介绍了异步相关的内容,包括:
- 回调函数与回调地狱
- Promise详解
- async/await语法
- Generator函数
- 事件循环机制
- 异步编程最佳实践
1、回调函数与回调地狱
JavaScript最初是为处理网页交互而设计的语言,异步编程是其核心特性之一。最早的异步编程方式是使用回调函数。
什么是回调函数?
回调函数是作为参数传递给另一个函数的函数,并在特定事件发生后执行。
// 基本回调示例
function fetchData(callback) {
setTimeout(() => {
const data = { name: "张三", age: 30 };
callback(data);
}, 1000);
}
fetchData(function(data) {
console.log("数据获取成功:", data);
});
回调函数使我们能够非阻塞地执行代码,这对于网络请求、文件操作等耗时任务尤为重要。
回调地狱问题
当多个异步操作需要依次执行时,回调函数会嵌套在一起,形成所谓的"回调地狱"(Callback Hell):
fetchUserData(function(user) {
console.log("获取用户信息:", user);
fetchUserPosts(user.id, function(posts) {
console.log("获取用户文章:", posts);
fetchPostComments(posts[0].id, function(comments) {
console.log("获取文章评论:", comments);
fetchCommentAuthor(comments[0].authorId, function(author) {
console.log("获取评论作者:", author);
// 还可以继续嵌套...
});
});
});
});
回调地狱带来的问题:
-
代码可读性差,形成"金字塔"结构
-
错误处理复杂
-
变量作用域容易混淆
-
代码维护困难
2、Promise详解
Promise是JavaScript中解决回调地狱的第一个标准方案,ES6正式将其纳入规范。
Promise的基本概念
Promise是一个代表异步操作最终完成或失败的对象。它有三种状态:
-
pending: 初始状态,既未完成也未失败
-
fulfilled: 操作成功完成
-
rejected: 操作失败
一旦Promise状态改变,就不能再变,这就是"承诺"的含义。
// 创建Promise
const promise = new Promise((resolve, reject) => {
// 异步操作
setTimeout(() => {
const success = Math.random() > 0.5;
if (success) {
resolve("操作成功"); // 成功时调用
} else {
reject("操作失败"); // 失败时调用
}
}, 1000);
});
// 使用Promise
promise
.then(result => {
console.log(result); // "操作成功"
})
.catch(error => {
console.log(error); // "操作失败"
})
.finally(() => {
console.log("无论成功失败都会执行");
});
Promise链式调用
Promise的优势之一是支持链式调用,可以优雅地处理依赖于前一个异步操作结果的多个异步操作:
fetchUserData(userId)
.then(user => {
console.log("用户数据:", user);
return fetchUserPosts(user.id); // 返回新的Promise
})
.then(posts => {
console.log("用户文章:", posts);
return fetchPostComments(posts[0].id);
})
.then(comments => {
console.log("文章评论:", comments);
return fetchCommentAuthor(comments[0].authorId);
})
.then(author => {
console.log("评论作者:", author);
})
.catch(error => {
// 捕获链中任何位置的错误
console.error("发生错误:", error);
});
这种链式写法将原本嵌套的回调拍平,提高了代码的可读性。
Promise常用方法
Promise类提供了几个实用的静态方法:
Promise.all()
Promise.all(): 并行执行多个Promise,当所有Promise都成功时返回结果数组
// 同时发起多个请求
const promises = [
fetch('https://api.example.com/users'),
fetch('https://api.example.com/posts'),
fetch('https://api.example.com/comments')
];
Promise.all(promises)
.then(responses => Promise.all(responses.map(res => res.json())))
.then(data => {
const [users, posts, comments] = data;
console.log(users, posts, comments);
})
.catch(error => {
// 任一请求失败都会进入catch
console.error("至少有一个请求失败:", error);
});
Promise.race()
Promise.race(): 返回最先解决或拒绝的Promise结果
// 实现超时功能
function fetchWithTimeout(url, ms) {
const fetchPromise = fetch(url);
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => reject(new Error("请求超时")), ms);
});
return Promise.race([fetchPromise, timeoutPromise]);
}
fetchWithTimeout('https://api.example.com/data', 3000)
.then(response => response.json())
.then(data => console.log(data))
.catch(error => console.error(error));
Promise.allSettled()
Promise.allSettled(): ES2020引入,等待所有Promise完成(无论成功或失败)
Promise.allSettled([
Promise.resolve(1),
Promise.reject('错误'),
Promise.resolve(3)
])
.then(results => {
console.log(results);
// [
// { status: "fulfilled", value: 1 },
// { status: "rejected", reason: "错误" },
// { status: "fulfilled", value: 3 }
// ]
});
Promise.any()
Promise.any(): ES2021引入,返回首个成功的Promise结果
// 尝试从多个源获取数据,返回最先成功的
Promise.any([
fetch('https://api-1.example.com/data').then(r => r.json()),
fetch('https://api-2.example.com/data').then(r => r.json()),
fetch('https://api-3.example.com/data').then(r => r.json())
])
.then(data => console.log("获取到数据:", data))
.catch(errors => console.error("所有请求均失败:", errors));
4、Generator函数
Generator函数是ES6引入的新特性,它允许函数在执行过程中暂停和恢复,这使得它特别适合实现异步控制流。
Generator基础
Generator函数在声明时使用星号(*)标记,内部使用yield关键字暂停执行:
function* numberGenerator() {
yield 1;
yield 2;
yield 3;
}
const gen = numberGenerator();
console.log(gen.next()); // { value: 1, done: false }
console.log(gen.next()); // { value: 2, done: false }
console.log(gen.next()); // { value: 3, done: false }
console.log(gen.next()); // { value: undefined, done: true }
生成器返回一个迭代器,调用next()
方法会执行代码直到遇到下一个yield
语句。
使用Generator实现异步流程控制
Generator可以用来处理异步操作,但通常需要一个运行器函数:
function fetchData(url) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (Math.random() > 0.3) {
resolve(`来自${url}的数据`);
} else {
reject(`获取${url}失败`);
}
}, 1000);
});
}
function* fetchSequence() {
try {
const user = yield fetchData('/api/user');
console.log(user);
const posts = yield fetchData('/api/posts');
console.log(posts);
const comments = yield fetchData('/api/comments');
console.log(comments);
return '所有数据获取完成';
} catch (error) {
console.error('出错了:', error);
return '数据获取过程出错';
}
}
// 手动运行生成器
function runGenerator(generatorFn) {
const generator = generatorFn();
function handle(result) {
if (result.done) return Promise.resolve(result.value);
return Promise.resolve(result.value)
.then(res => handle(generator.next(res)))
.catch(err => handle(generator.throw(err)));
}
return handle(generator.next());
}
runGenerator(fetchSequence)
.then(result => console.log(result))
.catch(err => console.error(err));
4、async/await语法
尽管Promise已经比回调函数有了很大改进,但ES2017引入的async/await语法进一步简化了异步编程,使异步代码看起来更像同步代码。
async/await基础
-
async:声明一个异步函数,它会返回一个Promise
-
await:暂停异步函数的执行,等待Promise解决
实现原理
(1)生成器与迭代器
async/await 的核心原理是利用生成器函数(Generator)的暂停和恢复能力:
function* genFunc() {
yield 1;
yield 2;
}
生成器可以通过 yield
暂停执行,并在之后通过 next()
恢复执行。
(2)Promise 结合
async/await 将 Generator 与 Promise 结合:
async
标记的函数总是返回 Promiseawait
操作会暂停函数执行,等待 Promise 完成
(3)自动执行器
关键环节是一个自动执行器,负责:
- 执行生成器函数
- 当遇到
yield
时暂停 - 等待 Promise 解决
- 将结果传回生成器并恢复执行
简化版实现
一个简化的 async/await 实现可以是:
function asyncToGenerator(generatorFunc) {
return function() {
const gen = generatorFunc.apply(this, arguments);
return new Promise((resolve, reject) => {
function step(key, arg) {
let result;
try {
result = gen[key](arg);
} catch (error) {
return reject(error);
}
const { value, done } = result;
if (done) {
return resolve(value);
} else {
return Promise.resolve(value).then(
val => step("next", val),
err => step("throw", err)
);
}
}
step("next");
});
};
}
Babel 转译示例
以下是 Babel 如何将 async/await 转译为 ES5 代码(简化版):
// 原始 async 函数
async function foo() {
const result = await someAsyncFunc();
return result;
}
// 转译后
function foo() {
return _asyncToGenerator(function* () {
const result = yield someAsyncFunc();
return result;
});
}
工作流程
- 当调用 async 函数时,自动创建一个 Promise 对象
- 函数体内代码正常执行,直到遇到 await 表达式
- await 表达式会暂停当前函数执行
- await 后的表达式会被转换成 Promise(如果不是已经是 Promise)
- 当该 Promise 完成时,恢复函数执行并返回 Promise 的结果
- 如果 Promise 被拒绝,await 表达式会抛出错误
// 使用async/await重写前面的例子
async function getUserInfo(userId) {
try {
const user = await fetchUserData(userId);
console.log("用户数据:", user);
const posts = await fetchUserPosts(user.id);
console.log("用户文章:", posts);
const comments = await fetchPostComments(posts[0].id);
console.log("文章评论:", comments);
const author = await fetchCommentAuthor(comments[0].authorId);
console.log("评论作者:", author);
return author;
} catch (error) {
console.error("发生错误:", error);
}
}
// 调用异步函数
getUserInfo(123).then(result => console.log("最终结果:", result));
相比Promise链,async/await的优势:
-
代码结构清晰,接近同步写法
-
便于使用条件语句和循环
-
易于进行错误处理
-
调试更简单
并行执行
虽然await会暂停函数执行,但有时我们需要并行执行多个异步操作:
async function fetchAllData() {
// 错误示范:串行执行,效率低
const users = await fetchUsers();
const posts = await fetchPosts();
const comments = await fetchComments();
// 正确示范:并行执行
const [users, posts, comments] = await Promise.all([
fetchUsers(),
fetchPosts(),
fetchComments()
]);
return { users, posts, comments };
}
错误处理
async函数中可以使用try/catch来捕获错误,也能捕获await的Promise拒绝:
async function fetchWithErrorHandling() {
try {
const response = await fetch('https://api.example.com/data');
if (!response.ok) {
throw new Error(`HTTP错误: ${response.status}`);
}
const data = await response.json();
return data;
} catch (error) {
console.error("获取数据失败:", error);
// 可以返回默认值
return { error: true, message: error.message };
}
}
Generator vs async/await
在ES2017引入async/await之前,Generator曾经是实现异步控制流的重要工具。现在,async/await基本上取代了Generator在异步编程中的角色,因为:
-
async/await是基于Generator和Promise的语法糖,更易于使用
-
async函数无需运行器,浏览器原生支持
-
错误处理更加直观
然而,Generator在某些场景(如惰性计算、状态机实现)中仍然非常有用。
5、事件循环机制
要真正理解JavaScript的异步编程,必须了解底层的事件循环机制。JavaScript是单线程的,依靠事件循环来处理异步操作。
事件循环的关键组件
事件循环机制涉及以下几个关键组件:
-
执行栈(Call Stack):管理函数调用的栈结构,遵循"后进先出"原则
-
宏任务队列(Macrotask Queue):存放宏任务,如setTimeout、setInterval、I/O等
-
微任务队列(Microtask Queue):存放微任务,如Promise回调、MutationObserver等
-
事件循环(Event Loop):持续检查执行栈和任务队列的循环过程
宏任务与微任务
宏任务(Macrotask)包括:
-
script(整体代码)
-
setTimeout/setInterval
-
setImmediate(Node.js环境)
-
I/O操作
-
UI渲染(浏览器)
-
requestAnimationFrame(浏览器)
微任务(Microtask)包括:
-
Promise.then/catch/finally
-
MutationObserver
-
process.nextTick(Node.js环境)
-
queueMicrotask()
事件循环的基本流程
(1) 开始:执行第一个宏任务,即全局代码(script)
(2) 同步代码执行:
-
所有同步代码进入执行栈按顺序执行
-
如遇异步API,其回调函数被分发到对应的任务队列中
(3) 执行栈清空:
- 同步代码执行完毕,执行栈清空
(4) 处理微任务:
-
检查微任务队列,有微任务则依次执行所有微任务
-
执行过程中产生的新微任务也会在当前循环中执行
(5) UI渲染(仅浏览器环境):
- 如有必要,进行页面渲染更新
(6) 处理宏任务:
-
从宏任务队列取出一个任务执行
-
执行完后,返回步骤3,检查微任务队列
(7) 循环往复:
- 事件循环无限继续,直到所有任务队列清空
事件循环流程图:
实际例子解析
console.log('1. 开始'); // 同步代码
setTimeout(() => {
console.log('2. 第一个宏任务');
Promise.resolve().then(() => {
console.log('3. 宏任务中的微任务');
});
}, 0);
Promise.resolve().then(() => {
console.log('4. 第一个微任务');
setTimeout(() => {
console.log('5. 微任务中的宏任务');
}, 0);
});
console.log('6. 结束'); // 同步代码
// 输出顺序: 1 -> 6 -> 4 -> 2 -> 3 -> 5
(1) 第一个宏任务(script全局代码)
-
执行同步代码,打印"1. 开始"
-
遇到setTimeout,其回调被添加到宏任务队列
-
遇到Promise.then,其回调被添加到微任务队列
-
执行同步代码,打印"6. 结束"
-
同步代码执行完毕,执行栈清空
(2) 检查微任务队列
-
执行微任务,打印"4. 第一个微任务"
-
遇到setTimeout,其回调被添加到宏任务队列
-
微任务队列清空
(3) 进行UI渲染(如需)
(4) 取出下一个宏任务
-
执行第一个setTimeout的回调,打印"2. 第一个宏任务"
-
遇到Promise.then,其回调被添加到微任务队列
(5) 再次检查微任务队列
-
执行微任务,打印"3. 宏任务中的微任务"
-
微任务队列清空
(6) 进行UI渲染(如需)
(7) 取出下一个宏任务
- 执行第二个setTimeout的回调,打印"5. 微任务中的宏任务"
关于async/await在事件循环中的位置
前面讲到async/await 就是生成器和Promise的语法糖,它的工作流程中讲到,await 表达式会暂停当前函数执行,await 后的表达式会被转换成 Promise(如果不是已经是 Promise),所以:
- 当函数遇到 await 时,会将后续代码作为微任务放入事件循环
- 这就是为什么 await 之后的代码总是在当前同步代码执行完毕后执行
6、异步编程最佳实践
使用Promise而非回调
所有新代码应该优先使用Promise API而非传统回调:
// 不推荐
function fetchData(callback) {
setTimeout(() => {
callback(null, { data: 'success' });
}, 1000);
}
// 推荐
function fetchData() {
return new Promise((resolve) => {
setTimeout(() => {
resolve({ data: 'success' });
}, 1000);
});
}
优先使用async/await
对于大多数异步操作,使用async/await可以使代码更清晰:
// Promise链
function getUserData(userId) {
return fetchUser(userId)
.then(user => {
return fetchPosts(user.id)
.then(posts => {
user.posts = posts;
return user;
});
});
}
// 使用async/await
async function getUserData(userId) {
const user = await fetchUser(userId);
user.posts = await fetchPosts(user.id);
return user;
}
正确处理错误
异步代码中的错误处理尤为重要:
// Promise错误处理
fetchData()
.then(data => processData(data))
.then(result => displayResult(result))
.catch(error => {
console.error('发生错误:', error);
showErrorMessage(error);
});
// async/await错误处理
async function handleData() {
try {
const data = await fetchData();
const result = await processData(data);
displayResult(result);
} catch (error) {
console.error('发生错误:', error);
showErrorMessage(error);
}
}
避免嵌套async函数
当不需要等待内部异步操作时,避免嵌套async函数:
// 不好的实践
async function processItems(items) {
const results = [];
for (const item of items) {
// 没必要使用async函数
results.push(await (async () => {
const data = await fetchData(item.id);
return processData(data);
})());
}
return results;
}
// 更好的实践
async function processItems(items) {
const results = [];
for (const item of items) {
const data = await fetchData(item.id);
results.push(processData(data));
}
return results;
}
合理使用Promise并行执行
当多个异步操作相互独立时,应该并行执行它们:
// 低效方式:串行执行
async function loadData() {
const users = await fetchUsers();
const products = await fetchProducts();
const categories = await fetchCategories();
return { users, products, categories };
}
// 高效方式:并行执行
async function loadData() {
const [users, products, categories] = await Promise.all([
fetchUsers(),
fetchProducts(),
fetchCategories()
]);
return { users, products, categories };
}
避免不必要的async/await
不是所有返回Promise的函数都需要async关键字:
// 不必要的async
async function getData() {
return fetch('/api/data').then(r => r.json());
}
// 简化版本
function getData() {
return fetch('/api/data').then(r => r.json());
}
使用Promise工具方法
利用Promise提供的静态方法简化常见任务:
// 并行请求并使用所有结果
Promise.all([fetchUsers(), fetchPosts(), fetchComments()])
.then(([users, posts, comments]) => {
// 处理所有数据
});
// 超时处理
function fetchWithTimeout(url, timeout = 5000) {
return Promise.race([
fetch(url),
new Promise((_, reject) => {
setTimeout(() => reject(new Error('请求超时')), timeout);
})
]);
}
// 任一请求成功即可
function fetchFromMultipleSources(urls) {
return Promise.any(urls.map(url => fetch(url)));
}
编写可测试的异步代码
良好的异步代码应该易于测试:
// 可测试的异步函数
async function processUserData(userId) {
const user = await fetchUser(userId);
if (!user) {
throw new Error('用户不存在');
}
user.lastActive = new Date();
return saveUser(user);
}
// 测试代码
test('processUserData成功处理用户', async () => {
// 使用mock替换真实API
fetchUser = jest.fn().mockResolvedValue({ id: 1, name: '张三' });
saveUser = jest.fn().mockResolvedValue({ success: true });
const result = await processUserData(1);
expect(result.success).toBe(true);
expect(saveUser).toHaveBeenCalledWith(expect.objectContaining({
id: 1,
lastActive: expect.any(Date)
}));
});
总结
JavaScript异步编程经历了从回调函数、Promise、Generator到async/await的演进。这些技术的发展使得异步代码越来越接近同步代码的直观性和可维护性,同时保留了非阻塞执行的优势。
理解事件循环机制是掌握JavaScript异步编程的关键,它解释了不同类型任务的执行顺序。在实际开发中,合理选择异步编程技术、遵循最佳实践,可以帮助我们编写出高效、可靠和易于维护的异步代码。