Skip to content

Latest commit

 

History

History
490 lines (384 loc) · 18.8 KB

co源码分析.md

File metadata and controls

490 lines (384 loc) · 18.8 KB

co源码分析

author 愣锤 2022-09-04

背景介绍

ES2017 标准引入了 async 函数,使得我们操作异步变得更加简单了,它让我们真的可以使用同步的语法编写异步的逻辑,算是彻底解决了 javascript 嵌套地狱苦恼。

// 定义一个异步函数
const asyncFn = (timeout) => {
  return new Promise((resolve, reject) => {
    setTimeout(resolve, timeout, 'data');
  });
}

// 依次执行异步函数
async function asyncService() {
  // 等待异步执行的结果
  const result = await asyncFn(3000);
  // 等待异步执行的结果
  const result2 = await asyncFn(1000);
  // 返回结果
  return result + result2;
}

asyncService().then(data => {
  console.log('res', data);
}).catch(err => {
  console.log(err);
});

如上述代码所示,async 函数允许内部 await 异步函数,并且 await 会等待异步逻辑的执行结果,最终 async 函数返回一个 promise 实例。关于 async/await 想必大家都是非常熟悉的了,业务中应该都是在大量使用的。

那么在 async/await 标准被实现之前,是否可以像上述一样使用同步方式编写异步逻辑呢?答案是可以的,下面我们看下 async/await 标准之前的hack方案吧!

CO模块介绍

CO是大名鼎鼎的TJ巨佬编写的一个基于Generator语法实现的用同步方式编写异步逻辑的库,在Node端和浏览器端都可以使用。下面我们看下如何使用CO达到和上述async/await一样的效果:

const co = require('co');

// 定义一个异步函数
const asyncFn = (timeout) => {
  return new Promise((resolve, reject) => {
    setTimeout(resolve, timeout, 'data');
  });
}

const promise = co(function* () {
  // 等待异步执行的结果
  const result = yield asyncFn(3000);
  // 等待异步执行的结果
  const result2 = yield asyncFn(1000);
  // 返回结果
  return result + result2;
});

promise.then(data => {
  console.log(data);
}).catch(err => {
  // ...
});

可以看到,co利用Generator语法同样实现了async/await的效果,并且通过yield可以等待异步执行结果,最终也返回一个promise实例。

除此之外,co内部的yield除了支持异步函数,还可以是Generator构造函数、Generator实例、Thunk函数等。我们再看个复杂的例子:

function* GenFn() {
  return yield Promise.resolve(123);
}

function resolveThunk(done) {
  setTimeout(() => {
    done(null, 'thunk response')
  }, 1000);
}

function rejectThunk(done) {
  setTimeout(() => {
    done(new Error('thunk error'))
  }, 1000);
}

co(function* (){
  try {
    // 等待一个Generator的异步结果
    const res1 = yield GenFn;
    // 等待Generator实例的异步结果
    const res2 = yield GenFn();
    // 输出 123 123
    console.log(res1, res2);

    // 等待一个异步Thunk函数的结果,1s后输出thunk response
    const res3 = yield resolveThunk;
    console.log(res3);

    // 1s后抛出一个错误
    yield rejectThunk;
  } catch (err) {
    // 输出 try/catch error: thunk error
    console.log('try/catch error:', err.message);
  }
  return 'co data';
}).then(data => {
  // 输出 co resolved: co data
  console.log('co resolved:', data);
}).catch((error) => {
  console.log('co rejected:', error);
});

通过上面这个复杂的小例子可以看到,coyield支持的表达式的多样性。通过这里的错误抛出情况可知,yield后面表达式抛出的异步错误会被 function*(){} 内部的 try/catch 捕获,如果没有 try/catch 捕获,则会被上抛到 co 外部,也就是co()调用后返回的 promise 实例的 catch 捕获到。

要知道Generator语法本身是没有这些功能的,co基于Generator实现这一些的功能,真的是非常强悍,不由得让人竖起大拇指。理解Generator也是更好的理解async/await的逻辑。讲解co实现之前,先把Generator基础回顾一下。

Generator

Generator 生成器函数是 ES6 提供的一种异步编程解决方案,并且把js异步编程猛的带到一个新高度。有两个明显的语法特征:

  • function关键字与函数名之间有个 * 号,类似async
  • 函数内部可以使用yield关键词,类似await
// 定义一个Generator函数
function* gen() {
  yield 123;
  yield true;
  return false;
}

调用Generator函数会创建一个Generator对象,但是要注意的是此时Generator函数内部的代码逻辑并不会立即执行,而是需要通过Generator对象调用next方法才会执行。

function* Gen() {
  console.log('Gen run.')
  yield 123;
  yield 456;
}

// 没有任何输出
const gen = Gen();

从这里可以看到,仅调用Gen()函数,其内部代码是没有执行的。接着上面的代码我们继续调用:

// 打印 Gen run.
const res1 = gen.next();
// 输出 { value: 123, done: false }
console.log(res1);

const res2 = gen.next();
// 输出 { value: 456, done: false }
console.log(res2);

const res3 = gen.next();
// 输出 { value: undefined, done: true }
console.log(res3);

可以看到,第一次调用next的时候才开始执行内部代码,并且next调用返回一个对象,包含valuedone两个属性:

  • valueyield后面表达式的执行结果
  • done的值为truefalse,表达当前gen迭代器有没有执行完毕

这里有个重点得提醒一下,gen.next() 返回值中的 valueyield 后面表达式值的执行结果,就是说 yield 后面的表达式的执行结果赋值给的是 gen.next()value 值,而不是 yield expression 的值。

yield expression返回的默认是undefined的值,那么yield expression的值是由谁决定的呢?看下面的例子:

function* Gen() {
  const res = yield 123;
  // 输出456
  console.log(res);
  return res;
}

const gen = Gen();
const res1 = gen.next();
// 输出 { value: 123, done: false }
console.log(res1);

const res2 = gen.next(456);
// 输出 { value: 456, done: true }
console.log(res2);

在调用gen.next()时可以传入一个参数,该参数会作为上一次yield expression的返回值,但是要注意的是,第一次调用gen.next()是不可以传递的,即使传递也没有生效的。为什么呢?因为第一次调用gen.next()是让代码执行到第一个yield位置,还不存在上一个yield

为了让大家理清楚Generator的执行逻辑,总结了下面这张图:

image

Generator 函数返回的遍历器对象,除了拥有next方法外,还有throw方法。throw方法的主要作用是可以在Generator函数外部抛错,然后在函数内部捕获错误。如果函数内部没有捕获错误,则错误会上抛到外部。看下面这个例子:

function* Gen() {
  try {
    yield 123;
  } catch (error) {
    // 输出 inside:  Error: gen throw error
    console.log('inside: ', error);
  }
}

const gen = Gen();

try {
  // 先让gen函数运行到yield
  gen.next();
  // 在外边调用抛错逻辑
  gen.throw(new Error('gen throw error'))
} catch (error) {
  console.log('outside: ', error);
}

到这里,基本上 Generator 的主要用法就涵盖了。总结一下,Generator函数会创建一个生成器对象,巧妙之处在于可以控制内部代码的暂停,并把执行权交给其他协作者(或者通俗讲,交给外部)。

什么意思呢?就是内部的代码每次执行到 yield 命令时都会暂停,只有在外部再次调用 next 方法时才会继续执行到下一个 yield 命令,因此便可以方便 的控制代码的启停。基于此可以实现非常强大的异步用法。接下来我们就看co模块如何基于 Generator 函数实现强大的异步编程吧。

Co源码分析

知其然,知其所以然。

上面知道了co实现的功能是非常强大的,那么我们自然要了解一下其原理实现了,到底是如何玩转generator的?

co的源码仅一个index.js文件,结构相对简单,主要暴露出一个co函数,下面看下主体结构如下:

/**
 * 导出 `co` 模块
 */
module.exports = co['default'] = co.co = co;

/**
 * 执行一个generator函数或generator对象并返回一个promise
 * @param {Function} fn
 * @return {Promise}
 * @api public
 */
function co(gen) {
  var ctx = this;
  var args = slice.call(arguments, 1);

  return new Promise(function(resolve, reject) {
    // ......
  }
}

从上述代码可知道co库导出了一个cmd格式的函数,既包含默认导出也包含了按需导出。co函数内部则返回了一个promise实例,这样便支持了co().then().catch();调用。

co内部返回的是一个Promise实例,因此co调用时其new Promise内部代码是立即执行的,下面我们看Promise内部做了什么事情?

return new Promise(function(resolve, reject) {
  // 调用generator函数,得到迭代器对象
  if (typeof gen === 'function') gen = gen.apply(ctx, args);
  /**
  * 如果gen不存在或者不存在.next方法,
  * 说明不是generator函数,而是普通函数,则直接返回函数执行结果
  */
  if (!gen || typeof gen.next !== 'function') return resolve(gen);
    
  onFulfilled();
    
  function onFulfilled(res) {
    // ...
  }
    
  function onRejected(err) 
    // ...
  }
    
  function next(ret) {
    // ...
  }
}

co()调用时参数可以是Generator函数、Generator实例等,所以上述代码首先判断传入的参数是否是函数,如果是函数则调用该函数得到结果,结果由如下几个情况:

  • 参数是Generator函数则调用后得到Generator实例
  • 参数是普通函数则就是普通函数的执行结果

紧接着判断函数执行结果,如果执行结果不是函数,说明传入的参数不是Generator函数,比如传递的是普通函数,非函数等,则直接resolve函数执行结果或传入的参数。

通过上述的处理,主要保证了拿到的结果一定是个generator实例或者类似generator实例(鸭式辨型思想)。处理完了参数,接下来就是调用onFulfilled函数开始处理co()参数函数的内部逻辑了:

/**
 * @param {Mixed} res
 * @return {Promise}
 * @api private
 */
function onFulfilled(res) {
  var ret;
  try {
    // 调用迭代器的next方法获取yield的结果
    ret = gen.next(res);
  } catch (e) {
    // 调用失败直接reject错误,co().catch()可以捕获错误
    return reject(e);
  }
  // 调用成功继续next执行下去
  next(ret);
  return null;
}

onFulfilled的逻辑是拿到generator对象后,直接调用next方法开始执行generator函数内部逻辑到下一个yield位置处,gen.next(res);执行后得到yield expression的执行结果和当前generator函数是否执行结束的结果,然后将执行结果传递给next函数继续处理。如果gen.next(res);这行逻辑执行过程中出错则捕获错误直接reject

这里有个细节点要注意下,调用gen.next(res)传入了参数,从代码逻辑可以看到,第一次调用onFulfilled时传递的是undefined,后续则是调用onFulfilled时如果传入了参数,该参数是会被作为yield express的返回结果的,这点非常重要,要画重点!重点!重点! 比如下面这个例子,onFulfilled(res)的参数就是asyncFnResoledData。而onFulfilled(res)的参数其实就是yield后面异步函数resolved的值,后续分析会详细解释为什么:

co(function* {
  const asyncFnResoledData = yield asyncFn(); 
});

接下来我们看next函数的逻辑处理:

/**
 * 在generator对象中获取next value,返回promise
 * @param {Object} ret
 * @return {Promise}
 * @api private
 */
function next(ret) {
  // 如果迭代器已经执行到最后,resolve结果,此时co().then()可以拿到结果
  if (ret.done) return resolve(ret.value);
  // 将当前值尝试转换为promise
  var value = toPromise.call(ctx, ret.value);
  /**
   * promise.then时调用onFulfilled,promise.catch时调用onRejected
   * then时把结果给到onFulfilled,onFulfilled内部继续调用gen.next(data),
   * 因此达到了yield的结果就是then时的data结果
   * 注意:调用gen.next()时传入的结果会作为yield的返回数据
   */
  if (value && isPromise(value)) return value.then(onFulfilled, onRejected);
  // 如果yield后面跟的内容最终不能转换成promise则抛出错误
  return onRejected(new TypeError('You may only yield a function, promise, generator, array, or object, '
    + 'but the following object was passed: "' + String(ret.value) + '"'));
}

next的逻辑很关键,也是co的核心实现。这里首先根据gen.next()后的值进行判断:

  • 如果generator已经执行结束,则resolve结果出去,可以在co().then()中获取resolve的值
  • 如果未执行结束,则判断值是否是promise,则通过value.then(onFulfilled, onRejected)处理promise实例的resoledrejected逻辑。

上述这步value.then(onFulfilled, onRejected)逻辑很关键,这也是为什么co内部的yield等待一个异步时可以等待异步的代码执行,就像await一样。

promise实例rejected时则调用onRejected处理错误逻辑,或者根本就不是promise实例时(yield后面的表达式能得到promise的表达式)则调用onRejected抛出一个参数不对的错误。接下来我们看onRejected的逻辑:

/**
 * @param {Error} err
 * @return {Promise}
 * @api private
 */
function onRejected(err) {
  var ret;
  try {
    // 利用gen.throw抛出错误,
    // 如果调用处yield有try/catch则在function*(){}内部的try/catch内捕获到错误
    ret = gen.throw(err);
  } catch (e) {
    // 如果yield处没有trycatch捕获错误,则会被外部捕获,也就是此处
    // 此处捕获到错误后直接reject出去就可以在外部co().catch()处捕获到了
    return reject(e);
  }
  // gen.throw返回{done: boolean, value: any}后继续next
  next(ret);
}

onRejected函数的逻辑就是对reject错误调用gen.throw(err)抛错,注意这里调用的是generator实例的抛错,而不是js语法的throw抛错。这里是因为我们期望如果co()内的generator函数内部有try/catch逻辑时则由内部的try/catch捕获错误,而不是上抛到co.catch(),只有当内部没有try/catch时才错误上抛到外部。

弄清楚这块还是需要上述对generator语法的throw逻辑学习,gen.throw(err)主要作用是在外部抛错在内部捕获,如果内部没有try/catch捕获错误则错误才会上抛到gen.throw(err)调用处或再外部。因此这里如果co(function* { //... })内部没有捕获错误,则错误发生时会被onRejected函数的catch部分捕获,捕获后直接reject出去,就可以被co.catch()逻辑捕获了。内部由catch处理的话则继续调用next往后执行。

至此,co的核心实现已经结束,总结一下核心实现的流程图如下:

image

最后我们再补充一个重要的知识点,co模块如何thunk函数的?

我们从一开始co的学习使用得知,co是至此如下thunk函数的,也就是yield后面的表达式可以是一个thunk函数:

function resolveThunk(done) {
  setTimeout(() => {
    done(null, 'thunk response')
  }, 1000);
}

co(function* () {
  const res = yield resolveThunk;
  
  // ...
});

关键就在在于刚才的next函数内部有下面这一行代码:

// 将当前值尝试转换为promise
var value = toPromise.call(ctx, ret.value);

这里对于yield后面的表达式先进行了一次promise尝试转换,转换的逻辑主要是如果已经是promise了就不再重复转换,否则的根据value的数据类型进行不同的处理,其中有如下逻辑:

function toPromise(obj) {
  // 如果是null | undefined | ''则直接返回,不转换成promise
  if (!obj) return obj;
  // 如果已经是promise,不再重复转换
  if (isPromise(obj)) return obj;
  /**
   * 如果是Generator函数,或者Generator调用后的迭代器,
   * 则直接调用co()执行其内部逻辑,co后最终返回一个promise,
   * 通过此方式使得yield后面支持了Generator函数或者Generator迭代器
   */
  if (isGeneratorFunction(obj) || isGenerator(obj)) return co.call(this, obj);

  // 如果是函数,则支持thunk风格
  if ('function' == typeof obj) return thunkToPromise.call(this, obj);

  // 如果yield的是数组,则对数组每一项转换成promise,并用Promise.all包裹
  // 即所有pormise都resolved才resolved
  if (Array.isArray(obj)) return arrayToPromise.call(this, obj);
  if (isObject(obj)) return objectToPromise.call(this, obj);
  return obj;
}

比如这里就是判断如果是函数,比如thunk函数,就直接调用thunkToPromise转换,其他还有需要主要的就是如果yield后面是Generator或者Generator实例则先调用co进行结果获取,就像套娃一样。接下来我们重点看thunkToPromise的实现:

/**
 * 将一个thunk函数转换成一个promise
 *
 * @param {Function}
 * @return {Promise}
 * @api private
 */
function thunkToPromise(fn) {
  var ctx = this;
  // 返回一个promise
  return new Promise(function (resolve, reject) {
    // 核心做法在于把resolve和reject的机会交由用户触发
    // 触发逻辑是用户的 function thunk(done) {} 函数内部调用done时传入的参数
    // 如果第一个参数传入了有效值则reject,否则第二位及以后的参数都作为resolve值
    // 参数格式是nodejs风格的
    fn.call(ctx, function (err, res) {
      if (err) return reject(err);
      if (arguments.length > 2) res = slice.call(arguments, 1);
      resolve(res);
    });
  });
}

thunkToPromise其实就是一次对yield后面函数的一次包装调用并返回一个promise实例。这里封装的思路是:

  • new Promise是立即执行的,因此fn.call(ctx, function() {})直接调用用户的thunk函数
    • fnthunk函数
    • fn的第二个参数是传递给thunk函数的done参数
  • 调用thunk时传递了一个done函数让使用者根据业务逻辑调用done函数
    • 通过这种方式支持的异步,比如用户可以在一个异步resolvedrejected时进行done
  • done参数调用时会根据传给done的参数格式对thunk进行resolvereject
    • done的参数格式是符合nodejs标准的,第一个参数表示错误,后续参数都是resolved的值。

总结

co的核心实现就是利用generator控制代码执行的启动停止,并处理yield异步表达式的resolvedrejected状态。