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

JavaScript在Node编程中的异步控制流

2022-07-102.1k 阅读

异步编程在Node中的重要性

在Node.js编程环境中,异步操作占据着核心地位。Node.js之所以能够高效地处理大量并发请求,正是得益于其基于事件驱动、非阻塞I/O的架构设计,而异步编程则是实现这一架构的关键。

传统的同步编程模式在执行I/O操作(如读取文件、网络请求等)时,会阻塞线程,导致后续代码无法执行,直到I/O操作完成。这在处理高并发场景时效率极低,因为大部分时间线程都处于等待I/O操作完成的状态。而Node.js的异步编程模型允许在执行I/O操作时,主线程不会被阻塞,而是继续执行后续代码,当I/O操作完成后,通过回调函数或其他异步机制来处理结果。

例如,在同步读取文件的情况下:

const fs = require('fs');
const data = fs.readFileSync('example.txt', 'utf8');
console.log(data);
console.log('继续执行后续代码');

在这个例子中,readFileSync是同步读取文件的方法,在文件读取完成之前,console.log('继续执行后续代码')这行代码不会执行。如果文件读取时间较长,整个程序的执行就会被阻塞。

而使用异步读取文件的方式:

const fs = require('fs');
fs.readFile('example.txt', 'utf8', (err, data) => {
    if (err) {
        console.error(err);
        return;
    }
    console.log(data);
});
console.log('继续执行后续代码');

这里readFile是异步读取文件的方法,在调用readFile后,主线程不会等待文件读取完成,而是直接执行console.log('继续执行后续代码')。当文件读取完成后,会调用回调函数来处理读取到的数据。这种异步处理方式大大提高了程序的执行效率,尤其在处理大量I/O操作时表现得更为明显。

JavaScript异步控制流的基本概念

回调函数

回调函数是JavaScript中实现异步操作最基本的方式。当一个异步操作完成时,会调用事先传入的回调函数,并将操作结果(或错误)作为参数传递给回调函数。

例如,前面提到的文件读取操作:

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

这里(err, data) => {...}就是回调函数,err表示可能出现的错误,data表示读取到的数据。

然而,当存在多个异步操作,且一个操作依赖于另一个操作的结果时,回调函数会导致代码变得复杂,形成所谓的“回调地狱”。

例如,假设有三个异步操作,依次读取三个文件,并将它们的内容按顺序处理:

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

为了解决回调地狱的问题,Promise应运而生。Promise是一个表示异步操作最终完成(或失败)及其结果的对象。

一个Promise有三种状态:

  1. pending(进行中):初始状态,既不是成功,也不是失败状态。
  2. fulfilled(已成功):意味着操作成功完成,此时Promise有一个值(resolved value)。
  3. rejected(已失败):意味着操作失败,此时Promise有一个原因(rejection reason)。

Promise一旦从pending状态转变为fulfilledrejected状态,就不会再改变。

使用Promise重写前面读取三个文件的例子:

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

const readFileAsync = promisify(fs.readFile);

readFileAsync('file1.txt', 'utf8')
   .then(data1 => {
        return readFileAsync('file2.txt', 'utf8').then(data2 => {
            return readFileAsync('file3.txt', 'utf8').then(data3 => {
                return data1 + data2 + data3;
            });
        });
    })
   .then(result => {
        console.log(result);
    })
   .catch(err => {
        console.error(err);
    });

这里使用了util.promisify方法将Node.js的回调风格的函数fs.readFile转换为返回Promise的函数readFileAsync。通过.then方法来处理Promise成功的情况,通过.catch方法来捕获Promise失败的错误。这种链式调用的方式使得代码的可读性大大提高,避免了回调地狱。

async/await

async/await是基于Promise的更高级的异步处理语法糖,它使得异步代码看起来更像同步代码。

async函数是一个返回Promise的函数,在函数内部可以使用await关键字来暂停函数的执行,等待一个Promise被解决(resolved)或被拒绝(rejected)。

使用async/await重写前面的例子:

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

const readFileAsync = promisify(fs.readFile);

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

readFiles();

readFiles这个async函数中,通过await依次等待每个文件读取操作完成,代码结构简洁明了,就像在写同步代码一样,同时又保持了异步操作的特性。

常见的异步控制流模式

串行执行

串行执行是指多个异步操作按照顺序依次执行,前一个操作完成后才开始下一个操作。前面使用回调函数、Promise和async/await实现的依次读取三个文件的例子就是串行执行的模式。

使用async/await实现更通用的串行执行多个异步任务的函数:

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

async function serialTasks(tasks) {
    let result = [];
    for (let task of tasks) {
        let taskResult = await task();
        result.push(taskResult);
    }
    return result;
}

async function task1() {
    await delay(1000);
    return '任务1完成';
}

async function task2() {
    await delay(1500);
    return '任务2完成';
}

async function task3() {
    await delay(2000);
    return '任务3完成';
}

serialTasks([task1, task2, task3]).then(results => {
    console.log(results);
});

在这个例子中,serialTasks函数接收一个包含多个异步任务(返回Promise的函数)的数组,通过for...of循环依次执行每个任务,并将任务结果收集到result数组中。delay函数用于模拟异步延迟。

并行执行

并行执行是指多个异步操作同时开始执行,而不需要等待前一个操作完成。当所有操作都完成后,再处理最终结果。

使用Promise.all实现并行执行多个异步任务:

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

function task1() {
    return delay(1000).then(() => '任务1完成');
}

function task2() {
    return delay(1500).then(() => '任务2完成');
}

function task3() {
    return delay(2000).then(() => '任务3完成');
}

Promise.all([task1(), task2(), task3()])
   .then(results => {
        console.log(results);
    })
   .catch(err => {
        console.error(err);
    });

Promise.all接收一个Promise数组,当所有Promise都被解决(resolved)时,返回一个新的Promise,其值为所有Promise解决值组成的数组。如果其中任何一个Promise被拒绝(rejected),Promise.all返回的Promise也会被拒绝。

并发控制

并发控制是在并行执行的基础上,限制同时执行的异步操作数量。这在处理大量异步任务时非常有用,可以避免资源耗尽。

使用async/await和队列实现并发控制:

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

async function task(id) {
    await delay(1000);
    console.log(`任务 ${id} 完成`);
    return `任务 ${id} 的结果`;
}

async function concurrentTasks(tasks, maxConcurrent) {
    let queue = [...tasks];
    let results = [];
    let running = 0;

    async function processTask() {
        if (queue.length === 0 && running === 0) {
            return;
        }
        running++;
        let task = queue.shift();
        try {
            let result = await task();
            results.push(result);
        } catch (err) {
            console.error(err);
        } finally {
            running--;
            await processTask();
        }
    }

    for (let i = 0; i < maxConcurrent; i++) {
        await processTask();
    }

    return results;
}

let taskList = Array.from({ length: 5 }, (_, i) => () => task(i + 1));
concurrentTasks(taskList, 3).then(results => {
    console.log(results);
});

在这个例子中,concurrentTasks函数接收一个异步任务数组和最大并发数maxConcurrent。通过维护一个任务队列queue和当前正在运行的任务数running,每次从队列中取出一个任务执行,当有任务完成时,再从队列中取出新的任务执行,直到队列为空且所有任务都完成。

异步操作的错误处理

在异步编程中,错误处理至关重要。对于回调函数,通常将错误作为第一个参数传递给回调函数,在回调函数内部进行处理。

例如:

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

对于Promise,通过.catch方法捕获Promise链中任何一个被拒绝的Promise。

例如:

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

const readFileAsync = promisify(fs.readFile);

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

在async/await中,可以使用try...catch块来捕获错误。

例如:

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

const readFileAsync = promisify(fs.readFile);

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

readFile();

在处理多个异步操作时,错误处理需要更加谨慎。比如在Promise.all中,如果任何一个Promise被拒绝,Promise.all返回的Promise也会被拒绝,通过.catch捕获的错误就是第一个被拒绝的Promise的错误。

例如:

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

function task2() {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            reject(new Error('任务2失败'));
        }, 1500);
    });
}

function task3() {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            resolve('任务3完成');
        }, 2000);
    });
}

Promise.all([task1(), task2(), task3()])
   .then(results => {
        console.log(results);
    })
   .catch(err => {
        console.error(err);
    });

在这个例子中,task2被拒绝,Promise.all返回的Promise也被拒绝,.catch捕获到的错误就是task2的拒绝原因。

实际应用场景中的异步控制流

爬虫应用

在网络爬虫应用中,需要从多个网页中获取数据。每个网页的请求和数据提取都是异步操作。

假设要从多个URL中获取网页内容:

const axios = require('axios');
const cheerio = require('cheerio');

async function fetchPage(url) {
    try {
        let response = await axios.get(url);
        let $ = cheerio.load(response.data);
        let title = $('title').text();
        return title;
    } catch (err) {
        console.error(err);
        return null;
    }
}

async function crawlPages(urls) {
    let results = [];
    for (let url of urls) {
        let title = await fetchPage(url);
        if (title) {
            results.push({ url, title });
        }
    }
    return results;
}

let urls = [
    'https://example.com',
    'https://another-example.com',
    'https://third-example.com'
];

crawlPages(urls).then(results => {
    console.log(results);
});

在这个例子中,fetchPage函数用于从单个URL获取网页内容并提取标题,crawlPages函数通过串行方式依次处理每个URL。如果需要并行处理,可以使用Promise.all

文件处理

在处理大量文件时,可能需要读取多个文件的内容,进行一些处理后再写入新的文件。

例如,读取多个文本文件的内容,将它们合并并写入一个新文件:

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

const readFileAsync = promisify(fs.readFile);
const writeFileAsync = promisify(fs.writeFile);

async function readFiles(files) {
    let dataPromises = files.map(file => readFileAsync(file, 'utf8'));
    let data = await Promise.all(dataPromises);
    let combinedData = data.join('');
    await writeFileAsync('combined.txt', combinedData);
    console.log('文件合并完成');
}

let files = ['file1.txt', 'file2.txt', 'file3.txt'];
readFiles(files);

这里使用Promise.all并行读取多个文件,然后将读取到的数据合并并写入新文件。

数据库操作

在Node.js应用中与数据库交互时,也经常涉及异步控制流。比如在一个用户注册功能中,需要先检查用户名是否已存在,然后再插入新用户数据。

假设使用MySQL数据库,使用mysql2库:

const mysql = require('mysql2');

const connection = mysql.createConnection({
    host: 'localhost',
    user: 'root',
    password: 'password',
    database: 'test'
});

const queryAsync = promisify(connection.query.bind(connection));

async function registerUser(username, password) {
    try {
        let result = await queryAsync('SELECT * FROM users WHERE username =?', [username]);
        if (result.length > 0) {
            throw new Error('用户名已存在');
        }
        await queryAsync('INSERT INTO users (username, password) VALUES (?,?)', [username, password]);
        console.log('用户注册成功');
    } catch (err) {
        console.error(err);
    } finally {
        connection.end();
    }
}

registerUser('newuser', 'newpassword');

在这个例子中,先通过queryAsync异步查询数据库检查用户名是否存在,若不存在则插入新用户数据。通过try...catch处理可能出现的错误,并在最后关闭数据库连接。

通过合理运用异步控制流,开发者可以在Node.js应用中高效地处理各种复杂的异步场景,提升应用的性能和稳定性。无论是串行、并行还是并发控制,以及正确的错误处理,都是构建健壮的Node.js应用的关键要素。在实际开发中,需要根据具体的业务需求和系统资源情况,选择最合适的异步控制流模式。