Async/await 原理详解:co 函数是什么

之前一直有一个概念, async/await是一个语法糖,它的出现让代码写起来更加流畅,使用的话只需要知道它是一个"让异步代码写起来像同步"的方案,返回的是一个promise就够了,但如果深入去探索,你会发现一些很有意思的事情,比如今天说的这个co函数

这篇文章分两部分:第一部分 从最朴素的想法出发,一步步把 co 迭代到生产级;第二部分逐行剖析 tj/co 的真实源码,看看工业级实现比我们的手写版多考虑了什么。

读之前再来回顾生成器的两个基本事实:

  1. 生成器 yield X暂停 ,并把 X 通过 g.next() 的返回值吐出来;
  2. 下次 g.next(value)恢复 执行,并把 value 塞回给上一个 yield 表达式------这就是 yield 的"双向传值"。

第一部分:手写一个co函数

第 0 步:明确co的目标

co 的目标:让你能用同步的写法写异步。本来要这样写:

js 复制代码
fetchUser()
  .then(user => fetchPosts(user))
  .then(posts => console.log(posts));

我们希望能这样写(用生成器):

js 复制代码
co(function* () {
  const user = yield fetchUser();
  const posts = yield fetchPosts(user);
  console.log(posts);
});

但是,如果按照现在的模式,我们需要一次一次手动的去next(),想要自动化的去驱动,那就是co 要做的事情。具体来说:拿到 yield 出来的 Promise → 等它 resolve → 把结果用 next(结果) 塞回去 → 生成器继续 → 拿到下一个 Promise...... 循环往复,直到生成器结束。

第 1 步:写一个最原始的生成器

什么都不说,先看代码:

js 复制代码
function* gen() {
  const user = yield fetchUser();
  const posts = yield fetchPosts(user);
  console.log(posts);
}

const g = gen();

const r1 = g.next();          // 启动,跑到第一个 yield,r1.value 是 fetchUser() 的 Promise
r1.value.then(user => {       // 等它 resolve
  const r2 = g.next(user);    // 把 user 塞回去,跑到第二个 yield,r2.value 是第二个 Promise
  r2.value.then(posts => {
    g.next(posts);            // 把 posts 塞回去,生成器执行完,打印 posts
  });
});

这是一个最基础的生成器,但问题也很明显:yield 有几个,就要手写几层嵌套 。这不就是回调函数的另外一层外衣嘛?更好的方式是把这个"等 → 塞回 → 继续"的重复动作变成一个自动循环

第 2 步:简化版自动化

最简单的实现自动化调用的方式:递归。具体的代码如下:

js 复制代码
function co(generator) {
  const g = generator();
  function step(value) {
    const result = g.next(value);   // 把上次结果塞回去,推进到下一个 yield
    // result = { value: 吐出来的 Promise, done: 是否结束 }
    if (result.done) return;        // 生成器跑完了,收工
    result.value.then(res => {
      step(res);                    // 等 Promise 好了,把结果塞回去,进入下一轮
    });
  }
  step();   // 启动:第一次 next 不需要传值
}

这就是 co 的雏形。它把第 1 步那些嵌套全压成了一个递归循环:

js 复制代码
co(function* () {
  const user = yield fetchUser();
  const posts = yield fetchPosts(user);
  console.log(posts);
});
第 3 步:异常处理

上一个步骤中,只考虑了成功分支,现在需要添加异常处理。

首先引入生成器的第二个驱动方法:g.throw(err) 。它的作用是在生成器当前暂停的那个 yield 处抛出一个错误 ;如果那行外面包着 try,错误就会落进 catch。这正是异步错误能被同步 try/catch 捕获的底层机制。具体请看代码:

js 复制代码
function co(generator) {
  const g = generator();

  function step(nextFn) {
    let result;
    try {
      result = nextFn();   // nextFn 要么是 () => g.next(v),要么是 () => g.throw(e)
    } catch (e) {
      return Promise.reject(e); // 生成器内部没 catch 住,整体失败
    }
    if (result.done) {
      return Promise.resolve(result.value);   // 生成器的 return 值,作为最终结果
    }
    return Promise.resolve(result.value).then(
      res => step(() => g.next(res)), // 成功:下一轮用 next 塞结果
      err => step(() => g.throw(err)) // 失败:下一轮用 throw 塞错误
    );
  }
  return step(() => g.next());   // 启动
}

相比第 2 步,这一版多了三个关键点:

错误处理对称。 成功走 g.next(res)、失败走 g.throw(err),且两者都回到同一个 step 继续递归驱动,哪怕 catch 里又 yield 也能接着推进。外层的 try/catch 则兜住生成器内部未被捕获的错误。

co 返回一个 Promise。 return step(() => g.next()) 让整个 co 调用的结果是一个 Promise:生成器正常跑完就 resolve(值是 return 值),中途有未捕获错误就 reject。这正好呼应那个事实------async 函数永远返回一个 Promise

兼容普通值。Promise.resolve(result.value) 包一层,让 yield 出来的即使是普通值(非 Promise)也能正常处理。

js 复制代码
const willFail = () => Promise.reject(new Error('请求挂了'));

co(function* () {
  try {
    yield willFail();
  } catch (e) {
    console.log('catch 抓到了:', e.message);   // catch 抓到了: 请求挂了
  }
});
第 4 步:最终版本

整理一下最终的版本:

js 复制代码
function co(generator) {
  const g = generator();

  function step(nextFn) {
    let result;
    try {
      result = nextFn();
    } catch (e) {
      return Promise.reject(e);
    }

    if (result.done) {
      return Promise.resolve(result.value);   // 生成器的 return 值,作为最终结果
    }

    return Promise.resolve(result.value).then(
      res => step(() => g.next(res)),
      err => step(() => g.throw(err))
    );
  }

  return step(() => g.next());
}

// ------ 验证:成功链路 + 错误链路 + 返回值 ------
const delay = (v, ms) => new Promise(r => setTimeout(() => r(v), ms));

co(function* () {
  const a = yield delay(1, 100);
  const b = yield delay(2, 100);
  try {
    yield Promise.reject(new Error('boom'));
  } catch (e) {
    console.log('抓到错误:', e.message);   // 抓到错误: boom
  }
  return a + b;
}).then(result => {
  console.log('生成器返回:', result);       // 生成器返回: 3
});

把这个最终版和 async/await 的用法对比一下,差别只剩"语法 + 谁来调 step":

js 复制代码
// 手写 co + 生成器                        // async/await(引擎内置了 co)
co(function* () {                          async function () {
  const a = yield delay(1, 100);             const a = await delay(1, 100);
  const b = yield delay(2, 100);             const b = await delay(2, 100);
  return a + b;                              return a + b;
}).then(r => {});                          }().then(r => {});
手写 co async/await
function* async function
yield await
g.next(res) 塞回成功值 引擎内置
g.throw(err) 塞回错误 引擎内置(让 try/catch 生效)
co 返回 Promise async 函数自动返回 Promise

到这里,我们手写的 co 已经具备了生产级的骨架:自动驱动、错误处理、返回 Promise。接下来看看真实的 tj/co 是如何实现工业化的。

第二部分:tj/co 源码逐段剖析

tj/co 的核心文件 index.js 总共两百多行,有两个主要差异:一是更细致的错误处理结构,二是支持了 Promise 之外的多种"可 yield 值"。

入口:co 函数本身
js 复制代码
function co(gen) {
  var ctx = this;
  var args = slice.call(arguments, 1);

  // 把所有逻辑包进一个 Promise,避免 promise chaining 导致的内存泄漏
  return new Promise(function(resolve, reject) {
    if (typeof gen === 'function') gen = gen.apply(ctx, args);
    if (!gen || typeof gen.next !== 'function') return resolve(gen);

    onFulfilled();   // 启动

    // ... onFulfilled / onRejected / next 定义在这里
  });
}

这里有几个值得注意的设计:

整体包在 new Promise 里。 之所以用一个大 Promise 包住、而不是层层 .then 链下去,是为了避免 promise chaining 引发的内存泄漏,这是工业级实现才会在意的细节。

入参更宽容。 if (typeof gen === 'function') gen = gen.apply(ctx, args)------co 既接受"生成器对象",也接受"生成器函数"(是函数就先调用它拿到生成器对象)。后面那行 if (!gen || typeof gen.next !== 'function') return resolve(gen) 是兜底:如果传进来的根本不是生成器(没有 next 方法),就直接把它当普通值 resolve 掉,不报错。

成功路径:onFulfilled
js 复制代码
function onFulfilled(res) {
  var ret;
  try {
    ret = gen.next(res);     // 把上一步结果塞回去,推进生成器
  } catch (e) {
    return reject(e);        // 生成器内部抛错且没 catch,整体 reject
  }
  next(ret);                 // 把 {value, done} 交给 next 处理
  return null;
}

gen.next(res) 把上一次 Promise 的结果塞回生成器(yield 的双向传值);如果生成器内部抛了未捕获的错误,catch 住并 reject 整个 co。拿到 {value, done} 后交给 next 决定下一步。

失败路径:onRejected
js 复制代码
function onRejected(err) {
  var ret;
  try {
    ret = gen.throw(err);    // 把错误抛进生成器暂停处
  } catch (e) {
    return reject(e);        // 生成器没 catch 住这个错误,整体 reject
  }
  next(ret);
}

onRejectedgen.throw(err) 把错误送回生成器暂停的那一行 ------如果那行外面有 try/catch,错误就被业务代码自己捕获了(于是生成器能继续 next);如果没捕获,gen.throw 会把错误继续往外冒,被这里的 catch 兜住、reject 整个 co。

onFulfilledonRejected 结构对称,正好对应我们第 3 步里"next 与 throw 走统一路径"的设计。

调度核心:next
js 复制代码
function next(ret) {
  if (ret.done) return resolve(ret.value);              // ① 生成器结束,用 return 值 resolve
  var value = toPromise.call(ctx, ret.value);           // ② 把 yield 出来的值转成 Promise
  if (value && isPromise(value))                        // ③ 是 Promise,就挂上成功/失败回调
    return value.then(onFulfilled, onRejected);
  return onRejected(new TypeError(                      // ④ 不是可识别的 yieldable,报错
    'You may only yield a function, promise, generator, array, or object, '
    + 'but the following object was passed: "' + String(ret.value) + '"'));
}

这个 next 是整个 co 的调度中枢,四行对应四种情况:

ret.done 为真------生成器跑完了,用它的 return 值 resolve 整个 co。和我们手写版完全一致。

toPromise 是 tj/co 比我们手写版强大的关键。 我们的手写版只用 Promise.resolve(result.value) 处理"Promise 或普通值"两种;而 tj/co 把 yield 出来的值先送进 toPromise,统一转成 Promise------它支持的"可 yield 值"多得多(下一节细说)。

③ 转成 Promise 后,挂上 onFulfilled(成功)和 onRejected(失败)两个回调------这一行就是整个递归循环的"咬合点"。

④ 如果 yield 出来的东西连 toPromise 都转不了(既不是 Promise、也不是函数/数组/对象),就抛 TypeError 明确报错。

加分项:toPromise 支持多种"可 yield 值"
js 复制代码
function toPromise(obj) {
  if (!obj) return obj;
  if (isPromise(obj)) return obj;                                   // 已经是 Promise
  if (isGeneratorFunction(obj) || isGenerator(obj))                 // 生成器:递归用 co 处理
    return co.call(this, obj);
  if ('function' == typeof obj) return thunkToPromise.call(this, obj); // thunk 函数
  if (Array.isArray(obj)) return arrayToPromise.call(this, obj);    // 数组:Promise.all 并发
  if (isObject(obj)) return objectToPromise.call(this, obj);        // 对象:值并发后组装
  return obj;                                                        // 其它,原样返回
}

这是 tj/co 真正比手写版"工业化"的地方。

  • Promise------直接返回。
  • 生成器 / 生成器函数 ------递归调 co 处理,所以 co 支持嵌套的生成器。
  • thunk 函数 ------一种 fn(callback) 形式的旧式异步约定,thunkToPromise 把它转成 Promise(这是 co 早期版本的主要支持对象,现在更推荐 Promise)。
  • 数组 ------arrayToPromise 内部用 Promise.all,于是 yield [p1, p2] 能并发等待多个 Promise。
  • 对象 ------objectToPromise 把对象各个值并发跑完再按 key 组装回来,于是 yield { a: p1, b: p2 } 能拿到 { a: 结果1, b: 结果2 }

这些"可 yield 值"的扩展,是 co 作为一个真实库的便利性所在,但它们都建立在最核心的那条递归循环之上------本质没变。

顺带一提:co.wrap
js 复制代码
co.wrap = function (fn) {
  createPromise.__generatorFunction__ = fn;
  return createPromise;
  function createPromise() {
    return co.call(this, fn.apply(this, arguments));
  }
};

co() 是"立即执行一个生成器",而 co.wrap(fn*) 是把一个生成器函数转成一个普通函数,调用它返回 Promise。对比一下就明白它对应什么:

js 复制代码
const fn = co.wrap(function* (id) {
  return yield fetchUser(id);
});
fn(1).then(user => {});

// 几乎等价于现代写法:
async function fn(id) {
  return await fetchUser(id);
}
fn(1).then(user => {});

co.wrap 产出的"接受参数、返回 Promise 的函数",正是 async function 的形态。可以说 co.wrap 就是 async 关键字的前身。