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

JavaScript异步编程的最佳实践

2023-01-091.9k 阅读

JavaScript 异步编程基础

为什么需要异步编程

JavaScript 是一门单线程语言,这意味着它在同一时间只能执行一个任务。在浏览器环境中,这一特性确保了 DOM 操作的一致性,避免了多线程可能带来的竞争条件和死锁等问题。然而,单线程也有其局限性,例如,如果一个任务执行时间过长,就会阻塞主线程,导致页面失去响应,用户体验变差。

想象一下,在一个网页中发起一个网络请求来获取数据。网络请求通常需要一定的时间来完成,期间如果 JavaScript 主线程一直等待请求的返回,那么页面上的其他交互(如点击按钮、滚动页面等)都将无法响应。这就是异步编程在 JavaScript 中变得至关重要的原因。通过异步编程,我们可以在等待网络请求、文件读取等耗时操作完成的同时,让主线程继续处理其他任务,从而提高程序的响应性和用户体验。

回调函数

回调函数是 JavaScript 中实现异步编程最基本的方式。简单来说,回调函数就是作为参数传递给另一个函数的函数,这个被传递的函数会在主函数执行完成后被调用。

下面是一个简单的文件读取的例子,使用 Node.js 的 fs 模块(在浏览器环境中不存在,但原理类似):

const fs = require('fs');

fs.readFile('example.txt', 'utf8', function (err, data) {
    if (err) {
        console.error(err);
        return;
    }
    console.log(data);
});

在上述代码中,fs.readFile 是一个异步函数,它接受三个参数:要读取的文件名、编码格式以及一个回调函数。当文件读取操作完成后,无论成功与否,都会调用这个回调函数。如果读取过程中发生错误,err 参数会包含错误信息;如果成功,data 参数会包含文件的内容。

虽然回调函数简单直观,但当异步操作嵌套层次过多时,就会出现所谓的“回调地狱”。例如:

fs.readFile('file1.txt', 'utf8', function (err1, data1) {
    if (err1) {
        console.error(err1);
        return;
    }

    fs.readFile('file2.txt', 'utf8', function (err2, data2) {
        if (err2) {
            console.error(err2);
            return;
        }

        fs.readFile('file3.txt', 'utf8', function (err3, data3) {
            if (err3) {
                console.error(err3);
                return;
            }

            console.log(data1, data2, data3);
        });
    });
});

这种层层嵌套的代码不仅难以阅读和维护,而且容易出错。

事件监听

事件监听也是 JavaScript 实现异步编程的一种方式。在浏览器环境中,大量的操作都是基于事件驱动的,比如用户点击按钮、页面加载完成等。

我们可以通过 addEventListener 方法来为元素添加事件监听器。例如:

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
</head>

<body>
    <button id="myButton">点击我</button>
    <script>
        const button = document.getElementById('myButton');
        button.addEventListener('click', function () {
            console.log('按钮被点击了');
        });
    </script>
</body>

</html>

在上述代码中,我们为按钮添加了一个点击事件监听器。当用户点击按钮时,会触发这个监听器,执行回调函数中的代码。这种方式使得代码的逻辑更加清晰,每个事件处理函数都相对独立,避免了像回调地狱那样的嵌套问题。

在 Node.js 中,事件监听同样广泛应用。例如,net 模块用于创建 TCP 服务器,服务器实例会触发各种事件,如 connection 事件表示有新的客户端连接:

const net = require('net');

const server = net.createServer((socket) => {
    socket.write('欢迎连接到服务器!\n');
    socket.on('data', (data) => {
        console.log('收到客户端数据:', data.toString());
        socket.write('已收到你的数据!\n');
    });
    socket.on('end', () => {
        console.log('客户端断开连接');
    });
});

server.listen(8080, () => {
    console.log('服务器已启动,监听端口 8080');
});

在这个例子中,我们创建了一个 TCP 服务器。当有客户端连接时,会触发 connection 事件,我们在事件处理函数中对客户端进行操作。同时,通过监听 data 事件来处理客户端发送的数据,监听 end 事件来处理客户端断开连接的情况。

Promise

Promise 简介

Promise 是 JavaScript 异步编程的一个重要特性,它为解决回调地狱问题提供了一种更优雅的方式。Promise 代表一个异步操作的最终完成(或失败)及其结果值。

一个 Promise 有三种状态:

  1. Pending(进行中):初始状态,既没有被兑现(resolved),也没有被拒绝(rejected)。
  2. Fulfilled(已兑现):意味着操作成功完成,Promise 有一个相关的结果值。
  3. Rejected(已拒绝):意味着操作失败,Promise 有一个相关的拒绝原因(通常是一个错误对象)。

一旦 Promise 从 Pending 状态转换到 FulfilledRejected 状态,就称为“已敲定(settled)”,状态将不再改变。

创建 Promise

我们可以使用 new Promise 来创建一个 Promise 对象。Promise 构造函数接受一个执行器函数作为参数,这个执行器函数接受两个参数:resolvereject

const myPromise = new Promise((resolve, reject) => {
    // 模拟异步操作
    setTimeout(() => {
        const success = true;
        if (success) {
            resolve('操作成功');
        } else {
            reject(new Error('操作失败'));
        }
    }, 1000);
});

在上述代码中,我们使用 setTimeout 模拟了一个异步操作,1 秒后根据 success 的值决定是调用 resolve 还是 reject

处理 Promise

处理 Promise 通常使用 .then().catch().finally() 方法。

.then() 方法用于处理 Promise 被 resolve 的情况,它接受一个回调函数作为参数,这个回调函数会在 Promise 成功时被调用,并且会传入 Promise 的结果值。

myPromise.then((result) => {
    console.log(result); // 输出: 操作成功
});

.catch() 方法用于处理 Promise 被 reject 的情况,它接受一个回调函数作为参数,这个回调函数会在 Promise 失败时被调用,并且会传入 Promise 的拒绝原因。

myPromise.catch((error) => {
    console.error(error); // 输出: Error: 操作失败
});

.finally() 方法无论 Promise 是被 resolve 还是被 reject,都会被调用。它不接受任何参数,主要用于执行一些清理操作,比如关闭文件、释放资源等。

myPromise.finally(() => {
    console.log('Promise 已敲定,无论成功或失败都会执行这里');
});

Promise 链式调用

Promise 的一个强大之处在于可以进行链式调用。我们可以在 .then() 方法返回的新 Promise 上继续调用 .then() 等方法,从而避免了回调地狱。

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

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

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

step1()
   .then(step2)
   .then(step3)
   .then((finalResult) => {
        console.log(finalResult);
    })
   .catch((error) => {
        console.error(error);
    });

在上述代码中,step1 执行完成后,将结果传递给 step2step2 执行完成后又将结果传递给 step3。如果任何一步出现错误,.catch() 方法会捕获并处理错误。

Promise.all() 和 Promise.race()

Promise.all() 方法用于将多个 Promise 实例包装成一个新的 Promise 实例。新的 Promise 只有在所有传入的 Promise 都被 resolve 时才会被 resolve,此时它的结果是一个包含所有传入 Promise 结果值的数组。如果有任何一个传入的 Promise 被 reject,新的 Promise 就会立即被 reject,并且拒绝原因就是第一个被 reject 的 Promise 的拒绝原因。

const promise1 = new Promise((resolve) => {
    setTimeout(() => {
        resolve('Promise 1 完成');
    }, 1000);
});

const promise2 = new Promise((resolve) => {
    setTimeout(() => {
        resolve('Promise 2 完成');
    }, 2000);
});

const promise3 = new Promise((resolve) => {
    setTimeout(() => {
        resolve('Promise 3 完成');
    }, 3000);
});

Promise.all([promise1, promise2, promise3]).then((results) => {
    console.log(results); // 输出: ['Promise 1 完成', 'Promise 2 完成', 'Promise 3 完成']
});

Promise.race() 方法同样将多个 Promise 实例包装成一个新的 Promise 实例。但不同的是,新的 Promise 会在第一个传入的 Promise 被 resolve 或 reject 时就被敲定,它的结果就是第一个被敲定的 Promise 的结果。

const promise4 = new Promise((resolve) => {
    setTimeout(() => {
        resolve('Promise 4 完成');
    }, 3000);
});

const promise5 = new Promise((resolve) => {
    setTimeout(() => {
        resolve('Promise 5 完成');
    }, 1000);
});

const promise6 = new Promise((resolve) => {
    setTimeout(() => {
        resolve('Promise 6 完成');
    }, 2000);
});

Promise.race([promise4, promise5, promise6]).then((result) => {
    console.log(result); // 输出: Promise 5 完成
});

async/await

async 函数简介

async 函数是 ES2017 引入的异步函数语法,它是基于 Promise 之上的更简洁的异步编程方式。async 函数本质上是一个返回 Promise 的函数,不过它的语法看起来更像是普通的同步函数。

async function asyncFunction() {
    return '异步函数返回值';
}

asyncFunction().then((result) => {
    console.log(result); // 输出: 异步函数返回值
});

在上述代码中,asyncFunction 是一个 async 函数,它返回一个 Promise,return 的值会作为这个 Promise 的 resolve 值。

await 关键字

await 关键字只能在 async 函数内部使用,它用于暂停 async 函数的执行,直到其等待的 Promise 被 resolve 或 reject。

function delay(ms) {
    return new Promise((resolve) => {
        setTimeout(resolve, ms);
    });
}

async function asyncTask() {
    console.log('开始任务');
    await delay(2000);
    console.log('任务完成,等待 2 秒后执行');
}

asyncTask();

在上述代码中,await delay(2000) 会暂停 asyncTask 函数的执行,直到 delay 返回的 Promise 被 resolve(即等待 2 秒),然后才会继续执行后面的代码。

使用 async/await 处理错误

async 函数中,可以使用普通的 try...catch 语句来捕获异步操作中的错误,这比 Promise 的 .catch() 方法在某些情况下更直观。

async function asyncErrorTask() {
    try {
        await Promise.reject(new Error('操作失败'));
    } catch (error) {
        console.error(error); // 输出: Error: 操作失败
    }
}

asyncErrorTask();

async/await 与 Promise 链式调用的比较

相比于 Promise 链式调用,async/await 的代码结构更接近同步代码,使得异步操作看起来更加自然和易于理解。例如,用 async/await 重写前面的 Promise 链式调用的例子:

async function step1() {
    await delay(1000);
    return '步骤 1 完成';
}

async function step2() {
    const result1 = await step1();
    console.log(result1);
    await delay(1000);
    return '步骤 2 完成';
}

async function step3() {
    const result2 = await step2();
    console.log(result2);
    await delay(1000);
    return '步骤 3 完成';
}

step3().then((finalResult) => {
    console.log(finalResult);
}).catch((error) => {
    console.error(error);
});

可以看到,async/await 语法使得代码的逻辑更加清晰,避免了 Promise 链式调用中可能出现的多层嵌套。

实际应用场景中的最佳实践

网络请求

在进行网络请求时,使用 async/await 结合 fetch API 是非常常见且推荐的方式。fetch API 本身返回一个 Promise,我们可以很方便地在 async 函数中使用 await 来处理。

async function fetchData() {
    try {
        const response = await fetch('https://example.com/api/data');
        if (!response.ok) {
            throw new Error(`HTTP error! status: ${response.status}`);
        }
        const data = await response.json();
        console.log(data);
    } catch (error) {
        console.error(error);
    }
}

fetchData();

在上述代码中,我们首先使用 fetch 发起一个网络请求,然后使用 await 等待响应。如果响应状态不是 ok,我们抛出一个错误。接着,我们再次使用 await 等待将响应解析为 JSON 格式的数据。

文件操作(Node.js)

在 Node.js 中进行文件操作时,也可以利用 async/await 提升代码的可读性。例如,使用 fs/promises 模块(Node.js 14+):

const fs = require('fs/promises');

async function readFiles() {
    try {
        const data1 = await fs.readFile('file1.txt', 'utf8');
        const data2 = await fs.readFile('file2.txt', 'utf8');
        const data3 = await fs.readFile('file3.txt', 'utf8');
        console.log(data1, data2, data3);
    } catch (error) {
        console.error(error);
    }
}

readFiles();

这里通过 fs/promises 模块的 readFile 方法返回的 Promise,使用 async/await 实现了顺序读取多个文件,避免了回调地狱。

并发与并行操作

在处理多个异步任务时,有时我们需要控制任务的并发数量,以避免资源耗尽等问题。例如,我们有一个任务列表,每个任务都是一个 Promise,我们希望同时执行一定数量的任务。

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

async function runTasksInParallel(tasks, maxParallel) {
    let completed = 0;
    const results = [];
    const running = [];

    for (let i = 0; i < tasks.length; i++) {
        while (running.length >= maxParallel) {
            await Promise.race(running);
            running.splice(running.indexOf(await Promise.race(running)), 1);
        }
        const task = asyncTask(tasks[i]);
        running.push(task);
        task.then((result) => {
            completed++;
            results.push(result);
            running.splice(running.indexOf(task), 1);
            if (completed === tasks.length) {
                console.log('所有任务完成:', results);
            }
        });
    }
}

const taskList = [1, 2, 3, 4, 5, 6];
runTasksInParallel(taskList, 3);

在上述代码中,runTasksInParallel 函数实现了控制任务并发数量的功能。maxParallel 表示同时执行的最大任务数。通过 Promise.race 和数组操作,我们确保在任何时刻同时运行的任务数量不超过 maxParallel

错误处理策略

在异步编程中,良好的错误处理策略至关重要。除了前面提到的在 async 函数中使用 try...catch 捕获错误外,还可以在整个应用层面设置全局的错误处理机制。

在 Node.js 中,可以监听 process 对象的 uncaughtException 事件来捕获未处理的异常:

process.on('uncaughtException', (error) => {
    console.error('未捕获的异常:', error);
    // 可以在这里进行一些清理操作或发送错误报告
});

在浏览器环境中,可以通过 window.onerror 来捕获全局的 JavaScript 错误:

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
</head>

<body>
    <script>
        window.onerror = function (message, source, lineno, colno, error) {
            console.error('全局错误:', message);
            console.error('错误源:', source);
            console.error('行号:', lineno);
            console.error('列号:', colno);
            console.error('错误对象:', error);
            return true; // 返回 true 表示错误已处理,阻止默认的错误处理行为
        };

        function throwError() {
            throw new Error('这是一个测试错误');
        }

        throwError();
    </script>
</body>

</html>

这样,在应用中未被捕获的异步错误也能得到妥善处理,避免程序崩溃或出现不可预测的行为。

性能优化

在异步编程中,性能优化也是一个重要的方面。例如,避免不必要的异步操作,尽量将同步操作放在异步操作之前执行。

另外,在处理大量异步任务时,可以考虑使用队列来管理任务,按照一定的顺序或策略执行任务,避免一次性启动过多任务导致内存或资源耗尽。

对于需要多次重复执行的异步操作,可以考虑使用缓存机制,避免重复执行相同的异步任务,从而提高性能。例如,对于网络请求,如果请求的参数和 URL 相同,并且响应结果在一定时间内没有变化,可以直接从缓存中获取数据,而不是再次发起网络请求。

const cache = {};
async function cachedFetch(url) {
    if (cache[url]) {
        return cache[url];
    }
    const response = await fetch(url);
    const data = await response.json();
    cache[url] = data;
    return data;
}

在上述代码中,cachedFetch 函数实现了一个简单的缓存机制,对于相同的 URL 请求,会先检查缓存中是否有数据,如果有则直接返回,否则发起网络请求并将结果存入缓存。

通过合理应用这些异步编程的最佳实践,可以使 JavaScript 应用更加健壮、高效和易于维护。无论是小型的前端项目还是大型的后端服务,良好的异步编程技巧都能显著提升应用的质量和性能。在实际开发中,需要根据具体的场景和需求,选择最合适的异步编程方式,并结合错误处理、性能优化等策略,打造出优秀的 JavaScript 应用。同时,随着 JavaScript 语言的不断发展,异步编程的相关技术也可能会有新的变化和改进,开发者需要持续关注并学习,以保持技术的先进性。