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

Node.js 异步编程入门指南

2022-08-155.3k 阅读

什么是异步编程

在 Node.js 中,理解异步编程是至关重要的。JavaScript 作为一门单线程语言,在传统的同步编程模型下,如果有一个长时间运行的任务,比如读取一个大文件或者进行网络请求,整个程序将会被阻塞,用户界面也会变得无响应(在浏览器环境下),这显然是不可接受的。而异步编程则提供了一种解决方案,使得 JavaScript 可以在执行这些耗时操作时,不会阻塞主线程,继续执行其他任务,当这些异步操作完成后,再通过特定的机制通知程序进行后续处理。

在 Node.js 中,大量的 API 都是异步的,比如文件系统操作(fs模块)、网络请求(http模块等)。这是因为 Node.js 主要设计用于构建高性能的网络应用,在这类应用中,I/O 操作频繁且耗时,如果采用同步方式,应用的性能会大打折扣。

异步编程的实现方式

回调函数

回调函数是 Node.js 中最基础的异步编程方式。它的原理很简单,就是将一个函数作为参数传递给另一个函数,当异步操作完成后,被调用的函数会执行这个作为参数的回调函数。

以下是一个使用fs模块读取文件的简单示例:

const fs = require('fs');

fs.readFile('example.txt', 'utf8', (err, data) => {
    if (err) {
        console.error('读取文件出错:', err);
        return;
    }
    console.log('文件内容:', data);
});

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

虽然回调函数简单直接,但当异步操作嵌套过多时,就会出现所谓的“回调地狱”(Callback Hell),代码的可读性和维护性会急剧下降。例如:

fs.readFile('file1.txt', 'utf8', (err1, data1) => {
    if (err1) {
        console.error('读取 file1.txt 出错:', err1);
        return;
    }

    fs.readFile('file2.txt', 'utf8', (err2, data2) => {
        if (err2) {
            console.error('读取 file2.txt 出错:', err2);
            return;
        }

        fs.readFile('file3.txt', 'utf8', (err3, data3) => {
            if (err3) {
                console.error('读取 file3.txt 出错:', err3);
                return;
            }

            console.log('file1 内容:', data1);
            console.log('file2 内容:', data2);
            console.log('file3 内容:', data3);
        });
    });
});

上述代码中,三个文件读取操作层层嵌套,随着异步操作的增加,代码会变得越来越难以理解和维护。

Promise

Promise 是为了解决回调地狱问题而引入的一种异步编程解决方案。Promise 表示一个异步操作的最终完成(或失败)及其结果值。它有三种状态:pending(进行中)、fulfilled(已成功)和rejected(已失败)。一旦状态改变,就不会再变,任何时候都可以得到这个结果。

创建一个 Promise 实例的基本语法如下:

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

promise.then((result) => {
    console.log(result);
}).catch((error) => {
    console.error(error);
});

在上述代码中,首先创建了一个 Promise 实例,在构造函数中进行了一个模拟的异步操作(setTimeout)。如果操作成功,调用resolve并传递成功的结果;如果失败,调用reject并传递错误信息。然后通过then方法来处理成功的情况,catch方法来处理失败的情况。

使用 Promise 可以将上述回调地狱的示例改写为更易读的形式:

const fs = require('fs');
const util = require('util');

const readFilePromise = util.promisify(fs.readFile);

readFilePromise('file1.txt', 'utf8')
   .then((data1) => {
        console.log('file1 内容:', data1);
        return readFilePromise('file2.txt', 'utf8');
    })
   .then((data2) => {
        console.log('file2 内容:', data2);
        return readFilePromise('file3.txt', 'utf8');
    })
   .then((data3) => {
        console.log('file3 内容:', data3);
    })
   .catch((err) => {
        console.error('读取文件出错:', err);
    });

在这段代码中,使用util.promisify方法将fs.readFile这个基于回调的异步函数转换为返回 Promise 的函数。然后通过链式调用then方法,依次处理每个文件的读取操作,使得代码逻辑更加清晰。

async/await

async/await是建立在 Promise 之上的一种更简洁的异步编程语法糖。async函数总是返回一个 Promise。如果async函数的返回值不是一个 Promise,JavaScript 会自动使用Promise.resolve将其包装成一个 Promise。

await只能在async函数内部使用,它会暂停async函数的执行,等待 Promise 被解决(resolved 或 rejected),然后恢复async函数的执行,并返回 Promise 的解决值。

以下是使用async/await改写上述文件读取的示例:

const fs = require('fs');
const util = require('util');

const readFilePromise = util.promisify(fs.readFile);

async function readFiles() {
    try {
        const data1 = await readFilePromise('file1.txt', 'utf8');
        console.log('file1 内容:', data1);

        const data2 = await readFilePromise('file2.txt', 'utf8');
        console.log('file2 内容:', data2);

        const data3 = await readFilePromise('file3.txt', 'utf8');
        console.log('file3 内容:', data3);
    } catch (err) {
        console.error('读取文件出错:', err);
    }
}

readFiles();

在上述代码中,定义了一个async函数readFiles。在函数内部,使用await等待每个文件读取的 Promise 完成,使得异步操作看起来就像同步操作一样,极大地提高了代码的可读性。同时,通过try...catch块来捕获可能出现的错误。

事件循环

理解事件循环(Event Loop)是深入掌握 Node.js 异步编程的关键。Node.js 基于 Chrome 的 V8 引擎,而 V8 引擎主要负责执行 JavaScript 代码,但它本身并不处理 I/O 操作等异步任务。Node.js 引入了一个事件循环机制,来协调这些异步操作。

事件循环的基本工作原理如下:

  1. 调用栈(Call Stack):JavaScript 是单线程的,这意味着它在同一时间只能执行一个任务。调用栈用于记录当前正在执行的函数调用。当一个函数被调用时,它会被压入调用栈;当函数执行完毕,它会从调用栈中弹出。

  2. 任务队列(Task Queue):异步任务(如 I/O 操作、定时器等)完成后,会将其回调函数放入任务队列中。任务队列是一个先进先出(FIFO)的数据结构。

  3. 事件循环过程:事件循环不断地检查调用栈是否为空。如果调用栈为空,它会从任务队列中取出一个任务(回调函数),将其压入调用栈并执行。这个过程不断重复,从而实现了异步任务的处理。

以下是一个简单的示例来说明事件循环的工作过程:

console.log('开始');

setTimeout(() => {
    console.log('定时器回调');
}, 0);

console.log('结束');

在上述代码中,首先输出开始,然后遇到setTimeout,它会将其回调函数放入任务队列,继续执行后续代码,输出结束。此时调用栈为空,事件循环从任务队列中取出setTimeout的回调函数,压入调用栈并执行,输出定时器回调

在 Node.js 中,事件循环分为多个阶段,每个阶段都有特定的任务类型进行处理,大致如下:

  1. timers:处理setTimeoutsetInterval设定的定时器回调。

  2. pending callbacks:执行一些系统级别的回调,比如 TCP 连接错误等。

  3. idle, prepare:Node.js 内部使用,一般开发者不需要关心。

  4. poll:这是事件循环中最重要的阶段之一,它会检查是否有新的 I/O 事件,如果有则执行相关的回调。同时,如果有已经到期的定时器,也会在这个阶段执行。

  5. check:执行setImmediate设定的回调。

  6. close callbacks:执行一些关闭相关的回调,比如socket.on('close', ...)

异步错误处理

在异步编程中,错误处理非常重要。不同的异步编程方式有不同的错误处理机制。

回调函数中的错误处理

在回调函数中,通常约定将错误对象作为回调函数的第一个参数。例如前面的fs.readFile示例:

const fs = require('fs');

fs.readFile('nonexistent.txt', 'utf8', (err, data) => {
    if (err) {
        console.error('读取文件出错:', err);
        return;
    }
    console.log('文件内容:', data);
});

在这个示例中,如果文件不存在或者读取过程中出现其他错误,err会被赋值为相应的错误对象,通过检查err来进行错误处理。

Promise 中的错误处理

Promise 通过catch方法来处理错误。在 Promise 的链式调用中,只要有一个 Promise 被拒绝(rejected),就会进入最近的catch块。例如:

const promise1 = new Promise((resolve, reject) => {
    setTimeout(() => {
        reject('操作失败');
    }, 1000);
});

const promise2 = new Promise((resolve, reject) => {
    setTimeout(() => {
        resolve('操作成功');
    }, 2000);
});

promise1
   .then((result) => {
        console.log(result);
    })
   .catch((error) => {
        console.error('promise1 错误:', error);
    });

promise2
   .then((result) => {
        console.log(result);
    })
   .catch((error) => {
        console.error('promise2 错误:', error);
    });

在上述代码中,promise1被拒绝,会进入它对应的catch块输出错误信息;promise2成功,会进入then块输出成功信息。

async/await 中的错误处理

async/await中,使用try...catch块来捕获错误。例如:

async function asyncFunction() {
    try {
        const result = await new Promise((resolve, reject) => {
            setTimeout(() => {
                reject('操作失败');
            }, 1000);
        });
        console.log(result);
    } catch (error) {
        console.error('async 函数错误:', error);
    }
}

asyncFunction();

在这个示例中,await的 Promise 被拒绝,会被try...catch块捕获并输出错误信息。

异步并发与串行

在实际应用中,经常会遇到需要处理多个异步操作的情况,这时候就涉及到异步并发和串行的概念。

异步并发

异步并发指的是同时发起多个异步操作,而不等待前一个操作完成再进行下一个。在 Node.js 中,可以使用Promise.all来实现异步并发。

Promise.all接受一个 Promise 数组作为参数,返回一个新的 Promise。只有当所有传入的 Promise 都被解决(resolved)时,新的 Promise 才会被解决,并且其解决值是一个包含所有传入 Promise 解决值的数组。如果其中任何一个 Promise 被拒绝(rejected),新的 Promise 就会立即被拒绝,其拒绝原因就是第一个被拒绝的 Promise 的原因。

以下是一个示例,同时读取多个文件:

const fs = require('fs');
const util = require('util');

const readFilePromise = util.promisify(fs.readFile);

const fileNames = ['file1.txt', 'file2.txt', 'file3.txt'];

Promise.all(fileNames.map((fileName) => readFilePromise(fileName, 'utf8')))
   .then((dataArray) => {
        dataArray.forEach((data, index) => {
            console.log(`${fileNames[index]} 内容:`, data);
        });
    })
   .catch((err) => {
        console.error('读取文件出错:', err);
    });

在上述代码中,通过map方法将每个文件名转换为对应的readFilePromise,然后使用Promise.all来并发执行这些文件读取操作。当所有文件都读取成功后,处理读取到的数据。

异步串行

异步串行则是依次执行异步操作,前一个操作完成后再进行下一个。前面使用async/await的文件读取示例就是一种异步串行的实现方式。

const fs = require('fs');
const util = require('util');

const readFilePromise = util.promisify(fs.readFile);

async function readFilesSequentially() {
    const fileNames = ['file1.txt', 'file2.txt', 'file3.txt'];
    for (const fileName of fileNames) {
        const data = await readFilePromise(fileName, 'utf8');
        console.log(`${fileName} 内容:`, data);
    }
}

readFilesSequentially();

在这个示例中,通过for...of循环依次读取每个文件,实现了异步串行操作。

异步编程与性能优化

合理使用异步编程可以显著提升 Node.js 应用的性能,但如果使用不当,也可能带来性能问题。

  1. 减少不必要的异步操作:虽然异步操作可以避免阻塞主线程,但过多的异步操作也会增加事件循环的负担。例如,如果一些操作本身并不耗时,没有必要将其异步化。

  2. 优化并发操作数量:在使用异步并发时,要注意控制并发的数量。如果同时发起过多的并发操作,可能会耗尽系统资源,比如文件描述符、网络连接等。可以使用Promise.allSettled结合队列控制等方式来优化并发数量。

  3. 缓存异步结果:对于一些重复执行的异步操作,可以考虑缓存其结果。例如,对于一些配置文件的读取,在第一次读取后将结果缓存起来,后续需要时直接使用缓存的数据,避免重复的 I/O 操作。

总结异步编程在 Node.js 生态中的应用

异步编程是 Node.js 的核心特性之一,广泛应用于各种场景。在 Web 开发中,处理大量的 HTTP 请求、数据库操作等都离不开异步编程。在微服务架构中,服务之间的通信、分布式缓存的访问等也都基于异步机制。掌握异步编程的各种方式,包括回调函数、Promise、async/await,以及理解事件循环、错误处理、并发与串行等概念,对于开发高性能、可靠的 Node.js 应用至关重要。通过合理运用异步编程技术,并进行性能优化,可以充分发挥 Node.js 的优势,构建出强大的网络应用。同时,随着 Node.js 生态的不断发展,异步编程的相关技术也在不断演进,开发者需要持续关注并学习新的特性和最佳实践,以适应不断变化的开发需求。

以上就是关于 Node.js 异步编程的详细指南,希望能帮助你深入理解和掌握这一重要的技术。在实际开发中,不断实践和总结经验,你将能够更加熟练地运用异步编程来构建优秀的 Node.js 应用。