MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

JavaScript异步编程的最佳实践与陷阱

2022-05-027.1k 阅读

JavaScript异步编程的基本概念

为什么需要异步编程

JavaScript是单线程语言,这意味着它在同一时间只能执行一个任务。在浏览器环境中,这一特性确保了DOM操作等不会出现冲突。然而,如果有一个长时间运行的任务(如网络请求、读取大文件),同步执行会阻塞主线程,导致界面卡顿,用户体验变差。而异步编程允许JavaScript在执行这类耗时任务时,不会阻塞主线程,让其他代码得以继续执行。

例如,假设我们有一个简单的网页,上面有一个按钮,点击按钮后需要发起一个网络请求获取数据并更新页面。如果使用同步方式,在请求等待响应的过程中,整个页面会处于无响应状态,按钮无法再次点击,滚动条也不能滑动。而异步编程则可以避免这种情况,让页面在请求过程中依然保持响应性。

异步任务的类型

  1. 宏任务(Macrotask):包括 script(整体代码)、setTimeoutsetIntervalsetImmediate(Node.js 环境)、I/OUI rendering(浏览器环境)等。宏任务会在调用栈清空后,被放入宏任务队列,按照顺序依次执行。例如:
console.log('start');
setTimeout(() => {
    console.log('setTimeout');
}, 0);
console.log('end');
// 输出:start end setTimeout

这里,setTimeout 的回调函数会在 script 代码(宏任务)执行完毕,调用栈清空后才被执行。

  1. 微任务(Microtask):主要有 Promise.thenprocess.nextTick(Node.js 环境)、MutationObserver(浏览器环境)等。微任务会在当前宏任务执行完,调用栈清空后,优先于宏任务队列中的任务执行。例如:
console.log('start');
Promise.resolve().then(() => {
    console.log('Promise.then');
});
console.log('end');
// 输出:start end Promise.then

Promise.then 的回调函数在 script 宏任务执行完毕后,立即执行,而不是等到下一个宏任务。

异步编程的实现方式

回调函数(Callback)

  1. 基本原理:回调函数是异步编程中最基本的方式。当一个异步操作完成时,会调用传入的回调函数,并将结果作为参数传递给它。例如,使用 fs 模块读取文件(Node.js 环境):
const fs = require('fs');
fs.readFile('example.txt', 'utf8', (err, data) => {
    if (err) {
        console.error(err);
    } else {
        console.log(data);
    }
});

这里,fs.readFile 是一个异步操作,它接受文件名、编码格式以及一个回调函数作为参数。当文件读取完成后,会调用回调函数,将可能出现的错误 err 和读取到的数据 data 传递进去。

  1. 回调地狱(Callback Hell):随着异步操作的嵌套增加,代码会变得难以阅读和维护,形成所谓的“回调地狱”。例如:
fs.readFile('file1.txt', 'utf8', (err1, data1) => {
    if (err1) {
        console.error(err1);
    } else {
        fs.readFile('file2.txt', 'utf8', (err2, data2) => {
            if (err2) {
                console.error(err2);
            } else {
                fs.readFile('file3.txt', 'utf8', (err3, data3) => {
                    if (err3) {
                        console.error(err3);
                    } else {
                        console.log(data1, data2, data3);
                    }
                });
            }
        });
    }
});

这种层层嵌套的代码,增加了调试和修改的难度,违背了代码的可读性和可维护性原则。

Promise

  1. 基本原理:Promise 是一个代表异步操作最终完成(或失败)及其结果值的对象。它有三种状态:pending(进行中)、fulfilled(已成功)和 rejected(已失败)。一旦状态改变,就不会再变。例如:
const promise = new Promise((resolve, reject) => {
    setTimeout(() => {
        resolve('Success');
    }, 1000);
});
promise.then(value => {
    console.log(value); // 输出:Success
}).catch(error => {
    console.error(error);
});

这里创建了一个 Promise,在 1 秒后调用 resolve,表示操作成功,并传递结果值。then 方法用于处理成功的情况,catch 方法用于捕获失败的情况。

  1. 链式调用:Promise 可以通过链式调用解决回调地狱的问题。例如:
function readFilePromise(filePath, encoding) {
    return new Promise((resolve, reject) => {
        fs.readFile(filePath, encoding, (err, data) => {
            if (err) {
                reject(err);
            } else {
                resolve(data);
            }
        });
    });
}
readFilePromise('file1.txt', 'utf8')
   .then(data1 => {
        return readFilePromise('file2.txt', 'utf8');
    })
   .then(data2 => {
        return readFilePromise('file3.txt', 'utf8');
    })
   .then(data3 => {
        console.log(data1, data2, data3);
    })
   .catch(err => {
        console.error(err);
    });

通过链式调用,代码变得更加清晰,每个异步操作都以一种线性的方式呈现,易于理解和维护。

  1. Promise 组合:Promise 提供了一些静态方法来组合多个 Promise,如 Promise.allPromise.race
    • Promise.all:接受一个 Promise 数组作为参数,只有当所有的 Promise 都成功时,它才会成功,并返回一个包含所有成功结果的数组;只要有一个 Promise 失败,它就会失败,并返回第一个失败的原因。例如:
const promise1 = new Promise((resolve) => {
    setTimeout(() => {
        resolve('Promise1');
    }, 1000);
});
const promise2 = new Promise((resolve) => {
    setTimeout(() => {
        resolve('Promise2');
    }, 2000);
});
Promise.all([promise1, promise2]).then(values => {
    console.log(values); // 输出:['Promise1', 'Promise2']
}).catch(error => {
    console.error(error);
});
- **Promise.race**:同样接受一个 Promise 数组作为参数,只要数组中的任何一个 Promise 成功或失败,它就会以相同的状态和结果值返回。例如:
const promise3 = new Promise((resolve) => {
    setTimeout(() => {
        resolve('Promise3');
    }, 3000);
});
const promise4 = new Promise((resolve) => {
    setTimeout(() => {
        resolve('Promise4');
    }, 1500);
});
Promise.race([promise3, promise4]).then(value => {
    console.log(value); // 输出:Promise4
}).catch(error => {
    console.error(error);
});

async/await

  1. 基本原理async/await 是建立在 Promise 之上的语法糖,使得异步代码看起来更像同步代码。async 函数总是返回一个 Promise。await 只能在 async 函数内部使用,它会暂停 async 函数的执行,等待 Promise 解决(resolved)或拒绝(rejected),并返回解决的值或抛出拒绝的原因。例如:
async function readFiles() {
    try {
        const data1 = await readFilePromise('file1.txt', 'utf8');
        const data2 = await readFilePromise('file2.txt', 'utf8');
        const data3 = await readFilePromise('file3.txt', 'utf8');
        console.log(data1, data2, data3);
    } catch (err) {
        console.error(err);
    }
}
readFiles();

这里,使用 await 依次等待每个文件读取操作完成,代码看起来如同同步执行,大大提高了可读性。

  1. 错误处理async/await 中的错误处理可以通过 try/catch 块来实现,比 Promise 的 catch 更直观。例如,如果在 await 某个 Promise 时出现错误,try/catch 块可以捕获并处理这个错误:
async function asyncErrorHandling() {
    try {
        const result = await new Promise((_, reject) => {
            setTimeout(() => {
                reject('Error occurred');
            }, 1000);
        });
        console.log(result);
    } catch (error) {
        console.error(error); // 输出:Error occurred
    }
}
asyncErrorHandling();

异步编程的最佳实践

合理使用异步操作

  1. 避免不必要的异步:虽然异步编程能提高程序的响应性,但并非所有任务都需要异步。对于一些非常短暂的计算任务,同步执行可能更加高效。例如,简单的数学运算:
function addNumbers(a, b) {
    return a + b;
}
const result = addNumbers(2, 3);
console.log(result); // 输出:5

这里直接同步计算并返回结果,比使用异步操作更合适。

  1. 区分宏任务和微任务:了解宏任务和微任务的执行顺序,合理安排任务。如果需要在当前宏任务结束后立即执行一些重要的逻辑,可以使用微任务。例如,在 Vue.js 中,nextTick 方法就是利用微任务来确保 DOM 更新后执行回调函数。
<!DOCTYPE html>
<html>
<head>
    <script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
</head>
<body>
    <div id="app">
        <p>{{ message }}</p>
    </div>
    <script>
        const app = new Vue({
            el: '#app',
            data: {
                message: 'Initial message'
            },
            mounted() {
                this.message = 'New message';
                this.$nextTick(() => {
                    console.log('DOM has been updated');
                });
            }
        });
    </script>
</body>
</html>

这里 $nextTick 回调函数中的代码会在 DOM 更新(宏任务)完成后,通过微任务立即执行。

Promise 的最佳实践

  1. 始终处理拒绝(rejected)状态:在使用 Promise 时,一定要通过 catch 方法或 try/catch 块(在 async/await 中)来处理可能出现的错误。否则,未处理的 Promise 拒绝会导致程序出错,并且很难调试。例如:
const promise = new Promise((_, reject) => {
    setTimeout(() => {
        reject('Something went wrong');
    }, 1000);
});
// 错误处理方式 1:使用 then 和 catch
promise.then(value => {
    console.log(value);
}).catch(error => {
    console.error(error);
});
// 错误处理方式 2:使用 async/await 和 try/catch
async function handlePromise() {
    try {
        const result = await promise;
        console.log(result);
    } catch (error) {
        console.error(error);
    }
}
handlePromise();
  1. 避免创建未使用的 Promise:有时候,我们可能会意外地创建一个 Promise,但没有对其进行任何处理。这不仅浪费资源,还可能导致潜在的错误。例如:
// 错误示例:未使用的 Promise
new Promise((resolve) => {
    setTimeout(() => {
        resolve('Result');
    }, 1000);
});
// 正确示例:使用 Promise
const usefulPromise = new Promise((resolve) => {
    setTimeout(() => {
        resolve('Result');
    }, 1000);
});
usefulPromise.then(value => {
    console.log(value);
});

async/await 的最佳实践

  1. 保持 async 函数简洁async 函数应该尽量保持单一职责,避免在一个函数中包含过多的异步操作。如果一个 async 函数变得过于复杂,可以将其中的部分逻辑拆分成多个更小的 async 函数。例如:
async function complexTask() {
    const step1Result = await step1();
    const step2Result = await step2(step1Result);
    const step3Result = await step3(step2Result);
    return step3Result;
}
async function step1() {
    // 执行第一步操作
    return 'Step 1 result';
}
async function step2(input) {
    // 执行第二步操作
    return input +'-> Step 2 result';
}
async function step3(input) {
    // 执行第三步操作
    return input +'-> Step 3 result';
}

这样每个 async 函数的职责清晰,便于维护和调试。

  1. 注意错误边界:在使用 async/await 时,要注意错误边界。如果一个 async 函数没有捕获错误,错误会向上抛出。例如:
async function innerFunction() {
    throw new Error('Inner error');
}
async function outerFunction() {
    try {
        await innerFunction();
    } catch (error) {
        console.error('Caught in outer function:', error);
    }
}
outerFunction();

这里 outerFunction 通过 try/catch 捕获了 innerFunction 抛出的错误,避免错误继续向上传播导致程序崩溃。

异步编程的陷阱

回调函数的陷阱

  1. 回调函数中的 this 指向问题:在回调函数中,this 的指向可能会出乎预料。例如:
function MyClass() {
    this.value = 42;
    setTimeout(function() {
        console.log(this.value); // 输出:undefined
    }, 1000);
}
new MyClass();

这里,在 setTimeout 的回调函数中,this 指向的是全局对象(在浏览器中是 window,在 Node.js 中是 global),而不是 MyClass 的实例。可以通过以下几种方法解决: - 使用 bind 方法

function MyClass() {
    this.value = 42;
    setTimeout(function() {
        console.log(this.value);
    }.bind(this), 1000);
}
new MyClass(); // 输出:42
- **使用箭头函数**:
function MyClass() {
    this.value = 42;
    setTimeout(() => {
        console.log(this.value);
    }, 1000);
}
new MyClass(); // 输出:42

箭头函数没有自己的 this,它会继承外层作用域的 this

  1. 回调函数的嵌套过深:如前文提到的回调地狱,嵌套过深的回调函数会使代码难以维护和调试。应该尽量使用 Promise 或 async/await 来避免这种情况。

Promise 的陷阱

  1. 未处理的拒绝(rejected)状态:如果忘记处理 Promise 的拒绝状态,会导致错误在运行时才被发现,并且很难追踪错误发生的位置。在现代浏览器和 Node.js 环境中,未处理的 Promise 拒绝会在控制台打印警告信息,但最好还是在代码中主动处理拒绝状态。例如:
const badPromise = new Promise((_, reject) => {
    reject('Uncaught error');
});
// 错误:未处理拒绝状态
// 正确:添加 catch 处理
badPromise.catch(error => {
    console.error('Caught error:', error);
});
  1. Promise 链式调用中的错误传递:在 Promise 链式调用中,如果某个 then 方法中抛出错误,错误会被传递到下一个 catch 方法。但如果在 then 方法中返回了一个新的 Promise,并且这个新 Promise 被拒绝,错误处理可能会变得复杂。例如:
Promise.resolve()
   .then(() => {
        return new Promise((_, reject) => {
            reject('Inner error');
        });
    })
   .then(() => {
        console.log('This will not be executed');
    })
   .catch(error => {
        console.error('Caught error:', error); // 输出:Caught error: Inner error
    });

这里,第一个 then 方法返回的新 Promise 被拒绝,错误被传递到了最后的 catch 方法。

async/await 的陷阱

  1. 错误处理不完整:虽然 async/await 使得错误处理更直观,但如果忘记在 async 函数中使用 try/catch 块,错误会向上抛出,可能导致程序崩溃。例如:
async function badAsyncFunction() {
    const result = await new Promise((_, reject) => {
        reject('Error in async function');
    });
    console.log(result); // 这行代码不会执行
}
// 错误:未捕获错误
// 正确:添加 try/catch 块
async function goodAsyncFunction() {
    try {
        const result = await new Promise((_, reject) => {
            reject('Error in async function');
        });
        console.log(result);
    } catch (error) {
        console.error('Caught error:', error);
    }
}
  1. 阻塞事件循环:虽然 async/await 让异步代码看起来像同步代码,但如果在 async 函数中执行了长时间的同步操作,仍然会阻塞事件循环。例如:
async function longSyncTask() {
    let sum = 0;
    for (let i = 0; i < 1000000000; i++) {
        sum += i;
    }
    return sum;
}
async function main() {
    console.log('Start');
    const result = await longSyncTask();
    console.log('Result:', result);
    console.log('End');
}
main();

在这个例子中,longSyncTask 中的循环会阻塞事件循环,导致其他代码无法执行,直到循环结束。如果需要执行长时间的计算任务,可以考虑使用 Web Workers(在浏览器环境)或将计算任务拆分成更小的部分,通过异步调度来执行。

通过了解这些最佳实践和陷阱,可以帮助开发者在 JavaScript 异步编程中写出更健壮、高效且易于维护的代码。在实际开发中,根据具体的业务需求和场景,合理选择异步编程的方式,并时刻注意避免各种陷阱,是编写优质代码的关键。同时,不断地实践和总结经验,也能更好地掌握异步编程这一重要的 JavaScript 特性。