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

JavaScript异步编程的常见模式与技巧

2023-03-022.2k 阅读

异步编程基础

在 JavaScript 中,异步编程是处理非阻塞 I/O 操作、网络请求、定时器等场景的关键技术。JavaScript 是单线程语言,这意味着它在同一时间只能执行一个任务。然而,许多操作,如网络请求或读取文件,可能需要花费很长时间才能完成。如果这些操作是同步的,它们会阻塞主线程,导致用户界面冻结,直到操作完成。为了解决这个问题,JavaScript 提供了异步编程机制。

回调函数

回调函数是 JavaScript 中最基本的异步编程模式。当一个异步操作完成时,它会调用一个作为参数传递给它的函数,这个函数就是回调函数。

// 模拟一个异步操作,例如读取文件
function readFileAsync(filePath, callback) {
    setTimeout(() => {
        const data = '模拟文件内容';
        callback(null, data);
    }, 1000);
}

readFileAsync('example.txt', (error, data) => {
    if (error) {
        console.error('读取文件出错:', error);
    } else {
        console.log('文件内容:', data);
    }
});

在这个例子中,readFileAsync 函数模拟了一个异步读取文件的操作。它接受一个文件路径和一个回调函数作为参数。setTimeout 模拟了异步操作的延迟,在延迟结束后,它调用回调函数,并传递可能的错误和数据。

然而,回调函数在处理多个异步操作时可能会导致回调地狱(Callback Hell),即回调函数嵌套过深,代码可读性和维护性变差。

// 回调地狱示例
function step1(callback) {
    setTimeout(() => {
        console.log('步骤1完成');
        callback();
    }, 1000);
}

function step2(callback) {
    setTimeout(() => {
        console.log('步骤2完成');
        callback();
    }, 1000);
}

function step3(callback) {
    setTimeout(() => {
        console.log('步骤3完成');
        callback();
    }, 1000);
}

step1(() => {
    step2(() => {
        step3(() => {
            console.log('所有步骤完成');
        });
    });
});

Promise

Promise 是一种更优雅的处理异步操作的方式。它代表一个异步操作的最终完成(或失败)及其结果值。

// 使用 Promise 模拟读取文件
function readFileAsyncWithPromise(filePath) {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            const success = true;
            if (success) {
                const data = '模拟文件内容';
                resolve(data);
            } else {
                const error = new Error('读取文件出错');
                reject(error);
            }
        }, 1000);
    });
}

readFileAsyncWithPromise('example.txt')
   .then(data => {
        console.log('文件内容:', data);
    })
   .catch(error => {
        console.log('读取文件出错:', error);
    });

Promise 有三种状态:pending(进行中)、fulfilled(已成功)和 rejected(已失败)。当 Promise 被 resolve 时,状态变为 fulfilled,并调用 then 方法中的成功回调;当 Promise 被 reject 时,状态变为 rejected,并调用 catch 方法中的错误回调。

通过链式调用 then 方法,可以处理多个异步操作,避免了回调地狱。

function step1() {
    return new Promise((resolve) => {
        setTimeout(() => {
            console.log('步骤1完成');
            resolve();
        }, 1000);
    });
}

function step2() {
    return new Promise((resolve) => {
        setTimeout(() => {
            console.log('步骤2完成');
            resolve();
        }, 1000);
    });
}

function step3() {
    return new Promise((resolve) => {
        setTimeout(() => {
            console.log('步骤3完成');
            resolve();
        }, 1000);
    });
}

step1()
   .then(() => step2())
   .then(() => step3())
   .then(() => {
        console.log('所有步骤完成');
    });

async/await

async/await 是基于 Promise 的一种更简洁的异步编程语法糖。async 函数总是返回一个 Promise,而 await 只能在 async 函数内部使用,它暂停 async 函数的执行,直到 Promise 被解决(resolved 或 rejected)。

// 使用 async/await 模拟读取文件
async function readFileAsyncWithAsyncAwait(filePath) {
    try {
        const data = await new Promise((resolve) => {
            setTimeout(() => {
                const success = true;
                if (success) {
                    const data = '模拟文件内容';
                    resolve(data);
                }
            }, 1000);
        });
        console.log('文件内容:', data);
    } catch (error) {
        console.log('读取文件出错:', error);
    }
}

readFileAsyncWithAsyncAwait('example.txt');

在这个例子中,await 暂停了 readFileAsyncWithAsyncAwait 函数的执行,直到 Promise 被解决。try/catch 块用于捕获可能的错误,这种方式使得异步代码看起来更像同步代码,大大提高了代码的可读性。

同样,使用 async/await 处理多个异步操作也非常简洁。

async function runSteps() {
    await step1();
    await step2();
    await step3();
    console.log('所有步骤完成');
}

async function step1() {
    return new Promise((resolve) => {
        setTimeout(() => {
            console.log('步骤1完成');
            resolve();
        }, 1000);
    });
}

async function step2() {
    return new Promise((resolve) => {
        setTimeout(() => {
            console.log('步骤2完成');
            resolve();
        }, 1000);
    });
}

async function step3() {
    return new Promise((resolve) => {
        setTimeout(() => {
            console.log('步骤3完成');
            resolve();
        }, 1000);
    });
}

runSteps();

异步控制流模式

串行执行

串行执行意味着一个异步操作完成后再开始下一个异步操作。在使用 Promise 时,可以通过链式调用 then 方法来实现串行执行。

function task1() {
    return new Promise((resolve) => {
        setTimeout(() => {
            console.log('任务1完成');
            resolve();
        }, 1000);
    });
}

function task2() {
    return new Promise((resolve) => {
        setTimeout(() => {
            console.log('任务2完成');
            resolve();
        }, 1000);
    });
}

function task3() {
    return new Promise((resolve) => {
        setTimeout(() => {
            console.log('任务3完成');
            resolve();
        }, 1000);
    });
}

task1()
   .then(() => task2())
   .then(() => task3())
   .then(() => {
        console.log('所有任务串行完成');
    });

使用 async/await 时,代码更加直观。

async function runTasksSequentially() {
    await task1();
    await task2();
    await task3();
    console.log('所有任务串行完成');
}

async function task1() {
    return new Promise((resolve) => {
        setTimeout(() => {
            console.log('任务1完成');
            resolve();
        }, 1000);
    });
}

async function task2() {
    return new Promise((resolve) => {
        setTimeout(() => {
            console.log('任务2完成');
            resolve();
        }, 1000);
    });
}

async function task3() {
    return new Promise((resolve) => {
        setTimeout(() => {
            console.log('任务3完成');
            resolve();
        }, 1000);
    });
}

runTasksSequentially();

并行执行

并行执行允许多个异步操作同时进行,当所有操作都完成后再进行下一步。在 Promise 中,可以使用 Promise.all 方法来实现并行执行。

function task1() {
    return new Promise((resolve) => {
        setTimeout(() => {
            console.log('任务1完成');
            resolve('任务1结果');
        }, 1000);
    });
}

function task2() {
    return new Promise((resolve) => {
        setTimeout(() => {
            console.log('任务2完成');
            resolve('任务2结果');
        }, 2000);
    });
}

function task3() {
    return new Promise((resolve) => {
        setTimeout(() => {
            console.log('任务3完成');
            resolve('任务3结果');
        }, 1500);
    });
}

Promise.all([task1(), task2(), task3()])
   .then(results => {
        console.log('所有任务并行完成,结果:', results);
    })
   .catch(error => {
        console.log('并行任务出错:', error);
    });

Promise.all 接受一个 Promise 数组作为参数,返回一个新的 Promise。当所有传入的 Promise 都被解决(resolved)时,新的 Promise 被解决,并以数组形式返回所有 Promise 的结果。如果其中任何一个 Promise 被拒绝(rejected),新的 Promise 也会被拒绝。

使用 async/await 时,同样可以通过 Promise.all 实现并行执行。

async function runTasksInParallel() {
    try {
        const results = await Promise.all([task1(), task2(), task3()]);
        console.log('所有任务并行完成,结果:', results);
    } catch (error) {
        console.log('并行任务出错:', error);
    }
}

async function task1() {
    return new Promise((resolve) => {
        setTimeout(() => {
            console.log('任务1完成');
            resolve('任务1结果');
        }, 1000);
    });
}

async function task2() {
    return new Promise((resolve) => {
        setTimeout(() => {
            console.log('任务2完成');
            resolve('任务2结果');
        }, 2000);
    });
}

async function task3() {
    return new Promise((resolve) => {
        setTimeout(() => {
            console.log('任务3完成');
            resolve('任务3结果');
        }, 1500);
    });
}

runTasksInParallel();

限制并发数

在某些情况下,我们可能需要限制并行执行的异步操作数量,以避免资源耗尽。例如,在进行大量网络请求时,过多的并发请求可能会导致网络拥堵或服务器负载过高。

可以通过队列和 Promise 来实现限制并发数。

function asyncTask(taskId) {
    return new Promise((resolve) => {
        setTimeout(() => {
            console.log(`任务 ${taskId} 完成`);
            resolve(taskId);
        }, Math.floor(Math.random() * 2000));
    });
}

function runTasksWithLimitedConcurrency(tasks, maxConcurrency) {
    return new Promise((resolve, reject) => {
        let completedCount = 0;
        const results = [];
        const runningTasks = [];

        function startNextTask() {
            while (runningTasks.length < maxConcurrency && tasks.length > 0) {
                const task = tasks.shift();
                const promise = task();
                runningTasks.push(promise);
                promise
                   .then(result => {
                        results.push(result);
                        completedCount++;
                        runningTasks.splice(runningTasks.indexOf(promise), 1);
                        if (completedCount === tasks.length) {
                            resolve(results);
                        } else {
                            startNextTask();
                        }
                    })
                   .catch(error => {
                        reject(error);
                    });
            }
        }

        startNextTask();
    });
}

const tasks = Array.from({ length: 10 }, (_, i) => () => asyncTask(i + 1));
runTasksWithLimitedConcurrency(tasks, 3)
   .then(results => {
        console.log('所有任务完成,结果:', results);
    })
   .catch(error => {
        console.log('任务执行出错:', error);
    });

在这个例子中,runTasksWithLimitedConcurrency 函数接受一个任务数组和最大并发数作为参数。它通过维护一个正在运行的任务数组 runningTasks 和一个完成任务计数 completedCount 来控制并发。当有任务完成时,从 runningTasks 中移除该任务,并启动下一个任务,直到所有任务都完成。

处理异步错误

Promise 中的错误处理

在 Promise 中,可以通过 catch 方法捕获异步操作中的错误。

function asyncTask() {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            const success = false;
            if (success) {
                resolve('任务成功');
            } else {
                reject(new Error('任务失败'));
            }
        }, 1000);
    });
}

asyncTask()
   .then(result => {
        console.log('任务结果:', result);
    })
   .catch(error => {
        console.log('捕获到错误:', error);
    });

如果在 then 方法中抛出错误,也会被后续的 catch 方法捕获。

function asyncTask() {
    return new Promise((resolve) => {
        setTimeout(() => {
            resolve('任务成功');
        }, 1000);
    });
}

asyncTask()
   .then(result => {
        throw new Error('自定义错误');
        console.log('任务结果:', result);
    })
   .catch(error => {
        console.log('捕获到错误:', error);
    });

async/await 中的错误处理

async/await 中,可以使用 try/catch 块来捕获异步操作中的错误。

async function asyncFunction() {
    try {
        const result = await new Promise((resolve, reject) => {
            setTimeout(() => {
                const success = false;
                if (success) {
                    resolve('任务成功');
                } else {
                    reject(new Error('任务失败'));
                }
            }, 1000);
        });
        console.log('任务结果:', result);
    } catch (error) {
        console.log('捕获到错误:', error);
    }
}

asyncFunction();

如果不使用 try/catch 块,async 函数中抛出的错误会被返回的 Promise 捕获。

async function asyncFunction() {
    const result = await new Promise((resolve, reject) => {
        setTimeout(() => {
            const success = false;
            if (success) {
                resolve('任务成功');
            } else {
                reject(new Error('任务失败'));
            }
        }, 1000);
    });
    console.log('任务结果:', result);
    throw new Error('自定义错误');
}

asyncFunction()
   .catch(error => {
        console.log('捕获到错误:', error);
    });

异步编程的高级技巧

超时控制

在异步操作中,有时我们需要设置一个超时时间,以防止操作无限期等待。可以通过 Promise.racesetTimeout 来实现超时控制。

function asyncTask() {
    return new Promise((resolve) => {
        setTimeout(() => {
            resolve('任务成功');
        }, 2000);
    });
}

function withTimeout(promise, timeout) {
    return Promise.race([
        promise,
        new Promise((_, reject) => {
            setTimeout(() => {
                reject(new Error('操作超时'));
            }, timeout);
        })
    ]);
}

withTimeout(asyncTask(), 1000)
   .then(result => {
        console.log('任务结果:', result);
    })
   .catch(error => {
        console.log('捕获到错误:', error);
    });

在这个例子中,withTimeout 函数接受一个 Promise 和超时时间作为参数。它使用 Promise.race 同时启动异步任务和一个超时定时器。如果定时器先到期,Promise 会被拒绝并抛出超时错误。

重试机制

对于一些可能会失败的异步操作,我们可以实现重试机制,在操作失败时自动重试一定次数。

function asyncTask() {
    return new Promise((resolve, reject) => {
        const success = Math.random() < 0.5;
        if (success) {
            resolve('任务成功');
        } else {
            reject(new Error('任务失败'));
        }
    });
}

async function retryAsyncTask(task, maxRetries = 3, delay = 1000) {
    let retries = 0;
    while (retries < maxRetries) {
        try {
            return await task();
        } catch (error) {
            retries++;
            console.log(`重试 ${retries} 次,原因:`, error);
            await new Promise(resolve => setTimeout(resolve, delay));
        }
    }
    throw new Error('达到最大重试次数,任务失败');
}

retryAsyncTask(asyncTask)
   .then(result => {
        console.log('任务结果:', result);
    })
   .catch(error => {
        console.log('最终错误:', error);
    });

在这个例子中,retryAsyncTask 函数接受一个异步任务、最大重试次数和重试延迟作为参数。它通过 while 循环不断尝试执行任务,直到任务成功或达到最大重试次数。如果任务失败,会等待指定的延迟时间后再次重试。

取消异步操作

在某些情况下,我们可能需要在异步操作完成之前取消它。例如,在用户取消一个正在进行的网络请求时。虽然 JavaScript 本身没有内置的取消机制,但可以通过 AbortControllerAbortSignal 来实现。

function asyncTask(signal) {
    return new Promise((resolve, reject) => {
        const timeoutId = setTimeout(() => {
            if (signal.aborted) {
                reject(new Error('任务已取消'));
            } else {
                resolve('任务成功');
            }
        }, 5000);

        signal.addEventListener('abort', () => {
            clearTimeout(timeoutId);
            reject(new Error('任务已取消'));
        });
    });
}

const controller = new AbortController();
const signal = controller.signal;

asyncTask(signal)
   .then(result => {
        console.log('任务结果:', result);
    })
   .catch(error => {
        console.log('捕获到错误:', error);
    });

// 模拟用户取消操作
setTimeout(() => {
    controller.abort();
}, 3000);

在这个例子中,asyncTask 函数接受一个 AbortSignal 对象作为参数。通过监听 signalabort 事件,可以在任务取消时清理资源并拒绝 Promise。AbortController 用于发出取消信号,通过调用 controller.abort() 方法可以取消异步任务。

异步编程与性能优化

减少异步操作的开销

虽然异步编程解决了阻塞问题,但它也带来了一些开销,如函数调用、Promise 创建等。在性能敏感的场景中,应尽量减少不必要的异步操作。

例如,对于一些简单的计算任务,应优先使用同步方式执行,而不是将其包装成异步任务。

// 同步计算
function syncCalculate() {
    let result = 0;
    for (let i = 0; i < 1000000; i++) {
        result += i;
    }
    return result;
}

// 异步计算(不必要的)
function asyncCalculate() {
    return new Promise((resolve) => {
        let result = 0;
        for (let i = 0; i < 1000000; i++) {
            result += i;
        }
        resolve(result);
    });
}

// 直接调用同步函数
const syncResult = syncCalculate();
console.log('同步计算结果:', syncResult);

// 调用异步函数
asyncCalculate()
   .then(asyncResult => {
        console.log('异步计算结果:', asyncResult);
    });

在这个例子中,syncCalculate 函数以同步方式进行简单的累加计算,而 asyncCalculate 函数将同样的计算包装成异步操作。在这种情况下,同步计算的性能更好,因为它避免了 Promise 创建和异步调度的开销。

合理使用并行和串行

在选择并行或串行执行异步操作时,需要考虑任务的性质和资源的限制。

如果任务之间相互独立,且系统资源充足,并行执行可以显著提高效率。例如,同时从多个 API 获取数据。

function fetchData1() {
    return new Promise((resolve) => {
        setTimeout(() => {
            resolve('数据1');
        }, 1000);
    });
}

function fetchData2() {
    return new Promise((resolve) => {
        setTimeout(() => {
            resolve('数据2');
        }, 1500);
    });
}

Promise.all([fetchData1(), fetchData2()])
   .then(results => {
        console.log('所有数据获取完成:', results);
    });

然而,如果任务之间存在依赖关系,或者系统资源有限,串行执行可能更合适。例如,一个任务需要使用另一个任务的结果,或者过多的并行请求会导致网络拥堵。

function task1() {
    return new Promise((resolve) => {
        setTimeout(() => {
            console.log('任务1完成');
            resolve('任务1结果');
        }, 1000);
    });
}

function task2(result1) {
    return new Promise((resolve) => {
        setTimeout(() => {
            console.log('任务2完成,使用任务1结果:', result1);
            resolve('任务2结果');
        }, 1000);
    });
}

task1()
   .then(result1 => task2(result1))
   .then(result2 => {
        console.log('所有任务串行完成,最终结果:', result2);
    });

优化异步错误处理

异步错误处理也会对性能产生影响。尽量在靠近错误发生的地方处理错误,避免错误在 Promise 链中传递过多层次。

例如,在一个复杂的异步操作链中,如果某个中间步骤可能出错,可以在该步骤直接处理错误,而不是让错误一直传递到最后。

function step1() {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            const success = true;
            if (success) {
                resolve('步骤1结果');
            } else {
                reject(new Error('步骤1出错'));
            }
        }, 1000);
    });
}

function step2(result1) {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            try {
                // 这里可能依赖步骤1的结果进行操作
                const newResult = result1 +'处理后';
                resolve(newResult);
            } catch (error) {
                reject(new Error('步骤2出错,原因:'+ error.message));
            }
        }, 1000);
    });
}

step1()
   .then(result1 => step2(result1))
   .then(finalResult => {
        console.log('所有步骤完成,最终结果:', finalResult);
    })
   .catch(error => {
        console.log('捕获到错误:', error);
    });

在这个例子中,step2 函数在内部尝试处理可能的错误,而不是让错误传递到最后统一处理,这样可以减少错误处理的复杂性和性能开销。

通过合理运用这些异步编程的模式与技巧,可以使 JavaScript 应用在处理异步操作时更加高效、稳定和易于维护。无论是处理简单的异步任务还是复杂的异步控制流,选择合适的方法和技术对于提升应用性能和用户体验至关重要。