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

JavaScript异步编程深度解析

2021-07-045.4k 阅读

1. 异步编程的背景与概念

在JavaScript中,理解异步编程是非常关键的,特别是随着Web应用变得越来越复杂,处理I/O操作、网络请求、DOM渲染等任务时,异步编程能让程序更加高效和响应迅速。

JavaScript是单线程语言,这意味着它在同一时间只能执行一个任务。如果有一个耗时很长的操作,比如读取一个大文件或者进行一个长时间的网络请求,在这个操作完成之前,后续的代码都无法执行,页面也会处于卡顿状态,用户体验极差。这就是为什么需要异步编程。

异步操作不会阻塞主线程,它们在后台运行,当操作完成时,通过回调函数、Promise、async/await等机制通知主线程执行相应的后续操作。

1.1 同步与异步的区别

同步操作:就像排队买票,前面的人没买完,后面的人只能等着。在JavaScript中,同步代码按顺序依次执行,前一个任务完成后,下一个任务才开始。例如:

console.log('start');
let result = 1 + 1;
console.log(result);
console.log('end');

上述代码会依次输出 start2end。因为每个操作都是同步的,前一个操作完成后才会执行下一个。

异步操作:想象你去餐厅点餐,点完餐后你不用一直等着餐做好,你可以去做其他事情,等餐好了服务员会通知你。在JavaScript中,异步操作会在后台执行,不会阻塞主线程。例如设置一个定时器:

console.log('start');
setTimeout(() => {
    console.log('timeout');
}, 2000);
console.log('end');

这段代码会先输出 startend,两秒后输出 timeoutsetTimeout 是一个异步操作,它不会阻塞主线程,在设置好定时器后,主线程继续执行后续代码,两秒后定时器触发,回调函数被放入任务队列等待主线程空闲时执行。

2. 回调函数(Callback)

回调函数是JavaScript中实现异步编程最基本的方式。当一个异步操作完成时,会调用事先传入的回调函数。

2.1 简单的回调函数示例

比如读取文件的操作,在Node.js中可以使用 fs 模块:

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

这里 readFile 是一个异步操作,它接收文件名、编码格式以及一个回调函数作为参数。当文件读取完成后,无论成功与否,都会调用这个回调函数。如果读取失败,err 会包含错误信息;如果成功,data 会包含文件内容。

2.2 回调地狱(Callback Hell)

虽然回调函数简单直接,但当有多个异步操作相互依赖时,会出现回调嵌套的情况,代码变得难以阅读和维护,这就是所谓的“回调地狱”。例如:

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应运而生。

3. Promise

Promise是ES6引入的异步编程解决方案,它将异步操作以同步操作的流程表达出来,避免了回调地狱。

3.1 Promise的基本概念

Promise是一个对象,它代表一个异步操作的最终完成(或失败)及其结果值。Promise有三种状态:

  • pending(进行中):初始状态,既不是成功,也不是失败状态。
  • fulfilled(已成功):意味着操作成功完成,Promise会有一个 resolved 值。
  • rejected(已失败):意味着操作失败,Promise会有一个 rejection 原因。

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

3.2 创建Promise

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

这里通过 new Promise 创建了一个Promise对象,传入的回调函数接受 resolvereject 两个参数。resolve 用于将Promise状态变为 fulfilledreject 用于将Promise状态变为 rejected

3.3 处理Promise

可以通过 then 方法来处理Promise的成功和失败情况:

promise.then((value) => {
    console.log(value); // 操作成功
}).catch((error) => {
    console.error(error); // 操作失败
});

then 方法接受两个回调函数,第一个用于处理 fulfilled 状态,第二个(可选)用于处理 rejected 状态。catch 方法是 .then(null, rejection) 的语法糖,专门用于捕获 rejected 状态的错误。

3.4 Promise链式调用

Promise的强大之处在于可以进行链式调用,解决了回调地狱的问题。例如:

function step1() {
    return new Promise((resolve) => {
        setTimeout(() => {
            resolve('step1 result');
        }, 1000);
    });
}
function step2(result) {
    return new Promise((resolve) => {
        setTimeout(() => {
            resolve(result +'-> step2 result');
        }, 1000);
    });
}
function step3(result) {
    return new Promise((resolve) => {
        setTimeout(() => {
            resolve(result +'-> step3 result');
        }, 1000);
    });
}

step1()
   .then(step2)
   .then(step3)
   .then((finalResult) => {
        console.log(finalResult);
    });

这段代码依次执行 step1step2step3 三个异步操作,每个操作的结果作为下一个操作的输入,代码逻辑清晰,避免了回调嵌套。

3.5 Promise.all 和 Promise.race

  • Promise.all:用于将多个Promise实例包装成一个新的Promise实例。新的Promise实例在所有输入的Promise都变为 fulfilled 时才会变为 fulfilled,如果其中有一个变为 rejected,则新的Promise会立即变为 rejected
const promise1 = new Promise((resolve) => {
    setTimeout(() => {
        resolve('promise1 result');
    }, 1000);
});
const promise2 = new Promise((resolve) => {
    setTimeout(() => {
        resolve('promise2 result');
    }, 2000);
});

Promise.all([promise1, promise2])
   .then((results) => {
        console.log(results); // ['promise1 result', 'promise2 result']
    });
  • Promise.race:同样将多个Promise实例包装成一个新的Promise实例。但只要其中一个Promise变为 fulfilledrejected,新的Promise就会变为相同的状态。
const promise3 = new Promise((resolve) => {
    setTimeout(() => {
        resolve('promise3 result');
    }, 3000);
});
const promise4 = new Promise((resolve) => {
    setTimeout(() => {
        resolve('promise4 result');
    }, 1000);
});

Promise.race([promise3, promise4])
   .then((result) => {
        console.log(result); // 'promise4 result'
    });

4. async/await

async/await是ES2017引入的异步编程语法糖,它基于Promise,让异步代码看起来更像同步代码,进一步提高了代码的可读性和可维护性。

4.1 async函数

async 函数是一个异步函数,它返回一个Promise对象。如果函数的返回值不是Promise,会被自动包装成一个已解决的Promise。

async function asyncFunction() {
    return 'async result';
}

asyncFunction().then((value) => {
    console.log(value); // async result
});

这里 asyncFunction 是一个异步函数,它返回的字符串被自动包装成一个已解决的Promise,通过 then 可以获取到返回值。

4.2 await关键字

await 只能在 async 函数内部使用,它用于暂停 async 函数的执行,等待一个Promise被解决(fulfilledrejected),然后返回Promise的解决值(如果是 fulfilled)或抛出错误(如果是 rejected)。

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

async function main() {
    console.log('start');
    await delay(2000);
    console.log('end');
}

main();

main 函数中,await delay(2000) 会暂停函数执行,等待 delay 返回的Promise被解决(两秒后),然后继续执行后续代码,输出 start,两秒后输出 end

4.3 处理错误

使用 try...catch 块可以捕获 await 的Promise被 rejected 时抛出的错误。

async function errorHandling() {
    try {
        await Promise.reject('error');
    } catch (error) {
        console.error(error); // error
    }
}

errorHandling();

在这个例子中,await Promise.reject('error') 会抛出错误,被 catch 块捕获并处理。

4.4 并发与顺序执行

通过 Promise.allasync/await 可以实现并发和顺序执行异步操作。

  • 并发执行
async function concurrent() {
    const promise1 = delay(1000);
    const promise2 = delay(2000);
    await Promise.all([promise1, promise2]);
    console.log('both promises resolved');
}

concurrent();

这里 promise1promise2 会同时开始执行,Promise.all 等待它们都完成后继续执行。

  • 顺序执行
async function sequential() {
    await delay(1000);
    await delay(2000);
    console.log('both delays completed sequentially');
}

sequential();

sequential 函数中,delay(1000) 完成后才会开始 delay(2000),实现了顺序执行。

5. 事件循环(Event Loop)

理解事件循环对于深入掌握JavaScript异步编程至关重要。JavaScript是单线程运行的,但它通过事件循环机制来处理异步操作。

5.1 调用栈(Call Stack)

调用栈是一个存储函数调用的栈结构。当JavaScript执行一个函数时,会将该函数添加到调用栈的顶部;函数执行完毕后,会从调用栈的顶部移除。例如:

function func1() {
    function func2() {
        console.log('func2');
    }
    func2();
    console.log('func1');
}
func1();

在这个例子中,func1 被调用,它被压入调用栈。然后 func2 被调用,也被压入调用栈。func2 执行完毕后从调用栈移除,接着 func1 执行完毕也从调用栈移除。

5.2 任务队列(Task Queue)

任务队列是一个存储异步任务回调函数的队列。当一个异步操作完成(比如定时器到期、网络请求返回等),它的回调函数会被放入任务队列。但这个回调函数不会立即执行,而是等待调用栈为空时,才会被放入调用栈执行。

5.3 事件循环机制

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

  1. 首先,JavaScript引擎执行同步代码,将函数调用依次压入调用栈并执行,直到调用栈为空。
  2. 然后,事件循环检查任务队列。如果任务队列中有任务(回调函数),它会将第一个任务从任务队列中取出并压入调用栈执行。
  3. 调用栈再次为空后,事件循环继续检查任务队列,重复上述过程。

例如,对于下面的代码:

console.log('start');
setTimeout(() => {
    console.log('timeout');
}, 2000);
console.log('end');

一开始,console.log('start')console.log('end') 作为同步代码依次执行,输出 startendsetTimeout 是异步操作,它的回调函数被放入任务队列。两秒后,回调函数准备好,但此时调用栈为空,事件循环将回调函数从任务队列取出放入调用栈,然后执行,输出 timeout

6. 微任务(Microtask)

除了任务队列(也称为宏任务队列),JavaScript还有微任务队列。微任务比宏任务优先级更高。

6.1 微任务示例

Promise.then 的回调函数、MutationObserver 的回调函数等都是微任务。例如:

console.log('start');
Promise.resolve().then(() => {
    console.log('promise then');
});
console.log('end');

这里 Promise.resolve().then 的回调函数是微任务。在同步代码 console.log('start')console.log('end') 执行完毕后,事件循环会先检查微任务队列,发现有 Promise.then 的回调函数,将其放入调用栈执行,输出 startendpromise then

6.2 微任务与宏任务的执行顺序

宏任务和微任务的执行顺序遵循以下规则:

  1. 首先执行同步代码,直到调用栈为空。
  2. 然后,事件循环执行微任务队列中的所有微任务,直到微任务队列为空。
  3. 接着,事件循环从宏任务队列中取出一个宏任务放入调用栈执行,执行完毕后,再次检查并执行微任务队列中的所有微任务。
  4. 重复上述过程,每次宏任务执行完毕后,都会执行微任务队列中的所有微任务。

例如:

console.log('1');
setTimeout(() => {
    console.log('4');
    Promise.resolve().then(() => {
        console.log('5');
    });
}, 0);
Promise.resolve().then(() => {
    console.log('2');
});
console.log('3');

输出结果为 13245。首先执行同步代码 console.log('1')console.log('3')。然后事件循环执行微任务队列中的 Promise.then 回调函数,输出 2。接着 setTimeout 的回调函数作为宏任务被放入调用栈执行,输出 4,在这个宏任务执行过程中,又产生了一个微任务 Promise.then,事件循环在这个宏任务执行完毕后,执行微任务队列中的这个微任务,输出 5

7. 异步编程中的错误处理

在异步编程中,错误处理非常重要,否则可能导致程序崩溃或出现难以调试的问题。

7.1 回调函数中的错误处理

在回调函数中,通常通过第一个参数来传递错误信息。例如:

function asyncOperation(callback) {
    const success = false;
    if (success) {
        callback(null,'success result');
    } else {
        callback(new Error('operation failed'));
    }
}

asyncOperation((err, result) => {
    if (err) {
        console.error(err);
        return;
    }
    console.log(result);
});

这里 asyncOperation 模拟一个异步操作,通过回调函数的第一个参数传递错误信息。调用者在回调函数中检查 err 并处理错误。

7.2 Promise中的错误处理

Promise通过 catch 方法来捕获错误。例如:

function asyncPromise() {
    return new Promise((resolve, reject) => {
        const success = false;
        if (success) {
            resolve('success result');
        } else {
            reject(new Error('operation failed'));
        }
    });
}

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

asyncPromise 中,如果操作失败,通过 reject 抛出错误,在 then 链的末尾通过 catch 捕获并处理错误。

7.3 async/await中的错误处理

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

async function asyncFunction() {
    try {
        const result = await Promise.reject(new Error('operation failed'));
        console.log(result);
    } catch (error) {
        console.error(error);
    }
}

asyncFunction();

await 后的Promise如果被 rejected,会抛出错误,被 try...catch 块捕获并处理。

8. 异步编程的性能优化

在进行异步编程时,合理的性能优化可以提高程序的运行效率和响应速度。

8.1 减少不必要的异步操作

虽然异步操作能避免阻塞主线程,但过多不必要的异步操作也会增加开销。例如,如果一个操作非常简单且耗时极短,就没有必要将其异步化。

8.2 控制并发数量

在进行多个异步操作时,特别是网络请求等I/O操作,如果并发数量过多,可能会耗尽系统资源,导致性能下降。可以使用 Promise.all 结合队列来控制并发数量。例如:

function asyncTask() {
    return new Promise((resolve) => {
        setTimeout(resolve, 1000);
    });
}

function limitConcurrent(tasks, limit) {
    return new Promise((resolve) => {
        const results = [];
        let completed = 0;
        const executeTask = () => {
            if (tasks.length === 0 && completed === limit) {
                resolve(results);
                return;
            }
            const task = tasks.shift();
            task().then((result) => {
                results.push(result);
                completed++;
                executeTask();
            });
        };
        for (let i = 0; i < limit; i++) {
            executeTask();
        }
    });
}

const tasks = Array.from({ length: 10 }, () => asyncTask);
limitConcurrent(tasks, 3).then((results) => {
    console.log(results);
});

这里 limitConcurrent 函数可以控制同时执行的异步任务数量为 limit,避免过多任务同时执行造成性能问题。

8.3 缓存异步结果

对于一些经常重复执行的异步操作,可以缓存其结果。例如,对于一个异步获取用户信息的函数:

const userCache = {};
async function getUserInfo(userId) {
    if (userCache[userId]) {
        return userCache[userId];
    }
    const response = await fetch(`/user/${userId}`);
    const data = await response.json();
    userCache[userId] = data;
    return data;
}

这样,当多次请求同一用户信息时,如果缓存中有数据,就直接返回缓存结果,避免重复的网络请求。

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

在实际的Web开发项目中,异步编程无处不在。

9.1 网络请求

无论是使用 fetch 还是 axios 进行网络请求,都是异步操作。例如,使用 fetch 获取数据:

async function getData() {
    try {
        const response = await fetch('/api/data');
        const data = await response.json();
        console.log(data);
    } catch (error) {
        console.error(error);
    }
}

getData();

这里通过 await 等待网络请求完成并处理响应数据,整个过程不会阻塞主线程。

9.2 处理用户交互

在处理用户交互事件(如点击按钮、滚动页面等)时,可能需要进行异步操作。例如,点击按钮后发起一个网络请求:

const button = document.getElementById('myButton');
button.addEventListener('click', async () => {
    try {
        const response = await fetch('/api/action');
        const result = await response.json();
        console.log(result);
    } catch (error) {
        console.error(error);
    }
});

这种方式确保了在处理用户交互的同时,不会影响页面的响应性。

9.3 动画与渲染

在Web动画和渲染中,也会涉及异步操作。例如,使用 requestAnimationFrame 来实现动画,它是一个异步函数,会在浏览器下一次重绘之前调用回调函数。

function animate() {
    let count = 0;
    function step() {
        count++;
        console.log(count);
        if (count < 10) {
            requestAnimationFrame(step);
        }
    }
    requestAnimationFrame(step);
}

animate();

通过 requestAnimationFrame 可以实现流畅的动画效果,并且不会阻塞主线程,保证页面的性能。

通过深入理解和掌握JavaScript的异步编程,开发者能够编写出更加高效、响应迅速且易于维护的Web应用程序。无论是处理复杂的业务逻辑,还是优化用户体验,异步编程都起着至关重要的作用。