在JavaScript异步代码编常见的问题
在JavaScript的异步编程中,我们经常面临各种挑战。这包括处理竞态条件、避免回调地狱、处理错误,以及确保资源不泄漏。异步函数的不确定性和事件竞争也是常见问题。解决这些问题需要仔细考虑和正确的工具,以确保代码的可读性和可维护性。希望能在这篇文章中你能收获一些新内容,不断学习实践成长。
常见情景问题
传入异步executor
js
// ❌
new Promise(async (resolve, reject) => {});
// ✅
new Promise((resolve, reject) => {});
-
错误处理问题:如果异步函数内部抛出一个错误,它将不会被新构造的Promise reject,而是会被视为未捕获的异常。这是因为Promise构造函数在异步函数内没有适当的错误处理机制。这可能会导致错误的处理问题,因为在一般情况下,我们期望Promise能够reject并传递错误信息。
-
不必要的包装问题 :如果在构造函数内部使用
await
,那么将Promise包装在异步函数内可能是不必要的。await
本身已经会返回一个Promise,并且如果内部Promise没有额外的异步操作,包装它可能会导致多余的Promise嵌套。这增加了代码的复杂性,而不提供额外的好处。
为了解决这些问题,通常更好的做法是在异步函数内部创建一个Promise并正确处理错误,然后将这个Promise返回。这将确保错误能够正常地被捕获并传递,而不会引入不必要的Promise包装。
示例:
javascript
function someAsyncFunction() {
return new Promise(async (resolve, reject) => {
try {
// 异步操作
const result = await someOtherAsyncFunction();
resolve(result);
} catch (error) {
reject(error);
}
});
}
这种方式可以更好地处理异步操作和错误,并避免不必要的Promise包装。
循环中的await
js
// ❌
for (const url of urls) {
const response = await fetch(url);
}
// ✅
const responses = [];
for (const url of urls) {
const response = fetch(url);
responses.push(response);
}
await Promise.all(responses);
当对可迭代对象的每个元素执行操作并等待异步任务时,这通常表明程序没有充分利用 JavaScript 的事件驱动架构。通过并行执行任务,可以大大提高代码的效率。
当然这只是一般情况,如果对于异步的执行需要顺序上的控制还是可以使用这种方法。
executor
方法存在return
js
// ❌
new Promise((resolve, reject) => {
return result;
});
// ✅
new Promise((resolve, reject) => {
resolve(result);
});
在Promise构造函数中,你不能使用return
语句来返回一个值并期望它会影响Promise的状态。这是因为Promise构造函数的参数是一个executor函数,它在Promise对象被创建时会立即执行,而return
语句在这个上下文中并不会影响Promise的行为。你可以在executor函数内部使用resolve
和reject
函数来控制Promise的状态,而不是使用return
语句。
竞态条件 (Race Conditions
)
这是一个非常有意思的问题,先让我们看一个示例
js
// ❌
let totalPosts = 0;
async function getPosts(userId) {
const users = [{ id: 1, posts: 5 }, { id: 2, posts: 3 }];
await sleep(Math.random() * 1000);
return users.find((user) => user.id === userId).posts;
}
async function addPosts(userId) {
totalPosts += await getPosts(userId);
}
await Promise.all([addPosts(1), addPosts(2)]);
console.log('Post count:', totalPosts);
第一感觉结果应该是8,但是实际上这里打印的内容是3或者5. 问题在于 读取和更新 totalPosts
之间存在时间间隔 .这会导致竞态条件,以便在单独的函数调用中更新值时,更新不会反映在当前函数作用域中。因此,这两个函数都将它们的结果与 totalPosts
的初始值 0 相加。
为了避免这种争用条件,你应该确保在更新变量的同时读取变量。
ts
let totalPosts = 0;
const sleep = async (delay) => {
return new Promise((r) => {
setTimeout(r, delay);
});
};
await(async () => {
async function getPosts(userId) {
const users = [
{ id: 1, posts: 5 },
{ id: 2, posts: 3 },
];
await sleep(Math.random() * 1000);
return users.find((user) => user.id === userId).posts;
}
async function addPosts(userId) {
const post = await getPosts(userId);
totalPosts += post;
console.log(totalPosts);
}
await Promise.all([addPosts(1), addPosts(2)]);
})();
console.log("Post count:", totalPosts);
回调地狱
js
// ❌
async1((err, result1) => {
async2(result1, (err, result2) => {
async3(result2, (err, result3) => {
async4(result3, (err, result4) => {
console.log(result4);
});
});
});
});
// ✅
const result1 = await asyncPromise1();
const result2 = await asyncPromise2(result1);
const result3 = await asyncPromise3(result2);
const result4 = await asyncPromise4(result3);
console.log(result4);
这里展示了如何将回调地狱(回调嵌套)转化为使用Promise和async/await语法的清晰可读的代码。深层嵌套的回调结构会使代码难以理解和维护,而将其改写为Promise链和async/await语法可以显著提高代码的可读性和可维护性。
在第一个示例中,多个异步操作被嵌套在回调函数内部,这导致了代码的缩进深度和复杂性的增加。而在第二个示例中,使用async/await和Promise,每个异步操作都被清晰地表达,并且代码结构更加扁平,易于理解。
这种重构的好处包括:
-
可读性提高:使用async/await和Promise可以让代码更具可读性,更接近自然语言的表达方式,易于理解代码的执行流程。
-
错误处理:使用async/await使错误处理更加简单,你可以使用try-catch块来捕获和处理异步操作中的错误。
-
扁平结构:代码变得更加扁平,没有深层嵌套的回调,使代码更易维护。
-
可维护性:改写为Promise和async/await的代码更容易维护,因为你可以更容易地跟踪和调试问题。
总之,将回调地狱重构为Promise链和async/await语法是一种良好的实践,可以提高代码质量和可维护性。这使得异步代码更容易编写和理解,减少了潜在的错误和问题。
异步方法中 return await
js
// ❌
async () => {
return await getUser(userId);
};
// ✅
async () => {
return getUser(userId);
};
两种写法都可以工作,但第一种写法在async
函数中使用了await
,然后返回一个Promise。然而,它引入了不必要的Promise包装,因为async
函数本身会隐式地返回一个Promise。
从async
函数中返回的值会自动包装成一个Promise,所以在大多数情况下,直接返回异步操作的结果即可,而不需要额外的await
和Promise包装。
reject
非Error
结果
在创建一个Promise并拒绝(reject)它时,通常更好的做法是使用new Error
来创建一个Error对象,以便提供有关错误的更多信息,以及允许捕获和处理这个错误。因此,第二种写法是更推荐的。
❌ 不推荐的写法:
javascript
Promise.reject('An error occurred');
在这种写法中,你只传递了一个简单的字符串作为拒绝的理由。这可能会使错误处理和调试更加困难,因为它没有提供关于错误的详细信息。
✅ 推荐的写法:
javascript
Promise.reject(new Error('An error occurred'));
在这种写法中,你使用new Error
创建了一个Error对象,将有关错误的信息包装在其中。这样做可以提供更多的上下文和信息,使错误处理更容易,也使错误消息更具描述性。当你在代码中捕获这个错误时,可以访问错误对象的属性,如message
,以了解发生了什么错误。
总之,使用new Error
来创建Error对象并拒绝Promise是更好的做法,以提供更多的错误信息和更好的错误处理能力。
node runtime
下的异步编程问题
回调忽略错误处理
js
// ❌
function callback(err, data) {
console.log(data);
}
// ✅
function callback(err, data) {
if (err) {
console.log(err);
return;
}
console.log(data);
}
在 Node.js 中,通常将错误作为第一个参数传递给回调函数。忘记处理错误可能会导致应用程序行为异常。
在这种写法中,首先检查了err
参数,如果存在错误,会进行适当的处理,例如打印错误信息。然后,只有在没有错误时才会处理数据。这使得代码更具健壮性,能够更好地应对潜在的问题。
正确处理错误是编写可靠的代码的关键部分。在回调函数中,要确保检查并适当地处理错误,以防止错误被忽略,从而提高代码的稳定性和可维护性。
回调第一个参数不是error
js
// ❌
cb('An error!');
callback(result);
// ✅
cb(new Error('An error!'));
callback(null, result);
在调用回调函数时,通常更好的做法是遵循一种约定,使错误作为第一个参数传递,以便在调用方能够更容易地检查和处理错误。
在回调函数中,建议使用一种明确的约定,将错误作为第一个参数传递,以确保错误能够被正确处理和检查。这有助于减少混淆,提高代码的可读性。
调用api中存在异步替代方法的同步方法
js
// ❌
const file = fs.readFileSync(path);
// ✅
const file = await fs.readFile(path);
在Node.js中,通常不建议使用同步的文件读取方法(如fs.readFileSync
)来读取文件,因为它会阻塞Node.js的事件循环,可能导致性能问题,特别是在处理大量文件或需要高并发的情况下。而使用异步的文件读取方法(如fs.promises.readFile
)是更好的选择,因为它不会阻塞事件循环,允许Node.js处理多个请求而不被阻塞。
同样的,在commonJs规范中通过require 去导入其他module也会有相同问题。
await
同步方法或值
js
// ❌
function getValue() {
return someValue;
}
await getValue();
// ✅
async function getValue() {
return someValue;
}
await getValue();
在使用await
关键字的上下文中,只有异步函数(返回Promise的函数)才能够被等待, 虽然第一种写法并不会产生特定的错误(方法会同步resolve),但是确保你等待的函数是一个异步函数(返回Promise的函数),以便能够正确地使用await
来等待其完成。
promise
忽略catch
js
// ❌
myPromise()
.then(() => {});
// ✅
myPromise()
.then(() => {})
.catch(() => {});
在Promise链中,通常需要正确处理错误,以确保能够捕获和处理在Promise链中发生的任何拒绝(reject)情况。因此,第一种写法是不推荐的,因为它没有提供错误处理机制,而第二种写法是更好的做法,因为它包括了.catch()
用于捕获可能发生的错误。
特别是服务端场景对于Promise异常的处理是必要的,同时需要确定的是使用try catch的范围。
混合同步和异步代码
js
// ❌
if (getUserFromDB()) {}
// ✅ 👎
if (await getUserFromDB()) {}
// ✅ 👍
const user = await getUserFromDB();
if (user) {}
使用await
关键字等待异步操作的结果是一种良好的实践,可以确保在继续执行后续代码之前等待异步操作完成。然而,将await
直接用在条件语句中,如if
,可能会引发一些问题和混淆,因此建议将await
用在适当的地方以提高可读性。
使用await
关键字等待异步操作的结果是重要的,但在条件语句中使用await
应该小心谨慎,以避免混淆和提高可读性。将await
的结果分配给一个变量通常是更好的做法。
异步函数未使用 async
关键字
js
// ❌
function doSomething() {
return somePromise;
}
// ✅
async function doSomething() {
return somePromise;
}
在JavaScript中,一个函数可以返回一个Promise对象,但是通常,如果函数是异步的,最好将其标记为async
以明确表明其异步性质,并使其返回值自动包装在Promise中。这有助于代码的清晰性和可读性。
此外,标记函数为async
还有助于确保错误处理,因为async
函数内部可以使用try...catch
块来捕获和处理可能发生的异常,以及返回被拒绝的Promise。
总之,通常最好将异步函数标记为async
以提高代码的可读性,并确保明确表示函数的异步性质。这有助于降低混淆和错误的风险。
使用eslint统一规范和风格
这里推荐使用eslint-config-async
eslint-plugin-node
来完成异步代码编写的规范,同时在协作开发时能起到非常大的作用。
参考引用:[maximorlov]: maximorlov.com/linting-rul... "14 Linting Rules To Help You Write Asynchronous Code in JavaScript - Maxim Orlov"