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

JavaScript事件循环与异步编程实践

2021-03-244.6k 阅读

JavaScript 事件循环与异步编程基础

理解 JavaScript 的单线程特性

JavaScript 是一门单线程语言,这意味着它在同一时间只能执行一个任务。这个特性是由其设计初衷决定的,JavaScript 最初被设计用于在浏览器中与用户进行交互,处理 DOM 操作等。如果它是多线程的,在多个线程同时操作 DOM 时,就会产生竞态条件(race condition),导致难以预测的结果。例如:

// 假设这是两个线程同时执行的代码
let num = 0;
// 线程 1
function increment1() {
    num++;
}
// 线程 2
function increment2() {
    num++;
}
// 如果是多线程,这两个操作可能会在同一时间对 num 进行操作,
// 由于 CPU 时间片分配的不确定性,最终 num 的值可能不是 2

为了避免这种混乱,JavaScript 采用了单线程模型。然而,这也带来了一个问题,如果一个任务执行时间过长,就会阻塞后续任务的执行,导致页面假死(在浏览器环境中)。例如,以下代码会阻塞线程:

function longRunningTask() {
    let sum = 0;
    for (let i = 0; i < 1000000000; i++) {
        sum += i;
    }
    console.log(sum);
}
// 调用这个函数会阻塞后续代码执行
longRunningTask();
console.log('This will be printed after the long running task is finished');

为了解决这个问题,JavaScript 引入了异步编程机制。

异步编程的概念

异步编程允许 JavaScript 在执行某个耗时任务时,不阻塞主线程,而是继续执行后续代码。当这个耗时任务完成后,再通过回调函数等方式通知主线程执行相应的处理。常见的异步任务包括网络请求(如 AJAX)、文件读取(在 Node.js 环境中)、定时器(setTimeoutsetInterval)等。例如,使用 setTimeout 来模拟一个异步任务:

console.log('Start');
setTimeout(() => {
    console.log('This is a delayed message');
}, 1000);
console.log('End');
// 输出结果:
// Start
// End
// This is a delayed message (1 秒后输出)

在上述代码中,setTimeout 会将其回调函数放入一个队列中,而不会阻塞主线程。主线程继续执行后续的 console.log('End'),当 setTimeout 设置的延迟时间到达后,其回调函数才会被执行。

事件循环机制

事件循环(Event Loop)是 JavaScript 实现异步编程的核心机制。它就像一个循环监视器,不断地检查调用栈(Call Stack)和任务队列(Task Queue)。当调用栈为空时,事件循环会从任务队列中取出一个任务(回调函数)放入调用栈中执行。如此反复,直到任务队列也为空。

  1. 调用栈:调用栈是一个存储函数调用的栈结构。当一个函数被调用时,它会被压入调用栈;当函数执行完毕,它会从调用栈中弹出。例如:
function add(a, b) {
    return a + b;
}
function multiply(a, b) {
    let result = add(a, b);
    return result * 2;
}
// 调用 multiply 函数
multiply(2, 3);
// 调用栈变化:
// 1. 调用 multiply(2, 3),multiply 函数压入调用栈
// 2. 在 multiply 函数中调用 add(2, 3),add 函数压入调用栈
// 3. add 函数执行完毕返回结果 5,add 函数从调用栈弹出
// 4. multiply 函数继续执行并返回结果 10,multiply 函数从调用栈弹出
  1. 任务队列:任务队列也称为消息队列,用于存放异步任务的回调函数。当一个异步任务完成时,其回调函数会被放入任务队列。例如,setTimeout 的回调函数会在延迟时间到达后被放入任务队列。
  2. 事件循环过程:事件循环不断地重复以下步骤:
    • 检查调用栈是否为空。如果为空,则进入下一步;否则,继续执行调用栈中的函数。
    • 检查任务队列是否有任务。如果有,则将任务队列中的第一个任务(回调函数)取出并压入调用栈,然后执行该函数;如果任务队列为空,则继续等待。
console.log('Initial log');
setTimeout(() => {
    console.log('Timeout callback');
}, 0);
console.log('Final log');
// 输出结果:
// Initial log
// Final log
// Timeout callback
// 解释:
// 1. 首先输出 'Initial log'
// 2. setTimeout 的回调函数被放入任务队列,由于延迟时间为 0,
// 但此时调用栈不为空(还有后续代码),所以不会立即执行
// 3. 输出 'Final log',此时调用栈为空
// 4. 事件循环从任务队列中取出 setTimeout 的回调函数放入调用栈并执行,输出 'Timeout callback'

异步编程的实现方式

回调函数

回调函数是 JavaScript 中最基本的异步编程方式。当一个异步操作完成时,会调用事先传入的回调函数。例如,使用 fs 模块(在 Node.js 中)读取文件:

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

在上述代码中,fs.readFile 是一个异步操作,它接收一个回调函数作为参数。当文件读取完成后,会调用这个回调函数,并将错误(如果有)和读取到的数据作为参数传递给回调函数。

然而,回调函数在处理多个异步操作时,容易出现回调地狱(Callback Hell)的问题。例如,假设我们需要依次读取三个文件:

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

这种层层嵌套的代码结构可读性和维护性都很差,这就是回调地狱的典型表现。

Promise

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

  1. 创建 Promise:可以使用 new Promise 来创建一个 Promise 对象,它接收一个执行器函数,该函数包含 resolvereject 两个参数。例如:
const promise = new Promise((resolve, reject) => {
    setTimeout(() => {
        const success = true;
        if (success) {
            resolve('Operation successful');
        } else {
            reject('Operation failed');
        }
    }, 1000);
});
  1. 处理 Promise:可以使用 .then() 方法来处理 Promise 成功的情况,使用 .catch() 方法来处理 Promise 失败的情况。例如:
promise.then((result) => {
    console.log(result); // 输出 'Operation successful'
}).catch((error) => {
    console.error(error);
});
  1. 链式调用:Promise 最大的优势之一是可以进行链式调用,这使得多个异步操作可以按顺序执行,而不会出现回调地狱的问题。例如,假设我们需要依次读取三个文件,使用 Promise 可以这样实现:
const fs = require('fs');
const { promisify } = require('util');
const readFile = promisify(fs.readFile);
readFile('file1.txt', 'utf8')
   .then((data1) => {
        console.log(data1);
        return readFile('file2.txt', 'utf8');
    })
   .then((data2) => {
        console.log(data2);
        return readFile('file3.txt', 'utf8');
    })
   .then((data3) => {
        console.log(data3);
    })
   .catch((err) => {
        console.error(err);
    });

在上述代码中,promisify 函数将 fs.readFile 这个基于回调的函数转换为返回 Promise 的函数。每个 .then() 方法返回的新 Promise 会等待前一个 Promise 完成后才执行,从而实现了顺序执行多个异步操作。

async/await

async/await 是在 Promise 的基础上进一步优化的异步编程语法糖。它使得异步代码看起来更像同步代码,大大提高了代码的可读性。

  1. async 函数:使用 async 关键字定义的函数会返回一个 Promise 对象。例如:
async function asyncFunction() {
    return 'Hello, async!';
}
const result = asyncFunction();
result.then((value) => {
    console.log(value); // 输出 'Hello, async!'
});

在上述代码中,asyncFunction 函数虽然看起来像同步函数,但实际上返回的是一个 Promise 对象。

  1. await 关键字await 只能在 async 函数内部使用,它用于暂停 async 函数的执行,等待一个 Promise 对象解决(resolved)或被拒绝(rejected)。例如,使用 async/await 来依次读取三个文件:
const fs = require('fs');
const { promisify } = require('util');
const readFile = promisify(fs.readFile);
async function readFiles() {
    try {
        const data1 = await readFile('file1.txt', 'utf8');
        console.log(data1);
        const data2 = await readFile('file2.txt', 'utf8');
        console.log(data2);
        const data3 = await readFile('file3.txt', 'utf8');
        console.log(data3);
    } catch (err) {
        console.error(err);
    }
}
readFiles();

在上述代码中,await 使得 readFile 操作等待前一个操作完成后再执行,代码结构更加清晰,就像同步代码一样。

深入事件循环:宏任务与微任务

宏任务(Macrotask)

宏任务是事件循环中的一类任务,常见的宏任务包括 setTimeoutsetIntervalsetImmediate(在 Node.js 中)、I/O 操作、script(整体代码块)等。事件循环每次从任务队列(宏任务队列)中取出一个宏任务执行,当该宏任务执行完毕后,会检查微任务队列并执行所有微任务,然后再从宏任务队列中取下一个宏任务。例如:

console.log('Global code');
setTimeout(() => {
    console.log('Timeout callback');
});
Promise.resolve().then(() => {
    console.log('Promise then callback');
});
console.log('End of global code');
// 输出结果:
// Global code
// End of global code
// Promise then callback
// Timeout callback
// 解释:
// 1. 首先执行整体代码块(宏任务)中的 'Global code' 和 'End of global code'
// 2. Promise.resolve().then() 的回调函数放入微任务队列
// 3. setTimeout 的回调函数放入宏任务队列
// 4. 整体代码块(宏任务)执行完毕后,检查微任务队列,执行 'Promise then callback'
// 5. 微任务队列执行完毕后,从宏任务队列中取出 setTimeout 的回调函数并执行,输出 'Timeout callback'

微任务(Microtask)

微任务也是事件循环中的任务类型,与宏任务不同的是,微任务的执行优先级更高。常见的微任务包括 Promise.then()MutationObserver(在浏览器环境中用于监听 DOM 变化)等。当一个宏任务执行完毕后,事件循环会立即执行微任务队列中的所有微任务,直到微任务队列为空,然后才会去处理下一个宏任务。例如:

console.log('Start');
setTimeout(() => {
    console.log('Timeout');
    Promise.resolve().then(() => {
        console.log('Timeout - Promise then');
    });
}, 0);
Promise.resolve().then(() => {
    console.log('Global - Promise then');
});
console.log('End');
// 输出结果:
// Start
// End
// Global - Promise then
// Timeout
// Timeout - Promise then
// 解释:
// 1. 首先输出 'Start' 和 'End'
// 2. setTimeout 的回调函数放入宏任务队列,Promise.resolve().then() 的回调函数放入微任务队列
// 3. 整体代码块(宏任务)执行完毕后,检查微任务队列,执行 'Global - Promise then'
// 4. 微任务队列执行完毕后,从宏任务队列中取出 setTimeout 的回调函数并执行,输出 'Timeout'
// 5. 在 setTimeout 的回调函数中,又有一个 Promise.resolve().then() 的回调函数放入微任务队列
// 6. setTimeout 的回调函数执行完毕后,再次检查微任务队列,执行 'Timeout - Promise then'

宏任务与微任务的应用场景

  1. 宏任务:常用于处理一些比较耗时的异步操作,如 I/O 操作、定时器等。这些操作可能会阻塞主线程一段时间,但通过宏任务队列的机制,可以避免阻塞其他任务的执行。
  2. 微任务:常用于需要在当前任务(宏任务)执行完毕后立即执行的操作,并且这些操作不希望被其他宏任务打断。例如,在 DOM 变化后立即执行一些逻辑,使用 MutationObserver(微任务)可以保证在 DOM 变化后尽快执行,而不会被其他宏任务(如 setTimeout 回调)插入执行。

异步编程实践中的常见问题与解决方案

并发与并行

在异步编程中,需要区分并发(Concurrency)和并行(Parallelism)的概念。

  1. 并发:指的是在同一时间段内,多个任务交替执行,但在同一时刻只有一个任务在执行。JavaScript 通过事件循环实现并发,虽然它是单线程的,但可以通过异步任务队列来交替执行不同的任务,从而给人一种多个任务同时执行的错觉。例如,同时发起多个 AJAX 请求,这些请求会异步执行,在事件循环的调度下,它们的回调函数会在合适的时机被执行。
  2. 并行:指的是在同一时刻,多个任务真正地同时执行。这通常需要多核 CPU 的支持,每个任务可以在不同的 CPU 核心上并行运行。JavaScript 本身是单线程的,无法直接实现并行,但在 Node.js 环境中,可以通过 cluster 模块利用多核 CPU 实现并行计算。例如,以下是一个简单的 cluster 模块使用示例:
const cluster = require('cluster');
const http = require('http');
const numCPUs = require('os').cpus().length;
if (cluster.isMaster) {
    console.log(`Master ${process.pid} is running`);
    for (let i = 0; i < numCPUs; i++) {
        cluster.fork();
    }
    cluster.on('exit', (worker, code, signal) => {
        console.log(`worker ${worker.process.pid} died`);
    });
} else {
    http.createServer((req, res) => {
        res.writeHead(200);
        res.end('Hello World\n');
    }).listen(8000);
    console.log(`Worker ${process.pid} started`);
}

在上述代码中,cluster 模块允许主进程(Master)创建多个工作进程(Worker),每个工作进程可以独立处理 HTTP 请求,从而实现并行处理。

错误处理

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

  1. 回调函数的错误处理:在回调函数中,通常将错误作为第一个参数传递。例如:
const fs = require('fs');
fs.readFile('nonexistent.txt', 'utf8', (err, data) => {
    if (err) {
        console.error(err);
        return;
    }
    console.log(data);
});
  1. Promise 的错误处理:Promise 可以使用 .catch() 方法来捕获整个 Promise 链中的错误。例如:
const fs = require('fs');
const { promisify } = require('util');
const readFile = promisify(fs.readFile);
readFile('nonexistent.txt', 'utf8')
   .then((data) => {
        console.log(data);
    })
   .catch((err) => {
        console.error(err);
    });
  1. async/await 的错误处理:在 async 函数中,可以使用 try...catch 块来捕获错误。例如:
const fs = require('fs');
const { promisify } = require('util');
const readFile = promisify(fs.readFile);
async function readFiles() {
    try {
        const data = await readFile('nonexistent.txt', 'utf8');
        console.log(data);
    } catch (err) {
        console.error(err);
    }
}
readFiles();

性能优化

在异步编程中,性能优化也是需要考虑的重要方面。

  1. 合理使用定时器:避免设置过小的 setTimeoutsetInterval 延迟时间,因为频繁的任务调度会增加事件循环的负担。例如,如果不需要非常精确的定时任务,可以适当增大延迟时间。
  2. 减少微任务的使用:虽然微任务执行优先级高,但过多的微任务会导致宏任务执行延迟。在不必要的情况下,尽量使用宏任务。
  3. 并行处理:在 Node.js 环境中,对于计算密集型任务,可以使用 cluster 模块实现并行处理,提高性能。

总结

JavaScript 的事件循环和异步编程是其核心特性,通过合理运用回调函数、Promise、async/await 等异步编程方式,以及深入理解宏任务和微任务的执行机制,可以编写出高效、可靠的异步代码。在实际开发中,需要根据具体的应用场景选择合适的异步编程方式,并注意并发、错误处理和性能优化等问题,以提供更好的用户体验和系统性能。希望通过本文的介绍,读者能够对 JavaScript 事件循环与异步编程有更深入的理解和实践能力。