【async/await 原理】之 Thunk 函数和 co 函数原理

864 阅读9分钟

Hi我是蜗牛,用直白的大白话来解释清楚复杂的问题,让新手朋友也能很好的接受。

async/await 原理网上一大堆,然后我发现很多朋友盲目的记住了 Promise + Generator = async/await,却忽略一些很重要的点,Thunk 函数和 co 模块,写这篇文章的目的是为了从0到1的将这两个函数剖析清楚,看完你对 async/await 的认识会再深刻一点

1. Generator 函数

要搞清楚 async/await,Generator 函数是基础,我们先看一个小demo

image.png

函数 A 用定时器模拟一个异步请求,我们希望 A 执行完毕之后 B 再执行

回调我们就不说了,你肯定知道

image.png

当然你不仅要知道 Promise 的用法,现在你还得知道 Generator 的语法:

image.png

Generator函数的特点:

  1. 函数声明成 function* 的样子就是 ES6 定义的 Generator 函数,Generator函数具有一个关键字 yeild,用来封印代码,任何代码前面有 yeild 关键字的,js引擎便不再自动按顺序执行它。
  2. Generator函数执行会得到一个 Generator 对象 ,该对象(g)中具有一个 next 方法,当我们人为的调用 g.next() 时,js引擎才会开始执行 Generator 函数中被封印的代码。
  3. g.next() 返回一个对象 { value: xxx, done: false },其中 value 代表的是当前某个 yeild 的执行结果,done: false 代表 Generator函数还没有执行完毕
  4. 不断地调用 g.next() 会让 js 引擎不断地执行 Generator 函数中的 yeild 封印 (一个next解开一个 yeild 封印)

为什么第一次之后再调用 g.next() 得到的 value 都是 undefined ?这里就要说到 Generator 函数的另一个特点

image.png

首先,执行顺序是这样的:

  1. 第一个 g.next() 执行,带来了 A() 的调用,但是 A 执行需要 1000 毫秒才能执行完毕
  2. 第二个 g.next() 执行是不会等待这 1000 毫秒的,所以 js引擎从 let a = yield A() 这一行之后继续执行,所以 a 变量 被打印,此时因为 A 还没执行完,所有 a 的值是 undefined。
  3. 第二个 g.next() 导致js引擎异步执行完了 第二个 yeild, 所以 B 函数被调用,因为 B 函数是同步代码,所以 打印的顺序是 undefined > 同步b > 异步A

好的那我用 2000 毫秒的定时器来执行第二个 g.next(),是不是变量 a 就有值了呢

image.png

你会发现,执行顺序确实是捋顺了,异步A先执行,同步B再执行,可是变量 a 的值还是 undefined呀?2000毫秒之后 yeild A() 早已有了结果,为什么变量 a 还是没有值呢?那你再看下面这份代码

image.png

你发现了,next函数是完全可以指定上一个 yeild 的执行结果的,这里我们在第二个 g.next() 的执行中传递了实参,它直接当做了 yeild A() 的执行结果,给到了变量 a

所以 Generator 函数还有一个特点,就是内部的 yeild A() 被执行的时候,哪怕 A() 有自己的返回结果,Generator 函数依然不采用,Generator 函数只认自己下一个 next 传入的值

举一个形象一点例子,我是 Generator 函数,我内部有一个 yeild A(),和一个 yeild B(),当我通过自己的 next 让 yeild A() 生效了之后,A有返回值,可是我不认可,我不理会,我再次调用 next() 如果没有接到参数,任谁来问我 yeild A() 的执行结果,我都说不知道,但如果第二次 调用 next('hello') 接受了参数 ‘hello’,那么谁在问我 yeild A() 的执行结果,我都会告诉他是 ‘hello’

总结就一句话:yeild 的执行结果不是靠 yeild 后面接的函数来决定的,而是自己的下一个 next 决定的

明白了 Generator 函数以上的特点之后,这个 yeild 我就可以拿来做文章了,既然不调用next,yeild 就无法被执行,那我只要在合适的时机调用 next,整个 Generator 函数中的哪一行代码什么时候执行岂不是我们人为可控的了!

所以如何保证第一个 g.next() 执行完毕也就是它放开的那个 yeild 后续逻辑执行完毕后,我再去执行第二个 g.next() 就变成了我们现在要解决的首要问题了

而且,我猜你也行到了解决方案:

image.png

这样不就行了吗!.then 让一个 yeild 后面的 A() 先执行,且必须等到的 Promise 的状态要变更成 fulfilled 状态,才让第二个 g.next() 被调用,这样不就能保证 一定先执行 异步A,在执行同步B了嘛!

2. 当前面临的问题

你已经能理解,我们借助 Promise + Generator 可以手动控制异步代码和同步代码的执行顺序了,可是这么写目前依然存在一个很严峻的问题,看下面代码

image.png

执行顺序是我们理想中的顺序: 异步a > 异步b > 异步c

可是试想一下,如果我有 5个 这样的函数呢?10个呢?100个呢?

g.next().value.then(() => {
  g.next().value.then(() => {
    g.next()
    // ......
  })
})

这里的层级岂不是无穷尽了,所以我们得优化此处的代码,也叫,如何让 Generator 函数自动的执行下去

3. Thunk 函数

对于我们目前面对的问题,Thunk 函数这个概念就被引入用来解决这一问题。Thunk 函数是一种用于延迟执行的函数包装器,通常用于处理异步操作或生成器函数的执行。Thunk 函数的目标是将一个函数的执行推迟到稍后的时间,以便在需要时再执行。在 JavaScript 中,Thunk 函数通常是一个带有回调函数的函数包装器。

代码理解最方便:

image.png

在这个示例中,simpleThunk 函数接受一个回调函数作为参数,模拟了一个异步操作并在操作完成后调用回调函数。

现在,让我们将 Thunk 函数与生成器函数结合使用,以实现生成器函数的自动执行:

image.png

解释:

  1. 函数 simpleThunk 用于将其他函数包装成 Thunk 函数,我们将传入的异步函数 ABC 包装成一个 Thunk 函数,以便在生成器函数中使用。
  2. 异步函数 ABC,现在它们接受一个回调函数作为参数,并在异步操作完成后调用回调函数。
  3. 在生成器函数 exampleGenerator 中,我们使用 simpleThunk 包装了异步操作,然后使用 yield 关键字等待 Thunk 函数的执行。
  4. run 函数执行 Thunk 函数,并在异步操作完成后继续迭代生成器函数。

整理一下执行逻辑:

  1. run(exampleGenerator); run 函数中调用 exampleGenerator 的到对象(g)
  2. iterate(g); 执行了第一个 g.next(),这就开启了 yeild simpleThunk(A); 的执行,得到的仍然是一个函数体,所以 const { value, done } = g.next(); 中value还是一个函数
  3. 调用 value() 带来了 函数A 的执行,A在1秒钟之后才会调用自己内部的 callback,这就导致了 A函数没有执行完毕的话,iterate(g) 这一处的递归就无法开始
value(() => {
  iterate(g);
});

// () => { iterate(g) }  value接受到的这个回调函数 就是 A函数中的 callback
  1. A函数执行完毕,递归进去执行 B函数,B函数执行完毕,递归进去执行 C函数

这样一来,我们就通过打造一个 Thunk 函数和一个 Thunk 函数执行器(run)来实现了让 Generator 函数自动执行下一次层的 next(),解决了上述我们遇到的问题

4. co 模块

除了 Thunk 函数的方式,还有一个方法可以实现 Generator 的自动执行,就是 co 模块。co 模块是大佬 TJ Holowaychuk封装的一个库,一个用于控制生成器函数执行的库,它允许你以同步的方式编写异步代码,使得生成器函数内部的异步操作看起来像同步代码一样。co 模块的实现基于 Promise 和生成器函数的特性,它自动迭代生成器并处理 Promise 对象的返回值。

它的用法非常简单:

image.png

那么它的实现原理呢?其实也非常简单,我们可以这样来实现它:

image.png

同样是使用递归,不过 co 借助了 Promise中的 then 方法,所以需要使用者注意 yeild 后面的内容一定要返回一个 Promise 对象,当上一个 yeild 执行完毕且状态变更为 fulfilled,then才能执行,也就才能走进下一层的递归。

5. 回顾 async/await

async/await 的语法:

image.png

对比上述我们通过 Generator + Thunk 和 Generator + Promise + co的手段 来实现将异步捋成同步后,可以很明确的看出 async/await 的实现就是由 Generator + Promise + co的手段 来封装的

6. Thunk和co的区别

co 函数和 Thunk 函数都是用于处理异步操作的工具,但它们之间存在一些关键区别。

  1. 用途:

    • co 函数通常用于协调和管理异步操作的流程,使得异步操作看起来像同步代码一样执行。它通常与生成器函数结合使用。
    • Thunk 函数主要用于封装异步操作,将其包装成一个函数,以便在需要时延迟执行。Thunk 函数通常用于构建异步流程控制工具。
  2. 执行方式:

    • co 函数是一个库或工具,它使用 Promise 和生成器函数的协作来实现异步控制。co 内部会自动执行生成器函数,并管理异步操作的执行流程。
    • Thunk 函数是一个函数包装器,它接受回调函数作为参数,并通常需要手动调用来执行。Thunk 函数的执行需要显式地调用,而不像 co 那样自动进行异步流程控制。
  3. 使用场景:

    • co 函数适用于较复杂的异步流程控制,例如需要按顺序执行多个异步操作、处理错误等情况。它在管理多个异步任务时非常有用。
    • Thunk 函数通常用于构建异步库或处理单一异步操作的情况,它更侧重于将异步操作封装成可延迟执行的函数,以便在需要时执行。

简而言之,co 和 Thunk 函数都有各自的用途和优势,选择使用哪个取决于你的具体需求和代码结构。 co 更适合复杂的异步控制流程,而 Thunk 函数更适合将异步操作封装成可延迟执行的函数。当然,实际开发过程中,肯定是直接 async/await呀,香喷喷。