从 C 语言过渡到 TypeScript 的异步编程,核心差异在于执行模型的不同。C 语言采用同步阻塞模型,调用一个 I/O 函数后线程挂起,直到操作完成才继续执行下一条语句。JavaScript 运行在单线程环境中,如果采用同样的阻塞方式,页面在发起网络请求后将完全冻结,无法响应用户交互。因此 JavaScript 采用了事件循环配合回调机制,通过 Promiseasync/await 提供语法层面的封装。理解这些机制在底层如何运作,是写出可靠异步代码的基础。

await 的本质:函数切割与微任务注册

在 C 语言中,一个函数调用会占据调用栈直到返回。JavaScript 的 await 关键字表面上也在等待一个异步操作完成,但它并不会阻塞主线程。实际上,await 会触发编译器对 async 函数进行重组,将其切割为多个片段,把 await 之后的代码注册为微任务,然后立即释放主线程。

考虑以下代码:

async function loadUser() {
    console.log("步骤 1:开始加载");
    const user = await fetchUser();  // 假设这是一个返回 Promise 的网络请求
    console.log("步骤 2:获得用户", user.name);
    const orders = await fetchOrders(user.id);
    console.log("步骤 3:获得订单数", orders.length);
}

这段代码在逻辑上看起来是顺序执行的,但在事件循环中的实际行为如下:

  1. 调用 loadUser(),进入函数体,同步执行 console.log("步骤 1")
  2. 执行 fetchUser(),该函数返回一个 Promise,状态为 pending
  3. 遇到 await,JavaScript 引擎将 loadUser 函数中从当前位置到函数末尾的所有代码包装成一个回调函数
  4. 这个回调函数被注册到微任务队列中,等待 fetchUser() 返回的 Promise 状态变为 fulfilled
  5. loadUser() 的同步执行部分结束,主线程弹出该函数的同步上下文,继续执行调用者后面的代码
  6. fetchUser() 的网络请求完成,Promise 状态变为 fulfilled,事件循环在合适的时机取出之前注册的微任务,恢复执行被切割下来的代码片段
  7. 执行 console.log("步骤 2"),然后遇到第二个 await,重复上述切割和注册过程

这意味着 await 后面的代码实际上运行在不同的调用栈中。虽然代码书写上是连续的,但执行时经历了多次调用栈的切换。主线程在两次执行之间可以去处理其他事件,比如响应点击、执行其他定时器回调、渲染页面等。

可以通过以下代码验证这一点:

async function test() {
    console.log("A");
    await Promise.resolve();
    console.log("B");
}

test();
console.log("C");

// 输出顺序:
// A
// C
// B

如果 await 真的阻塞了主线程,那么 C 不可能在 B 之前输出。实际上,await Promise.resolve()console.log("B") 注册为微任务,主线程立刻去执行外层的同步代码 console.log("C"),然后在当前同步代码全部结束后,清空微任务队列,才执行 B

事件循环的执行顺序

浏览器的事件循环由三个核心部分组成:调用栈(Call Stack)、微任务队列(Microtask Queue)和宏任务队列(Macrotask Queue)。事件循环的每一轮迭代遵循以下固定规则:

  1. 执行调用栈中所有的同步代码,直到调用栈为空
  2. 检查微任务队列,依次执行其中所有任务,直到微任务队列为空
  3. 执行一个宏任务(如果宏任务队列中有任务的话)
  4. 回到步骤 1,进入下一轮循环

这种机制决定了微任务的优先级总是高于宏任务。只有当微任务队列完全清空后,事件循环才会处理宏任务队列中的下一个任务。

微任务的来源包括

  • Promise.prototype.then() 注册的回调
  • Promise.prototype.catch() 注册的回调
  • Promise.prototype.finally() 注册的回调
  • queueMicrotask() 显式注册的回调
  • async 函数中 await 关键字后续产生的代码片段

宏任务的来源包括

  • setTimeout()setInterval() 的回调
  • requestAnimationFrame() 的回调(严格来说是渲染任务,但在概念上属于宏任务级别)
  • 用户交互事件(如点击、键盘输入)的回调
  • 网络请求完成后的回调(如 XMLHttpRequestonload

通过以下代码可以观察这种优先级差异:

console.log("同步 1");

setTimeout(() => {
    console.log("宏任务 1");
    Promise.resolve().then(() => {
        console.log("宏任务 1 中的微任务");
    });
}, 0);

Promise.resolve().then(() => {
    console.log("微任务 1");
});

setTimeout(() => {
    console.log("宏任务 2");
}, 0);

Promise.resolve().then(() => {
    console.log("微任务 2");
});

console.log("同步 2");

执行顺序推导如下:

  1. 同步代码阶段:console.log("同步 1")console.log("同步 2")
  2. 第一个 setTimeout 将回调注册到宏任务队列
  3. 第一个 Promise.resolve().then() 将回调注册到微任务队列
  4. 第二个 setTimeout 将回调注册到宏任务队列(排在第一个 setTimeout 之后)
  5. 第二个 Promise.resolve().then() 将回调注册到微任务队列(排在第一个微任务之后)
  6. 同步代码结束,进入微任务阶段:依次执行微任务 1、微任务 2
  7. 微任务队列清空,进入宏任务阶段:取出第一个宏任务(即宏任务 1)执行
  8. 宏任务 1 执行过程中产生了新的微任务(Promise.resolve().then()),该微任务被立即加入微任务队列
  9. 当前宏任务执行完毕,事件循环检查微任务队列,发现新的微任务,立即执行(宏任务 1 中的微任务)
  10. 微任务队列再次清空,执行下一个宏任务(宏任务 2)

最终输出:

同步 1
同步 2
微任务 1
微任务 2
宏任务 1
宏任务 1 中的微任务
宏任务 2

这个例子展示了事件循环的一个重要特性:当一个宏任务执行完毕后,如果它产生了新的微任务,这些微任务会在下一个宏任务之前全部执行完毕。这也是 await 能够形成"伪同步"效果的基础——await 产生的后续代码作为微任务,具有比定时器等宏任务更高的执行优先级。

泛型在异步编程中的类型约束

在 C 语言中,处理通用数据结构通常使用 void* 指针,但这完全依赖程序员的自律来确保类型安全,编译器无法检查运行时指针转换是否正确。TypeScript 的泛型系统为异步编程提供了编译期的类型约束,使得异步操作返回的数据结构可以在调用点被精确指定。

考虑一个通用的 HTTP 请求封装:

async function http<T>(url: string, options?: RequestInit): Promise<T> {
    const response = await fetch(url, options);
    
    if (!response.ok) {
        throw new Error(`HTTP ${response.status}: ${response.statusText}`);
    }
    
    return response.json() as T;
}

这个函数本身不依赖于任何具体的业务类型。调用方通过泛型参数 T 指定期望的返回数据结构:

interface User {
    id: number;
    name: string;
    email: string;
}

interface ApiError {
    code: number;
    message: string;
}

// 调用方精确指定返回类型
const user = await http<User>("https://api.example.com/user/1");
// TypeScript 推断 user 为 User 类型
console.log(user.name);  // 有完整的类型提示和检查

// 如果尝试访问不存在的属性,编译器会报错
// console.log(user.age);  // 错误:User 类型上不存在 age 属性

这种方式比使用 anyunknown 更安全。any 会关闭类型检查,使得 TypeScript 退化为 JavaScript,失去了静态分析的优势。泛型则保持了类型信息的传递,在编译期就能发现属性访问错误、类型不匹配等问题。

当需要并发请求多个接口时,Promise.all 结合泛型可以自动推导结果元组的类型:

interface Order {
    orderId: string;
    amount: number;
}

const [user, orders] = await Promise.all([
    http<User>("/api/user"),
    http<Order[]>("/api/orders")
]);

// TypeScript 自动推断:
// user 的类型为 User
// orders 的类型为 Order[]
// 无需手动类型断言

这里 Promise.all 接收一个元组 [Promise<User>, Promise<Order[]>],返回类型被推导为 Promise<[User, Order[]]>。解构赋值后,每个变量都带有精确的类型信息。

常见陷阱与解决方案

1. forEach 中不能使用 await

数组的 forEach 方法会同步遍历所有元素,对每个元素调用回调函数。如果回调是 async 函数,forEach 不会等待前一个异步操作完成,而是瞬间启动所有回调,导致并发执行而非串行执行。

const delays = [1000, 2000, 3000];

// 错误写法:并发执行,总耗时约 3 秒(最慢的那个),且无法按顺序等待
delays.forEach(async (ms) => {
    await sleep(ms);
    console.log(ms);
});
console.log("forEach 结束");  // 这行会立即执行,不会等待上面的 sleep

// 正确写法:使用 for...of 循环,串行执行,总耗时 6 秒,且"结束"在最后输出
for (const ms of delays) {
    await sleep(ms);
    console.log(ms);
}
console.log("forEach 结束");

原因在于 forEach 的实现类似于:

function forEach(callback) {
    for (let i = 0; i < this.length; i++) {
        callback(this[i], i, this);  // 同步调用,不处理返回值
    }
}

async 函数的返回值是 Promise,但 forEach 忽略了这个返回值,因此它无法形成等待链。for...of 循环在 async 函数内部执行时,await 会暂停整个循环的下一次迭代,从而实现真正的串行。

2. 错误处理的边界

try/catch 只能捕获同一次调用栈中的同步错误或 await 的异步错误,无法捕获在不同调用栈中抛出的错误。

// 无法捕获:setTimeout 的回调在全新的调用栈中执行
try {
    setTimeout(() => {
        throw new Error("定时器错误");
    }, 0);
} catch (e) {
    // 永远不会执行到这里
    console.log("捕获错误", e);
}

// 可以捕获:await 将异步 reject 转换为了当前调用栈的异常
try {
    await fetchUser();  // 如果内部 reject(new Error("网络错误"))
} catch (e) {
    // 可以正常捕获
    console.log("捕获错误", e);
}

对于需要统一错误处理的异步流程,可以在 async 函数外层包裹 try/catch,或者使用 .catch() 处理 Promise 链末端的错误。

3. 类方法中的 this 丢失

当把类实例的方法作为回调传递时,普通方法的 this 会丢失,而箭头函数属性会保留定义时的 this

class DataLoader {
    private prefix = "加载完成:";
    
    // 普通方法:this 取决于调用方式
    async load(url: string) {
        const data = await http<string>(url);
        console.log(this.prefix, data);  // 如果作为回调传递,this 可能变为 undefined
    }
    
    // 箭头函数属性:this 被词法绑定到实例
    loadSafe = async (url: string) => {
        const data = await http<string>(url);
        console.log(this.prefix, data);  // 无论怎么传递,this 始终指向实例
    };
}

const loader = new DataLoader();

// 直接调用:两者都正常
loader.load("https://api.example.com/data");
loader.loadSafe("https://api.example.com/data");

// 作为回调传递:普通方法会丢失 this
const fn1 = loader.load;
// fn1("...");  // 运行时错误:Cannot read property 'prefix' of undefined

const fn2 = loader.loadSafe;
// fn2("...");  // 正常执行,this 仍指向 loader 实例

在需要把方法作为回调传递的场景(如事件监听、定时器、数组方法),应使用箭头函数属性定义,或者在传递时使用 .bind(this) 显式绑定。

总结

从 C 语言到 TypeScript 的异步编程,核心变化在于执行权的调度方式。C 语言通过阻塞线程等待 I/O,JavaScript 通过事件循环调度回调。await 关键字并非阻塞主线程,而是将函数切割为多个片段,通过微任务队列恢复执行。理解宏任务与微任务的优先级、掌握泛型在异步场景中的类型约束、避免 forEachawait 的错误组合,是写出可靠异步代码的基础。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐