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

JavaScript利用Node实现默认异步的技巧

2022-03-067.7k 阅读

JavaScript异步编程基础

在深入探讨利用Node实现默认异步的技巧之前,我们先来回顾一下JavaScript异步编程的基础概念。

回调函数(Callback)

回调函数是JavaScript异步编程中最基本的方式。当一个函数需要执行一些异步操作(如读取文件、发起网络请求等)时,它不会阻塞代码的执行,而是在操作完成后调用传入的回调函数。例如,使用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是一个异步函数,它接受文件名、编码格式以及一个回调函数作为参数。当文件读取操作完成后,无论成功与否,都会调用回调函数,并将错误对象(如果有)和读取到的数据传递给回调函数。

事件监听(Event Listener)

事件监听也是JavaScript中常用的异步处理方式。许多Node.js模块(如nethttp等)都使用事件驱动的架构。例如,创建一个简单的HTTP服务器:

const http = require('http');

const server = http.createServer((req, res) => {
    res.writeHead(200, {'Content-Type': 'text/plain'});
    res.end('Hello, World!');
});

server.on('error', (err) => {
    console.error('Server error:', err);
});

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

在这个例子中,server.on('error')用于监听服务器在运行过程中可能出现的错误事件,而server.listen的回调函数则在服务器成功启动并开始监听指定端口时被调用。

Promise

Promise是ES6引入的一种更优雅的异步处理方式。它代表一个异步操作的最终完成(或失败)及其结果值。Promise有三种状态:pending(进行中)、fulfilled(已成功)和rejected(已失败)。一旦状态改变,就不会再变。例如,将前面的文件读取操作使用Promise封装:

const fs = require('fs');
const { promisify } = require('util');

const readFileAsync = promisify(fs.readFile);

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

这里使用util.promisify将Node.js的基于回调的fs.readFile函数转换为返回Promise的函数。then方法用于处理Promise成功的情况,catch方法用于捕获Promise失败时的错误。

async/await

async/await是ES2017引入的异步语法糖,它基于Promise,使得异步代码看起来更像同步代码。async函数总是返回一个Promise,而await只能在async函数内部使用,用于暂停函数执行,等待Promise解决(resolved)或拒绝(rejected)。例如:

const fs = require('fs');
const { promisify } = require('util');

const readFileAsync = promisify(fs.readFile);

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

readFileContent();

在上述代码中,readFileContent是一个async函数,其中await readFileAsync('example.txt', 'utf8')暂停函数执行,直到文件读取操作完成,然后将读取到的数据赋值给data变量。try...catch块用于捕获可能发生的错误。

Node.js中的异步特性

Node.js以其异步I/O模型而闻名,这使得它非常适合构建高性能、可扩展的网络应用程序。Node.js的异步特性主要体现在以下几个方面:

非阻塞I/O

Node.js的核心是基于事件驱动和非阻塞I/O。当一个I/O操作(如读取文件、写入数据库、发起网络请求等)被调用时,Node.js不会等待操作完成,而是继续执行后续代码。当I/O操作完成后,通过事件循环将结果通知给应用程序。例如,在处理多个并发的HTTP请求时,Node.js可以在等待一个请求的响应时,继续处理其他请求,从而提高了系统的整体吞吐量。

事件循环(Event Loop)

事件循环是Node.js实现异步的关键机制。它是一个持续运行的循环,不断检查事件队列中是否有事件需要处理。当有异步操作完成(如I/O操作、定时器到期等)时,相关的回调函数会被放入事件队列。事件循环每次迭代时,会从事件队列中取出一个事件(回调函数)并执行。以下是事件循环的简化工作流程:

  1. 执行栈(Call Stack):JavaScript代码在执行栈中执行。当一个函数被调用时,它会被压入执行栈;当函数执行完毕,它会从执行栈中弹出。
  2. 任务队列(Task Queue):异步操作完成后,其回调函数会被放入任务队列。任务队列分为宏任务队列(Macro Task Queue)和微任务队列(Micro Task Queue)。常见的宏任务包括setTimeoutsetInterval、I/O操作等;常见的微任务包括Promise的then回调、process.nextTick等。
  3. 事件循环迭代:事件循环不断检查执行栈是否为空。如果执行栈为空,它会从宏任务队列中取出一个宏任务放入执行栈执行。在一个宏任务执行完毕后,事件循环会清空微任务队列中的所有微任务,然后再进行下一次迭代,从宏任务队列中取出下一个宏任务。

多线程与单线程

Node.js是单线程的,这意味着它在一个进程中只有一个主线程来执行JavaScript代码。然而,这并不意味着Node.js不能利用多核CPU的优势。Node.js通过cluster模块实现了多进程架构,每个进程可以处理独立的请求,从而充分利用多核CPU的资源。此外,Node.js内部的一些I/O操作(如文件系统操作、网络操作等)实际上是由底层的C/C++库通过多线程或异步I/O机制来完成的,这使得Node.js能够在单线程的环境下高效地处理大量的并发请求。

利用Node实现默认异步的技巧

使用原生异步模块

Node.js提供了许多原生的异步模块,如fs(文件系统)、http(HTTP服务器)、net(网络)等。这些模块的方法大多数都是异步的,默认不会阻塞主线程。例如,在处理文件上传时,可以使用fs.createWriteStream来异步写入文件:

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

const server = http.createServer((req, res) => {
    if (req.method === 'POST' && req.url === '/upload') {
        const writeStream = fs.createWriteStream('uploadedFile.txt');
        req.pipe(writeStream);

        writeStream.on('finish', () => {
            res.writeHead(200, {'Content-Type': 'text/plain'});
            res.end('File uploaded successfully');
        });
    } else {
        res.writeHead(404, {'Content-Type': 'text/plain'});
        res.end('Not Found');
    }
});

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

在上述代码中,fs.createWriteStream创建了一个异步写入流,req.pipe(writeStream)将HTTP请求的数据流直接管道到文件写入流,这样数据就可以异步地写入文件,而不会阻塞主线程处理其他请求。

promisify 转换

对于一些基于回调的异步函数,我们可以使用util.promisify将其转换为返回Promise的函数,从而更方便地使用async/await语法。除了前面提到的fs.readFile,再比如setTimeout函数,虽然它本身不是Node.js特有的,但可以通过类似的方式进行封装:

const { promisify } = require('util');
const setTimeoutPromise = promisify(setTimeout);

async function delayAndLog() {
    await setTimeoutPromise(2000);
    console.log('Delayed for 2 seconds');
}

delayAndLog();

在这个例子中,promisify(setTimeout)setTimeout函数转换为返回Promise的函数setTimeoutPromise。在delayAndLog函数中,使用await setTimeoutPromise(2000)暂停函数执行2秒钟,然后再打印日志。

利用 async_hooks 模块

async_hooks模块是Node.js提供的一个用于跟踪异步资源生命周期的模块。它可以帮助我们深入了解异步操作的执行过程,对于优化和调试异步代码非常有帮助。例如,我们可以使用async_hooks来记录某个异步操作从开始到结束的时间:

const asyncHooks = require('async_hooks');
const fs = require('fs');
const { promisify } = require('util');

const readFileAsync = promisify(fs.readFile);

const asyncHook = asyncHooks.createHook({
    init(asyncId, type, triggerAsyncId) {
        if (type === 'FSReqWrap') {
            console.log(`Async operation started: ${type}, asyncId: ${asyncId}, triggered by: ${triggerAsyncId}`);
        }
    },
    destroy(asyncId) {
        console.log(`Async operation ended: asyncId: ${asyncId}`);
    }
});

asyncHook.enable();

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

readFileContent();

在上述代码中,async_hooks.createHook创建了一个异步钩子,通过init方法在异步操作开始时打印相关信息,通过destroy方法在异步操作结束时打印信息。这里主要关注FSReqWrap类型的异步操作,它对应文件系统相关的异步操作。

控制异步并发

在实际应用中,我们经常需要控制异步操作的并发数量,以避免资源耗尽或性能问题。例如,假设有一组URL需要同时发起HTTP请求获取数据,但为了防止对服务器造成过大压力,我们希望同时并发的请求数量不超过一定限制。可以使用async/await结合队列来实现:

const http = require('http');
const { promisify } = require('util');

const requestAsync = promisify((options, callback) => {
    const req = http.request(options, callback);
    req.end();
});

async function fetchUrls(urls, maxConcurrent) {
    let results = [];
    let queue = [...urls];
    let activeCount = 0;

    async function processQueue() {
        while (queue.length > 0 && activeCount < maxConcurrent) {
            activeCount++;
            const url = queue.shift();
            try {
                const res = await requestAsync({
                    method: 'GET',
                    host: new URL(url).host,
                    path: new URL(url).pathname
                });
                results.push(res);
            } catch (err) {
                console.error(`Error fetching ${url}:`, err);
            } finally {
                activeCount--;
                await processQueue();
            }
        }
    }

    await processQueue();
    return results;
}

const urls = [
    'http://example.com',
    'http://example.org',
    'http://example.net'
];

fetchUrls(urls, 2).then(results => {
    console.log('All requests completed:', results);
});

在上述代码中,fetchUrls函数接受一个URL数组和最大并发数maxConcurrent。通过一个队列queue来管理待处理的URL,使用activeCount来记录当前正在进行的请求数量。processQueue函数递归地从队列中取出URL并发起请求,当有请求完成时,无论成功与否,都会减少activeCount并继续处理队列中的下一个URL,直到队列为空。

处理异步错误

在异步编程中,错误处理至关重要。使用async/await时,可以通过try...catch块来捕获异步操作中抛出的错误。然而,对于一些全局的异步错误,比如未处理的Promise拒绝,可以通过process.on('unhandledRejection')来捕获。例如:

process.on('unhandledRejection', (reason, promise) => {
    console.log('Unhandled Rejection at:', promise, 'reason:', reason);
});

async function asyncFunction() {
    throw new Error('Async operation failed');
}

asyncFunction().catch(err => {
    console.error('Caught in local catch block:', err);
});

在上述代码中,asyncFunction抛出一个错误,通过局部的catch块可以捕获到这个错误并打印日志。同时,如果没有局部的catch块,未处理的Promise拒绝会被process.on('unhandledRejection')捕获并打印相关信息,这有助于我们及时发现和处理异步操作中未处理的错误。

性能优化与最佳实践

合理使用异步操作

虽然异步操作可以提高应用程序的性能和响应性,但并不是所有场景都适合使用异步。对于一些非常短的同步操作,使用异步可能会引入额外的开销。例如,简单的数学计算或字符串处理,直接在主线程中同步执行可能更高效。只有在涉及I/O操作、网络请求等可能会阻塞主线程的操作时,才应该使用异步。

避免过度嵌套

在使用回调函数进行异步编程时,很容易出现回调地狱(Callback Hell),即多层嵌套的回调函数使得代码难以阅读和维护。使用Promise或async/await可以有效地避免这种情况。例如,以下是一个回调地狱的示例:

const fs = require('fs');

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

使用Promise改写后:

const fs = require('fs');
const { promisify } = require('util');

const readFileAsync = promisify(fs.readFile);

Promise.all([
    readFileAsync('file1.txt', 'utf8'),
    readFileAsync('file2.txt', 'utf8'),
    readFileAsync('file3.txt', 'utf8')
])
   .then(([data1, data2, data3]) => {
        console.log(data1, data2, data3);
    })
   .catch(err => {
        console.error(err);
    });

通过Promise.all可以将多个异步操作并行执行,并在所有操作完成后统一处理结果,代码结构更加清晰。

优化事件循环

事件循环的性能直接影响应用程序的整体性能。为了优化事件循环,应尽量减少在事件循环中执行的同步代码的时间。避免在事件处理函数中执行长时间运行的同步操作,例如复杂的计算或大量的I/O操作。如果确实需要进行这些操作,可以考虑将其分解为多个小的操作,或者使用Web Workers(在浏览器环境中)或子进程(在Node.js环境中)来执行。

监控和调优异步性能

Node.js提供了一些工具来监控和调优异步性能,如node --prof命令可以生成性能分析报告。此外,一些第三方工具如Node.js Inspector也可以帮助我们深入分析异步代码的性能瓶颈。通过分析性能数据,我们可以找出哪些异步操作耗时较长,是否存在不必要的异步开销等问题,并针对性地进行优化。

在实际应用中,还需要根据具体的业务场景和性能需求,灵活运用上述技巧和最佳实践,以实现高效、稳定的JavaScript应用程序。通过合理利用Node.js的异步特性,我们可以充分发挥JavaScript在构建高性能网络应用方面的潜力。