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

Node.js 异步编程的最佳实践

2023-08-027.4k 阅读

理解 Node.js 中的异步编程

在 Node.js 开发中,异步编程是一个核心概念。Node.js 以其单线程、事件驱动的架构而闻名,这种架构使得异步操作成为处理 I/O 密集型任务的关键。

为什么需要异步编程

在传统的同步编程模型中,代码按照顺序依次执行。如果有一个操作需要等待外部资源(如读取文件、网络请求等),主线程就会被阻塞,直到该操作完成。这在 I/O 操作频繁的应用中会严重影响性能,因为 I/O 操作通常比 CPU 计算要慢得多。

而 Node.js 的异步编程模型允许主线程在等待 I/O 操作完成时继续执行其他任务,通过事件循环来处理 I/O 操作的结果。这样可以极大地提高应用的并发处理能力,充分利用系统资源。

异步操作的基本形式

  1. 回调函数:回调函数是 Node.js 中最基本的异步编程方式。在一个异步操作完成后,会调用传入的回调函数,并将操作结果作为参数传递给回调函数。

示例代码如下:

const fs = require('fs');

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

在上述代码中,fs.readFile 是一个异步操作,它接收文件名、编码格式以及一个回调函数作为参数。当文件读取完成后,会调用回调函数,并将可能的错误 err 和读取到的数据 data 传递给回调函数。

  1. Promise:Promise 是一种更优雅的处理异步操作的方式,它通过链式调用解决了回调地狱(多层嵌套回调函数导致代码难以维护和阅读)的问题。

示例代码如下:

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

fs.readFile('example.txt', 'utf8')
   .then(data => {
        console.log(data);
    })
   .catch(err => {
        console.error(err);
    });

这里使用了 fs.promises 模块,它返回一个 Promise 对象。then 方法用于处理 Promise 成功的情况,catch 方法用于捕获 Promise 中抛出的错误。

  1. Async/Await:Async/Await 是基于 Promise 构建的语法糖,它使得异步代码看起来更像同步代码,进一步提高了代码的可读性。

示例代码如下:

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

async function readFileAsync() {
    try {
        const data = await fs.readFile('example.txt', 'utf8');
        console.log(data);
    } catch (err) {
        console.error(err);
    }
}

readFileAsync();

async 函数中,await 关键字只能在 async 函数内部使用,它会暂停函数的执行,直到 Promise 被解决(resolved)或被拒绝(rejected)。如果 Promise 被解决,await 会返回解决的值;如果 Promise 被拒绝,await 会抛出错误,可以通过 try...catch 块来捕获。

异步编程的最佳实践

1. 合理使用回调函数

虽然回调函数存在回调地狱的问题,但在一些简单的异步场景中,它仍然是一种有效的方式。

  • 错误处理:在回调函数中,始终将错误处理放在首位。按照 Node.js 的惯例,错误作为回调函数的第一个参数传递。如果没有错误,这个参数为 nullundefined

示例代码:

const http = require('http');

const server = http.createServer((req, res) => {
    const url = req.url;
    const method = req.method;

    if (method === 'POST') {
        let body = '';
        req.on('data', chunk => {
            body += chunk.toString();
        });
        req.on('end', () => {
            try {
                const data = JSON.parse(body);
                console.log(data);
                res.statusCode = 200;
                res.setHeader('Content-Type', 'application/json');
                res.write(JSON.stringify({ message: 'Data received successfully' }));
                res.end();
            } catch (err) {
                res.statusCode = 400;
                res.setHeader('Content-Type', 'application/json');
                res.write(JSON.stringify({ error: 'Invalid JSON data' }));
                res.end();
            }
        });
    } else {
        res.statusCode = 200;
        res.setHeader('Content-Type', 'text/plain');
        res.write('Hello, this is a simple server');
        res.end();
    }
});

server.listen(3000, () => {
    console.log('Server is listening on port 3000');
});

在这个 HTTP 服务器的例子中,处理 POST 请求数据时,在 req.on('end') 的回调函数中进行了错误处理,以确保如果 JSON 解析失败,能返回合适的错误响应。

  • 避免回调地狱:如果出现多层嵌套回调,可以考虑将内部回调函数提取成独立的函数,或者使用其他异步处理方式(如 Promise 或 Async/Await)。

例如,假设有这样一段嵌套回调的代码:

fs.readFile('file1.txt', 'utf8', (err1, data1) => {
    if (err1) {
        console.error(err1);
        return;
    }
    fs.writeFile('file2.txt', data1, (err2) => {
        if (err2) {
            console.error(err2);
            return;
        }
        fs.readFile('file2.txt', 'utf8', (err3, data2) => {
            if (err3) {
                console.error(err3);
                return;
            }
            console.log(data2);
        });
    });
});

可以将内部回调提取成函数:

function writeFileAndRead(file1, file2) {
    fs.readFile(file1, 'utf8', (err1, data1) => {
        if (err1) {
            console.error(err1);
            return;
        }
        fs.writeFile(file2, data1, (err2) => {
            if (err2) {
                console.error(err2);
                return;
            }
            readFile(file2);
        });
    });
}

function readFile(file) {
    fs.readFile(file, 'utf8', (err, data) => {
        if (err) {
            console.error(err);
            return;
        }
        console.log(data);
    });
}

writeFileAndRead('file1.txt', 'file2.txt');

2. 深入理解 Promise

  • 创建 Promise:可以使用 new Promise() 构造函数来创建一个 Promise 对象。构造函数接收一个执行器函数,该执行器函数包含 resolvereject 两个参数,用于解决或拒绝 Promise。

示例代码:

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

delay(2000).then(() => {
    console.log('Delayed for 2 seconds');
});

在上述代码中,delay 函数返回一个 Promise,通过 setTimeout 模拟了一个异步操作,2 秒后调用 resolve 解决 Promise。

  • Promise 链:Promise 的链式调用是其强大之处。通过在 then 方法中返回一个新的 Promise,可以继续链式调用 then 方法。

示例代码:

function step1() {
    return new Promise((resolve) => {
        setTimeout(() => {
            console.log('Step 1 completed');
            resolve('Result of step 1');
        }, 1000);
    });
}

function step2(result) {
    return new Promise((resolve) => {
        setTimeout(() => {
            console.log('Step 2 completed with result:', result);
            resolve('Result of step 2');
        }, 1000);
    });
}

function step3(result) {
    return new Promise((resolve) => {
        setTimeout(() => {
            console.log('Step 3 completed with result:', result);
            resolve('Final result');
        }, 1000);
    });
}

step1()
   .then(step2)
   .then(step3)
   .then(finalResult => {
        console.log('Final result:', finalResult);
    });

在这个例子中,step1 执行完成后,将结果传递给 step2step2 执行完成后又将结果传递给 step3,形成了一个 Promise 链。

  • Promise.all 和 Promise.race
    • Promise.all:用于并行执行多个 Promise,并在所有 Promise 都解决时返回一个包含所有结果的新 Promise。如果其中任何一个 Promise 被拒绝,整个 Promise.all 就会被拒绝。

示例代码:

const promise1 = new Promise((resolve) => {
    setTimeout(() => {
        resolve('Promise 1 result');
        console.log('Promise 1 resolved');
    }, 1000);
});

const promise2 = new Promise((resolve) => {
    setTimeout(() => {
        resolve('Promise 2 result');
        console.log('Promise 2 resolved');
    }, 2000);
});

Promise.all([promise1, promise2])
   .then(results => {
        console.log('All promises resolved:', results);
    })
   .catch(err => {
        console.error('One of the promises was rejected:', err);
    });

在上述代码中,Promise.all 等待 promise1promise2 都解决后,将它们的结果以数组形式传递给 then 方法。

  • Promise.race:同样接收一个 Promise 数组,但只要其中任何一个 Promise 被解决或被拒绝,Promise.race 就会立即以该 Promise 的结果或错误进行解决或拒绝。

示例代码:

const promise3 = new Promise((resolve) => {
    setTimeout(() => {
        resolve('Promise 3 result');
        console.log('Promise 3 resolved');
    }, 3000);
});

const promise4 = new Promise((resolve) => {
    setTimeout(() => {
        resolve('Promise 4 result');
        console.log('Promise 4 resolved');
    }, 1500);
});

Promise.race([promise3, promise4])
   .then(result => {
        console.log('The first resolved promise result:', result);
    })
   .catch(err => {
        console.error('The first rejected promise error:', err);
    });

在这个例子中,promise4 会先于 promise3 解决,所以 Promise.race 会以 promise4 的结果进行解决。

3. 熟练运用 Async/Await

  • 错误处理:在 async 函数中,使用 try...catch 块来捕获 await 操作抛出的错误。这比在 Promise 中使用 catch 方法更直观,因为代码结构更像同步代码的错误处理。

示例代码:

async function readFileAndProcess() {
    try {
        const data = await fs.readFile('nonexistent.txt', 'utf8');
        console.log(data);
    } catch (err) {
        console.error('Error reading file:', err);
    }
}

readFileAndProcess();

在这个例子中,如果文件不存在,await fs.readFile 会抛出错误,被 catch 块捕获并处理。

  • 并行执行异步操作:虽然 async 函数内的 await 会暂停函数执行,但可以使用 Promise.allasync 函数内并行执行多个异步操作。

示例代码:

async function parallelTasks() {
    const promise1 = fs.readFile('file1.txt', 'utf8');
    const promise2 = fs.readFile('file2.txt', 'utf8');

    const [data1, data2] = await Promise.all([promise1, promise2]);
    console.log('Data from file1:', data1);
    console.log('Data from file2:', data2);
}

parallelTasks();

在上述代码中,promise1promise2 会并行执行,Promise.all 等待它们都完成后,将结果分别赋值给 data1data2

  • 处理多个异步操作的结果:当需要处理多个异步操作的结果时,async 函数和 await 可以清晰地组织代码。

示例代码:

async function processMultipleFiles() {
    const fileNames = ['file1.txt', 'file2.txt', 'file3.txt'];
    const results = [];

    for (const fileName of fileNames) {
        try {
            const data = await fs.readFile(fileName, 'utf8');
            results.push(data);
        } catch (err) {
            console.error(`Error reading ${fileName}:`, err);
        }
    }

    console.log('All file data:', results);
}

processMultipleFiles();

在这个例子中,通过 for...of 循环遍历文件名数组,依次读取每个文件,并将结果存储在 results 数组中。如果读取某个文件出错,会进行相应的错误处理。

异步编程中的性能优化

1. 减少异步操作的开销

  • 避免不必要的异步转换:在某些情况下,将同步操作转换为异步操作可能会引入额外的开销。例如,简单的计算操作通常应该保持同步,只有在涉及 I/O 或其他阻塞操作时才使用异步。

示例代码:

function addNumbers(a, b) {
    return a + b;
}

async function addNumbersAsync(a, b) {
    return new Promise((resolve) => {
        setTimeout(() => {
            resolve(a + b);
        }, 0);
    });
}

// 直接调用同步函数性能更好
const result1 = addNumbers(2, 3);
// 异步转换引入了不必要的开销
addNumbersAsync(2, 3).then(result2 => {
    console.log(result2);
});

在上述代码中,addNumbers 是一个简单的同步计算函数,而 addNumbersAsync 通过 setTimeout 将其转换为异步操作,这在这种场景下是不必要的,会增加开销。

  • 复用异步操作结果:如果一个异步操作的结果会被多次使用,尽量避免重复执行该异步操作。可以将异步操作的结果缓存起来,以便后续使用。

示例代码:

let cachedData;

async function getData() {
    if (cachedData) {
        return cachedData;
    }
    const data = await fs.readFile('config.txt', 'utf8');
    cachedData = data;
    return data;
}

async function processData1() {
    const data = await getData();
    console.log('Processing data in processData1:', data);
}

async function processData2() {
    const data = await getData();
    console.log('Processing data in processData2:', data);
}

Promise.all([processData1(), processData2()]);

在这个例子中,getData 函数会检查是否已经缓存了数据,如果有则直接返回缓存数据,避免了重复读取文件。

2. 合理使用事件循环

  • 了解事件循环机制:Node.js 的事件循环是实现异步编程的核心机制。它不断地从任务队列中取出任务并执行,直到任务队列为空。理解事件循环的工作原理有助于优化异步代码的性能。

事件循环的基本流程如下:

  1. 执行栈:代码在执行栈中执行,同步代码会依次执行。
  2. 任务队列:当遇到异步操作时,操作会被放入任务队列,等待执行栈为空时,事件循环会将任务队列中的任务依次放入执行栈执行。
  3. 微任务队列:微任务(如 Promise 的 then 回调)会在当前任务执行结束后,下一个宏任务(如 I/O 操作、setTimeout 等)执行之前执行。

示例代码:

console.log('Start');

setTimeout(() => {
    console.log('Timeout callback');
}, 0);

Promise.resolve().then(() => {
    console.log('Promise then callback');
});

console.log('End');

在这个例子中,输出结果为:

Start
End
Promise then callback
Timeout callback

这是因为 console.log('Start')console.log('End') 是同步代码,会立即执行。setTimeout 回调会被放入宏任务队列,Promise.then 回调会被放入微任务队列。当执行栈为空时,先执行微任务队列中的任务,再执行宏任务队列中的任务。

  • 避免阻塞事件循环:长时间运行的同步任务会阻塞事件循环,导致其他异步任务无法及时执行。尽量将长时间运行的任务分解为多个较小的任务,或者使用 setImmediateprocess.nextTick 等方法将任务推迟到下一个事件循环周期执行。

示例代码:

function longRunningTask() {
    for (let i = 0; i < 1000000000; i++) {
        // 模拟长时间运行的任务
    }
    console.log('Long running task completed');
}

setTimeout(() => {
    console.log('Timeout callback should be executed immediately');
}, 0);

longRunningTask();

在上述代码中,longRunningTask 会阻塞事件循环,导致 setTimeout 回调无法及时执行。可以使用 setImmediate 来解决这个问题:

function longRunningTask() {
    setImmediate(() => {
        for (let i = 0; i < 1000000000; i++) {
            // 模拟长时间运行的任务
        }
        console.log('Long running task completed');
    });
}

setTimeout(() => {
    console.log('Timeout callback should be executed immediately');
}, 0);

longRunningTask();

这样,longRunningTask 会被推迟到下一个事件循环周期执行,不会阻塞当前事件循环,setTimeout 回调可以及时执行。

3. 优化异步 I/O 操作

  • 批量处理 I/O 操作:如果有多个相似的 I/O 操作,可以考虑批量处理,减少 I/O 操作的次数。例如,使用 fs.readdir 读取目录下的多个文件,而不是逐个读取。

示例代码:

async function readAllFilesInDir(dir) {
    const files = await fs.readdir(dir);
    const promises = files.map(file => fs.readFile(`${dir}/${file}`, 'utf8'));
    const results = await Promise.all(promises);
    return results;
}

readAllFilesInDir('myDirectory').then(data => {
    console.log('All file data:', data);
});

在这个例子中,先使用 fs.readdir 获取目录下的所有文件名,然后通过 Promise.all 并行读取所有文件,减少了 I/O 操作的总次数。

  • 使用流(Stream)进行 I/O 操作:对于大型文件或大量数据的 I/O 操作,使用流可以显著提高性能。流可以逐块处理数据,而不是一次性加载整个数据,减少内存占用。

示例代码:

const fs = require('fs');
const readableStream = fs.createReadStream('largeFile.txt');
const writableStream = fs.createWriteStream('newFile.txt');

readableStream.pipe(writableStream);

readableStream.on('error', err => {
    console.error('Read error:', err);
});

writableStream.on('error', err => {
    console.error('Write error:', err);
});

在上述代码中,通过 fs.createReadStream 创建可读流,通过 fs.createWriteStream 创建可写流,使用 pipe 方法将可读流的数据直接传输到可写流,实现高效的文件复制。

异步编程中的错误处理

1. 统一的错误处理策略

  • 全局错误处理:在 Node.js 应用中,可以设置全局的未捕获异常处理和未处理拒绝处理。

对于未捕获异常,可以使用 process.on('uncaughtException', callback) 来捕获应用中未被捕获的异常:

process.on('uncaughtException', (err) => {
    console.error('Uncaught Exception:', err.message);
    console.error(err.stack);
    // 可以在这里进行一些清理操作或记录日志
    process.exit(1);
});

对于未处理的 Promise 拒绝,可以使用 process.on('unhandledRejection', callback) 来捕获:

process.on('unhandledRejection', (reason, promise) => {
    console.error('Unhandled Rejection at:', promise, 'reason:', reason);
    // 同样可以进行清理或日志记录操作
});

虽然设置全局错误处理可以防止应用崩溃,但最好在代码中对可能出现的错误进行及时处理,而不是依赖全局处理。

  • 局部错误处理:在异步操作的具体代码中,始终进行错误处理。对于回调函数,按照惯例在回调函数的第一个参数处理错误;对于 Promise,使用 catch 方法;对于 async 函数,使用 try...catch 块。

2. 错误传播

  • 回调函数中的错误传播:如果一个回调函数调用另一个异步函数,应该将错误正确地传递给上层回调。

示例代码:

function innerAsync(callback) {
    fs.readFile('nonexistent.txt', 'utf8', (err, data) => {
        if (err) {
            return callback(err);
        }
        callback(null, data);
    });
}

function outerAsync() {
    innerAsync((err, data) => {
        if (err) {
            console.error('Error in outerAsync:', err);
            return;
        }
        console.log('Data in outerAsync:', data);
    });
}

outerAsync();

在这个例子中,innerAsync 中的错误通过回调函数正确地传递给了 outerAsync

  • Promise 中的错误传播:在 Promise 链中,错误会自动传播到最近的 catch 块。

示例代码:

function step1() {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            reject(new Error('Error in step1'));
        }, 1000);
    });
}

function step2() {
    return new Promise((resolve) => {
        setTimeout(() => {
            console.log('This should not be printed');
            resolve('Step 2 result');
        }, 1000);
    });
}

step1()
   .then(step2)
   .catch(err => {
        console.error('Caught error:', err.message);
    });

在这个 Promise 链中,step1 抛出的错误会跳过 step2then 回调,直接被 catch 块捕获。

  • Async/Await 中的错误传播:在 async 函数中,await 操作抛出的错误会被 try...catch 块捕获。如果没有 try...catch 块,错误会向上传播,直到被捕获。

示例代码:

async function innerAsync() {
    await fs.readFile('nonexistent.txt', 'utf8');
}

async function outerAsync() {
    try {
        await innerAsync();
    } catch (err) {
        console.error('Error in outerAsync:', err);
    }
}

outerAsync();

在这个例子中,innerAsyncawait 操作抛出的错误被 outerAsynctry...catch 块捕获。

异步编程在实际项目中的应用

1. Web 服务器开发

在 Node.js 构建的 Web 服务器中,异步编程无处不在。例如,处理 HTTP 请求、读取数据库、文件上传等操作都需要异步处理。

示例代码:

const http = require('http');
const fs = require('fs').promises;
const path = require('path');

const server = http.createServer(async (req, res) => {
    try {
        const filePath = path.join(__dirname, 'public', req.url === '/'? 'index.html' : req.url);
        const data = await fs.readFile(filePath, 'utf8');
        res.statusCode = 200;
        res.setHeader('Content-Type', 'text/html');
        res.write(data);
        res.end();
    } catch (err) {
        res.statusCode = 404;
        res.setHeader('Content-Type', 'text/plain');
        res.write('Not Found');
        res.end();
    }
});

server.listen(3000, () => {
    console.log('Server is listening on port 3000');
});

在这个简单的 Web 服务器例子中,使用 async 函数来处理请求,await 读取文件操作,确保在文件读取完成后再发送响应。如果文件不存在,捕获错误并返回 404 响应。

2. 数据库操作

Node.js 与各种数据库(如 MongoDB、MySQL 等)交互时,也依赖异步编程。

以 MongoDB 为例:

const { MongoClient } = require('mongodb');

async function connectAndQuery() {
    const uri = "mongodb://localhost:27017";
    const client = new MongoClient(uri);

    try {
        await client.connect();
        const database = client.db('myDatabase');
        const collection = database.collection('myCollection');
        const result = await collection.find({}).toArray();
        console.log(result);
    } catch (err) {
        console.error(err);
    } finally {
        await client.close();
    }
}

connectAndQuery();

在上述代码中,使用 async 函数连接 MongoDB 数据库,await 等待连接和查询操作完成。在 finally 块中关闭数据库连接,确保资源正确释放。

3. 实时应用开发

在实时应用(如 WebSocket 应用)中,异步编程用于处理连接、消息发送和接收等操作。

示例代码:

const WebSocket = require('ws');

const wss = new WebSocket.Server({ port: 8080 });

wss.on('connection', async (ws) => {
    try {
        const data = await fs.readFile('message.txt', 'utf8');
        ws.send(data);
    } catch (err) {
        console.error(err);
        ws.send('Error sending message');
    }

    ws.on('message', (message) => {
        console.log('Received message:', message);
    });

    ws.on('close', () => {
        console.log('Connection closed');
    });
});

在这个 WebSocket 服务器例子中,当有新连接时,异步读取文件并发送给客户端。同时,异步处理接收到的消息和连接关闭事件。

通过以上内容,我们全面深入地探讨了 Node.js 异步编程的最佳实践,包括理解异步编程的基本概念、掌握不同异步处理方式的应用、优化异步性能以及处理异步错误等方面,希望能帮助开发者在 Node.js 项目中编写高效、健壮的异步代码。