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

Typescript中的异步编程详解

2022-04-227.6k 阅读

1. 异步编程的背景与重要性

在现代编程中,尤其是在处理 I/O 操作(如网络请求、文件读取等)时,异步编程显得尤为重要。传统的同步编程方式会阻塞主线程,导致程序在等待操作完成时无法响应用户交互或执行其他任务。例如,在一个 Web 应用中,如果使用同步方式进行网络请求,在请求等待响应的过程中,页面会出现卡顿,用户无法进行任何操作。

而异步编程允许主线程在执行异步任务时继续执行其他代码,当异步任务完成后,通过回调函数、Promise 或 async/await 等机制来处理结果。这极大地提高了程序的响应性和性能,使得应用能够更流畅地运行。

2. TypeScript 中的异步编程方式

2.1 回调函数

回调函数是最基础的异步编程方式。在 TypeScript 中,我们可以定义一个接受回调函数作为参数的函数,当异步操作完成时,调用这个回调函数并传入结果。

function asyncOperation(callback: (result: string) => void) {
    setTimeout(() => {
        const result = "异步操作完成";
        callback(result);
    }, 1000);
}

asyncOperation((result) => {
    console.log(result);
});

在上述代码中,asyncOperation 函数模拟了一个异步操作,它接受一个回调函数 callback。通过 setTimeout 模拟异步操作延迟 1 秒执行,完成后调用回调函数并传入结果。

然而,回调函数存在一个问题,当有多个异步操作需要依次执行时,会出现回调地狱(Callback Hell)。例如:

function step1(callback: (result1: string) => void) {
    setTimeout(() => {
        const result1 = "第一步完成";
        callback(result1);
    }, 1000);
}

function step2(result1: string, callback: (result2: string) => void) {
    setTimeout(() => {
        const result2 = result1 + ",第二步完成";
        callback(result2);
    }, 1000);
}

function step3(result2: string, callback: (result3: string) => void) {
    setTimeout(() => {
        const result3 = result2 + ",第三步完成";
        callback(result3);
    }, 1000);
}

step1((result1) => {
    step2(result1, (result2) => {
        step3(result2, (result3) => {
            console.log(result3);
        });
    });
});

上述代码中,多个异步操作嵌套在一起,代码的可读性和维护性变得很差。为了解决这个问题,Promise 应运而生。

2.2 Promise

Promise 是一种处理异步操作的更优雅方式。它代表一个异步操作的最终完成(或失败)及其结果值。一个 Promise 有三种状态:pending(进行中)、fulfilled(已成功)和 rejected(已失败)。

function asyncOperation(): Promise<string> {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            const success = true;
            if (success) {
                resolve("异步操作成功");
            } else {
                reject("异步操作失败");
            }
        }, 1000);
    });
}

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

在上述代码中,asyncOperation 函数返回一个 Promise。resolve 函数用于将 Promise 的状态变为 fulfilled 并传递结果,reject 函数用于将 Promise 的状态变为 rejected 并传递错误信息。通过 .then() 方法处理成功的结果,通过 .catch() 方法处理失败的情况。

当有多个异步操作需要依次执行时,Promise 可以通过链式调用的方式解决回调地狱问题。例如:

function step1(): Promise<string> {
    return new Promise((resolve) => {
        setTimeout(() => {
            resolve("第一步完成");
        }, 1000);
    });
}

function step2(result1: string): Promise<string> {
    return new Promise((resolve) => {
        setTimeout(() => {
            const result2 = result1 + ",第二步完成";
            resolve(result2);
        }, 1000);
    });
}

function step3(result2: string): Promise<string> {
    return new Promise((resolve) => {
        setTimeout(() => {
            const result3 = result2 + ",第三步完成";
            resolve(result3);
        }, 1000);
    });
}

step1()
   .then(step2)
   .then(step3)
   .then((result3) => {
        console.log(result3);
    })
   .catch((error) => {
        console.error(error);
    });

在上述代码中,每个异步操作都返回一个 Promise,通过 .then() 方法将它们链接起来,使代码更具可读性。

2.3 async/await

async/await 是基于 Promise 的更简洁的异步编程语法糖。async 关键字用于定义一个异步函数,该函数始终返回一个 Promise。await 关键字只能在 async 函数内部使用,它用于暂停异步函数的执行,等待一个 Promise 被解决(resolved 或 rejected)。

async function asyncOperation() {
    try {
        const result = await new Promise<string>((resolve) => {
            setTimeout(() => {
                resolve("异步操作完成");
            }, 1000);
        });
        console.log(result);
    } catch (error) {
        console.error(error);
    }
}

asyncOperation();

在上述代码中,asyncOperation 是一个异步函数。await 等待 Promise 被解决,然后将结果赋值给 result。通过 try...catch 块来捕获可能的错误。

当有多个异步操作需要依次执行时,async/await 使得代码看起来像同步代码一样简洁。例如:

async function step1(): Promise<string> {
    return new Promise((resolve) => {
        setTimeout(() => {
            resolve("第一步完成");
        }, 1000);
    });
}

async function step2(result1: string): Promise<string> {
    return new Promise((resolve) => {
        setTimeout(() => {
            const result2 = result1 + ",第二步完成";
            resolve(result2);
        }, 1000);
    });
}

async function step3(result2: string): Promise<string> {
    return new Promise((resolve) => {
        setTimeout(() => {
            const result3 = result2 + ",第三步完成";
            resolve(result3);
        }, 1000);
    });
}

async function main() {
    try {
        const result1 = await step1();
        const result2 = await step2(result1);
        const result3 = await step3(result2);
        console.log(result3);
    } catch (error) {
        console.error(error);
    }
}

main();

在上述代码中,main 函数通过 await 依次等待每个异步操作完成,代码逻辑清晰,类似于同步代码的写法。

3. 异步操作的并发与并行

3.1 并发(Concurrent)

并发是指在同一时间段内,多个任务交替执行,但在同一时刻只有一个任务在执行。在 TypeScript 中,我们可以使用 Promise.all 来实现并发操作。Promise.all 接受一个 Promise 数组作为参数,当所有的 Promise 都被 resolved 时,它返回一个新的 Promise,该 Promise 的 resolved 值是一个包含所有输入 Promise resolved 值的数组。

function task1(): Promise<string> {
    return new Promise((resolve) => {
        setTimeout(() => {
            resolve("任务 1 完成");
        }, 2000);
    });
}

function task2(): Promise<string> {
    return new Promise((resolve) => {
        setTimeout(() => {
            resolve("任务 2 完成");
        }, 1000);
    });
}

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

在上述代码中,task1task2 两个异步任务同时开始执行,Promise.all 等待它们都完成后,将结果数组传递给 .then() 回调函数。

如果其中任何一个 Promise 被 rejected,Promise.all 会立即被 rejected,并将第一个被 rejected 的 Promise 的错误传递给 .catch() 回调函数。

3.2 并行(Parallel)

严格来说,JavaScript 是单线程语言,无法真正实现并行。然而,在 Node.js 环境中,通过利用多核 CPU 和多进程技术,可以实现类似并行的效果。例如,使用 cluster 模块可以创建多个工作进程来并行处理任务。

const cluster = require('cluster');
const http = require('http');
const numCPUs = require('os').cpus().length;

if (cluster.isMaster) {
    console.log(`主进程 ${process.pid} 正在运行`);

    for (let i = 0; i < numCPUs; i++) {
        cluster.fork();
    }

    cluster.on('exit', (worker, code, signal) => {
        console.log(`工作进程 ${worker.process.pid} 已退出`);
    });
} else {
    http.createServer((req, res) => {
        res.writeHead(200, { 'Content-Type': 'text/plain' });
        res.end('你好,世界!\n');
    }).listen(8000, () => {
        console.log(`工作进程 ${process.pid} 已启动`);
    });
}

在上述代码中,cluster 模块允许主进程创建多个工作进程,每个工作进程都可以独立处理 HTTP 请求,从而实现类似并行的效果,提高服务器的处理能力。

4. 异步错误处理

4.1 Promise 的错误处理

在 Promise 中,我们可以通过 .catch() 方法来捕获异步操作中可能出现的错误。例如:

function asyncOperation(): Promise<string> {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            const success = false;
            if (success) {
                resolve("异步操作成功");
            } else {
                reject("异步操作失败");
            }
        }, 1000);
    });
}

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

在上述代码中,如果 asyncOperation 中的 Promise 被 rejected,.catch() 方法会捕获到错误并进行处理。

4.2 async/await 的错误处理

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

async function asyncOperation() {
    try {
        const result = await new Promise<string>((resolve, reject) => {
            setTimeout(() => {
                const success = false;
                if (success) {
                    resolve("异步操作成功");
                } else {
                    reject("异步操作失败");
                }
            }, 1000);
        });
        console.log(result);
    } catch (error) {
        console.error(error);
    }
}

asyncOperation();

在上述代码中,await 表达式后的 Promise 如果被 rejected,try...catch 块会捕获到错误并进行处理。

5. 异步编程与事件循环

5.1 事件循环机制

JavaScript 是单线程语言,它通过事件循环(Event Loop)机制来实现异步编程。事件循环的基本原理是:JavaScript 引擎有一个调用栈(Call Stack)用于执行同步任务,还有一个任务队列(Task Queue)用于存放异步任务的回调函数。

当调用栈为空时,事件循环会从任务队列中取出一个任务放入调用栈中执行。这个过程不断重复,使得异步任务能够在同步任务执行完毕后依次执行。

例如,当我们使用 setTimeout 时,它会将回调函数放入任务队列中,而不会阻塞调用栈。当调用栈中的同步任务执行完毕后,事件循环会将 setTimeout 的回调函数从任务队列中取出并放入调用栈执行。

console.log('同步任务 1');
setTimeout(() => {
    console.log('异步任务回调');
}, 1000);
console.log('同步任务 2');

在上述代码中,首先输出 同步任务 1同步任务 2,然后等待 1 秒后输出 异步任务回调。这是因为 setTimeout 的回调函数被放入任务队列,在同步任务执行完毕后,事件循环将其取出执行。

5.2 宏任务与微任务

在事件循环中,任务队列又分为宏任务队列(Macro Task Queue)和微任务队列(Micro Task Queue)。常见的宏任务有 setTimeoutsetIntervalsetImmediate(Node.js 环境)、I/O 操作等;常见的微任务有 Promise.thenMutationObserver 等。

事件循环的执行过程是:先执行完调用栈中的同步任务,然后执行微任务队列中的所有微任务,直到微任务队列为空,最后从宏任务队列中取出一个宏任务放入调用栈执行,如此循环。

console.log('同步任务 1');
setTimeout(() => {
    console.log('宏任务回调');
}, 0);
Promise.resolve().then(() => {
    console.log('微任务回调');
});
console.log('同步任务 2');

在上述代码中,首先输出 同步任务 1同步任务 2,然后输出 微任务回调,最后输出 宏任务回调。这是因为 Promise.then 是微任务,在同步任务执行完毕后,事件循环先执行微任务队列中的任务,然后再执行宏任务队列中的任务。

6. 异步编程在实际项目中的应用场景

6.1 Web 开发中的网络请求

在 Web 开发中,网络请求是最常见的异步操作。例如,使用 fetch 进行 HTTP 请求:

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

getData();

在上述代码中,fetch 函数返回一个 Promise,通过 await 等待请求完成并获取响应数据。

6.2 文件操作(Node.js 环境)

在 Node.js 中,文件操作通常也是异步的。例如,读取文件内容:

const fs = require('fs').promises;

async function readFileContent() {
    try {
        const content = await fs.readFile('example.txt', 'utf8');
        console.log(content);
    } catch (error) {
        console.error(error);
    }
}

readFileContent();

在上述代码中,fs.readFile 返回一个 Promise,await 等待文件读取操作完成并获取文件内容。

6.3 定时任务

在一些场景下,我们需要执行定时任务,例如定时检查更新。可以使用 setInterval 结合异步操作来实现:

async function checkForUpdates() {
    try {
        const response = await fetch('https://example.com/api/checkUpdate');
        const data = await response.json();
        if (data.hasUpdate) {
            console.log('有新更新');
        } else {
            console.log('无新更新');
        }
    } catch (error) {
        console.error(error);
    }
}

setInterval(checkForUpdates, 60000); // 每分钟检查一次

在上述代码中,setInterval 每隔一分钟调用一次 checkForUpdates 函数,该函数通过网络请求检查是否有新更新。

7. 优化异步编程的性能

7.1 减少不必要的异步操作

在编写代码时,要尽量避免不必要的异步操作。例如,如果一个操作可以在同步方式下快速完成,就不要使用异步方式。这样可以减少事件循环的负担,提高性能。

// 同步方式
function addNumbers(a: number, b: number): number {
    return a + b;
}

// 不必要的异步方式
function addNumbersAsync(a: number, b: number): Promise<number> {
    return new Promise((resolve) => {
        setTimeout(() => {
            resolve(a + b);
        }, 0);
    });
}

在上述代码中,addNumbers 同步函数比 addNumbersAsync 异步函数更高效,因为 addNumbersAsync 使用 setTimeout 引入了不必要的异步开销。

7.2 合理使用并发与并行

在处理多个异步任务时,要根据任务的特点合理选择并发或并行方式。如果任务之间相互独立且对资源消耗不大,可以使用并发方式(如 Promise.all)来提高效率。如果任务对 CPU 资源消耗较大,可以考虑在 Node.js 环境中使用并行方式(如 cluster 模块)。

7.3 优化异步操作的顺序

在处理多个异步操作时,合理安排操作的顺序也可以提高性能。例如,如果有一些异步操作依赖于其他操作的结果,应该先执行那些不依赖其他结果的操作,以减少整体的等待时间。

async function task1(): Promise<string> {
    return new Promise((resolve) => {
        setTimeout(() => {
            resolve("任务 1 完成");
        }, 2000);
    });
}

async function task2(): Promise<string> {
    return new Promise((resolve) => {
        setTimeout(() => {
            resolve("任务 2 完成");
        }, 1000);
    });
}

async function task3(result1: string, result2: string): Promise<string> {
    return new Promise((resolve) => {
        setTimeout(() => {
            const result3 = result1 + " " + result2 + ",任务 3 完成";
            resolve(result3);
        }, 1000);
    });
}

async function main() {
    const [result1, result2] = await Promise.all([task1(), task2()]);
    const result3 = await task3(result1, result2);
    console.log(result3);
}

main();

在上述代码中,task1task2 不相互依赖,可以并发执行,然后 task3 依赖于 task1task2 的结果,这样安排顺序可以减少整体的执行时间。

8. 异步编程的常见问题与解决方案

8.1 内存泄漏

在异步编程中,如果不正确处理回调函数或 Promise,可能会导致内存泄漏。例如,在使用事件监听器时,如果没有及时移除监听器,会导致对象无法被垃圾回收。

class MyClass {
    private element: HTMLElement;

    constructor() {
        this.element = document.createElement('div');
        this.element.addEventListener('click', this.handleClick.bind(this));
    }

    private handleClick() {
        console.log('按钮被点击');
    }

    public destroy() {
        this.element.removeEventListener('click', this.handleClick.bind(this));
        // 移除元素等其他清理操作
    }
}

const myObject = new MyClass();
// 在适当的时候调用 myObject.destroy() 以避免内存泄漏

在上述代码中,MyClass 类在构造函数中添加了一个事件监听器,如果在对象不再使用时没有调用 destroy 方法移除监听器,会导致内存泄漏。

8.2 竞态条件(Race Condition)

竞态条件是指多个异步操作竞争共享资源,导致结果不可预测。例如,多个异步任务同时修改一个共享变量。

let sharedValue = 0;

function increment() {
    return new Promise((resolve) => {
        setTimeout(() => {
            sharedValue++;
            resolve();
        }, Math.random() * 100);
    });
}

Promise.all([increment(), increment(), increment()])
   .then(() => {
        console.log(sharedValue);
    });

在上述代码中,由于 increment 函数的执行时间是随机的,多个 increment 同时执行可能会导致 sharedValue 的最终值与预期不符。为了解决竞态条件,可以使用锁机制或其他同步策略。

let sharedValue = 0;
let isLocked = false;

async function increment() {
    while (isLocked) {
        await new Promise((resolve) => setTimeout(resolve, 100));
    }
    isLocked = true;
    try {
        sharedValue++;
    } finally {
        isLocked = false;
    }
}

Promise.all([increment(), increment(), increment()])
   .then(() => {
        console.log(sharedValue);
    });

在上述改进代码中,通过 isLocked 变量实现了一个简单的锁机制,确保在同一时间只有一个 increment 函数能够修改 sharedValue

8.3 未处理的 Promise 拒绝

如果在 Promise 链中没有正确处理拒绝情况,可能会导致未处理的 Promise 拒绝错误,这在开发中是一个常见问题。

function asyncOperation(): Promise<string> {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            reject("异步操作失败");
        }, 1000);
    });
}

asyncOperation()
   .then((result) => {
        console.log(result);
    });

在上述代码中,asyncOperation 的 Promise 被拒绝,但没有 .catch() 处理,会导致未处理的 Promise 拒绝错误。为了避免这种情况,应该始终在 Promise 链中添加 .catch() 处理或者在 async/await 中使用 try...catch 块。

function asyncOperation(): Promise<string> {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            reject("异步操作失败");
        }, 1000);
    });
}

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

通过上述修改,.catch() 会捕获到 Promise 被拒绝的错误并进行处理。

9. 异步编程的最佳实践

9.1 保持代码简洁

无论是使用回调函数、Promise 还是 async/await,都要尽量保持代码简洁。避免过度嵌套和复杂的逻辑,提高代码的可读性和可维护性。例如,在使用 async/await 时,将复杂的异步操作封装成独立的函数,使主函数逻辑更清晰。

async function fetchData(): Promise<any> {
    const response = await fetch('https://example.com/api/data');
    return response.json();
}

async function processData() {
    try {
        const data = await fetchData();
        // 处理数据
        console.log(data);
    } catch (error) {
        console.error(error);
    }
}

processData();

在上述代码中,fetchData 函数封装了网络请求和数据解析的操作,processData 函数专注于处理数据,使代码结构更清晰。

9.2 统一错误处理

在异步编程中,统一错误处理机制非常重要。无论是在 Promise 链中还是在 async/await 中,都要确保能够捕获并处理所有可能的错误。可以考虑封装一个全局的错误处理函数,在整个应用中统一使用。

function handleError(error: any) {
    console.error('全局错误处理:', error);
    // 可以在这里添加日志记录、错误上报等操作
}

async function asyncOperation() {
    try {
        const result = await new Promise<string>((resolve, reject) => {
            setTimeout(() => {
                reject("异步操作失败");
            }, 1000);
        });
        console.log(result);
    } catch (error) {
        handleError(error);
    }
}

asyncOperation();

在上述代码中,handleError 函数用于统一处理异步操作中的错误,方便管理和维护错误处理逻辑。

9.3 合理使用并发与异步控制

根据业务需求合理使用并发操作(如 Promise.all)和异步控制(如 async/await)。对于相互独立的异步任务,使用并发可以提高效率;对于有依赖关系的任务,使用 async/await 顺序执行更合适。同时,要注意并发任务的数量,避免过度消耗资源。

function task1(): Promise<string> {
    return new Promise((resolve) => {
        setTimeout(() => {
            resolve("任务 1 完成");
        }, 2000);
    });
}

function task2(): Promise<string> {
    return new Promise((resolve) => {
        setTimeout(() => {
            resolve("任务 2 完成");
        }, 1000);
    });
}

async function main() {
    const [result1, result2] = await Promise.all([task1(), task2()]);
    console.log(result1, result2);
}

main();

在上述代码中,task1task2 相互独立,使用 Promise.all 并发执行,提高了整体执行效率。

9.4 避免阻塞事件循环

要避免在异步操作中执行长时间的同步任务,以免阻塞事件循环。如果确实需要执行一些复杂的计算,可以考虑将其放到 Web Worker(在浏览器环境)或子进程(在 Node.js 环境)中执行,以保持主线程的响应性。

// Web Worker 示例(浏览器环境)
if (typeof Worker!== 'undefined') {
    const worker = new Worker('worker.js');
    worker.onmessage = function (event) {
        console.log('从 Worker 接收到结果:', event.data);
    };
    worker.postMessage({ data: '需要处理的数据' });
} else {
    console.log('当前环境不支持 Web Worker');
}

// 在 worker.js 中
self.onmessage = function (event) {
    // 执行复杂计算
    const result = performComplexCalculation(event.data);
    self.postMessage(result);
};

function performComplexCalculation(data: any): any {
    // 复杂计算逻辑
    return data;
}

在上述代码中,通过 Web Worker 将复杂计算放到单独的线程中执行,避免阻塞主线程。在 Node.js 环境中,可以使用 child_process 模块创建子进程来实现类似效果。

通过遵循这些最佳实践,可以编写出更健壮、高效且易于维护的异步代码。在实际项目中,要根据具体的业务场景和需求,灵活运用各种异步编程技术和策略,以达到最佳的性能和用户体验。