{"id":16634928,"url":"https://github.com/ruochuan12/koa-compose-analysis","last_synced_at":"2025-10-30T06:30:23.043Z","repository":{"id":53570369,"uuid":"402423473","full_name":"ruochuan12/koa-compose-analysis","owner":"ruochuan12","description":"50行代码串行Promise，koa洋葱模型原来是这么实现？","archived":false,"fork":false,"pushed_at":"2021-10-01T03:41:40.000Z","size":713,"stargazers_count":27,"open_issues_count":0,"forks_count":4,"subscribers_count":2,"default_branch":"main","last_synced_at":"2025-02-02T06:31:45.412Z","etag":null,"topics":["javascript","koa"],"latest_commit_sha":null,"homepage":"https://juejin.cn/post/7005375860509245471","language":"JavaScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":null,"status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/ruochuan12.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null}},"created_at":"2021-09-02T13:06:10.000Z","updated_at":"2024-12-29T13:46:14.000Z","dependencies_parsed_at":"2022-09-19T18:02:03.382Z","dependency_job_id":null,"html_url":"https://github.com/ruochuan12/koa-compose-analysis","commit_stats":null,"previous_names":["ruochuan12/koa-compose-analysis"],"tags_count":0,"template":false,"template_full_name":null,"repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/ruochuan12%2Fkoa-compose-analysis","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/ruochuan12%2Fkoa-compose-analysis/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/ruochuan12%2Fkoa-compose-analysis/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/ruochuan12%2Fkoa-compose-analysis/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/ruochuan12","download_url":"https://codeload.github.com/ruochuan12/koa-compose-analysis/tar.gz/refs/heads/main","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":238937490,"owners_count":19555376,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["javascript","koa"],"created_at":"2024-10-12T05:49:30.586Z","updated_at":"2025-10-30T06:30:22.683Z","avatar_url":"https://github.com/ruochuan12.png","language":"JavaScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"# 50行代码串行Promise，koa洋葱模型原来是这么实现？\n\n## 1. 前言\n\n大家好，我是[若川](https://lxchuan12.gitee.io)。欢迎关注我的[公众号若川视野](https://p1-jj.byteimg.com/tos-cn-i-t2oaga2asx/gold-user-assets/2019/12/13/16efe57ddc7c9eb3~tplv-t2oaga2asx-image.image \"https://p1-jj.byteimg.com/tos-cn-i-t2oaga2asx/gold-user-assets/2019/12/13/16efe57ddc7c9eb3~tplv-t2oaga2asx-image.image\")，最近组织了[**源码共读活动**《1个月，200+人，一起读了4周源码》](https://mp.weixin.qq.com/s?__biz=MzA5MjQwMzQyNw==\u0026mid=2650756550\u0026idx=1\u0026sn=9acc5e30325963e455f53ec2f64c1fdd\u0026chksm=8866564abf11df5c41307dba3eb84e8e14de900e1b3500aaebe802aff05b0ba2c24e4690516b\u0026token=917686367\u0026lang=zh_CN#rd)，感兴趣的可以加我微信 [ruochuan12](https://mp.weixin.qq.com/s?__biz=MzA5MjQwMzQyNw==\u0026mid=2650756550\u0026idx=1\u0026sn=9acc5e30325963e455f53ec2f64c1fdd\u0026chksm=8866564abf11df5c41307dba3eb84e8e14de900e1b3500aaebe802aff05b0ba2c24e4690516b\u0026token=917686367\u0026lang=zh_CN#rd) 参与，长期交流学习。\n\n之前写的[《学习源码整体架构系列》](https://juejin.cn/column/6960551178908205093) 包含`jQuery`、`underscore`、`lodash`、`vuex`、`sentry`、`axios`、`redux`、`koa`、`vue-devtools`、`vuex4`十余篇源码文章。其中最新的两篇是：\n\n[Vue 3.2 发布了，那尤雨溪是怎么发布 Vue.js 的？](https://juejin.cn/post/6997943192851054606)\n\n[初学者也能看懂的 Vue3 源码中那些实用的基础工具函数](https://juejin.cn/post/6994976281053888519)\n\n写相对很难的源码，耗费了自己的时间和精力，也没收获多少阅读点赞，其实是一件挺受打击的事情。从阅读量和读者受益方面来看，不能促进作者持续输出文章。\n\n所以转变思路，写一些相对通俗易懂的文章。**其实源码也不是想象的那么难，至少有很多看得懂**。\n\n之前写过 koa 源码文章[学习 koa 源码的整体架构，浅析koa洋葱模型原理和co原理](https://juejin.cn/post/6844904088220467213)比较长，读者朋友大概率看不完，所以本文从`koa-compose`50行源码讲述。\n\n本文涉及到的 [koa-compose 仓库](https://github.com/koajs/compose) 文件，整个`index.js`文件代码行数虽然不到 `50` 行，而且测试用例`test/test.js`文件 `300` 余行，但非常值得我们学习。\n\n歌德曾说：读一本好书，就是在和高尚的人谈话。 同理可得：读源码，也算是和作者的一种学习交流的方式。\n\n阅读本文，你将学到：\n\n```bash\n1. 熟悉 koa-compose 中间件源码、可以应对面试官相关问题\n2. 学会使用测试用例调试源码\n3. 学会 jest 部分用法\n```\n\n## 2. 环境准备\n\n### 2.1 克隆 koa-compose 项目\n\n[本文仓库地址 koa-compose-analysis](https://github.com/lxchuan12/koa-compose-analysis.git)，求个`star`~\n\n```bash\n# 可以直接克隆我的仓库，我的仓库保留的 compose 仓库的 git 记录\ngit clone https://github.com/lxchuan12/koa-compose-analysis.git\ncd koa-compose/compose\nnpm i\n```\n\n顺带说下：我是怎么保留 `compose` 仓库的 `git` 记录的。\n\n```bash\n# 在 github 上新建一个仓库 `koa-compose-analysis` 克隆下来\ngit clone https://github.com/lxchuan12/koa-compose-analysis.git\ncd koa-compose-analysis\ngit subtree add --prefix=compose https://github.com/koajs/compose.git main\n# 这样就把 compose 文件夹克隆到自己的 git 仓库了。且保留的 git 记录\n```\n\n关于更多 `git subtree`，可以看这篇文章[用 Git Subtree 在多个 Git 项目间双向同步子项目，附简明使用手册](https://segmentfault.com/a/1190000003969060)\n\n接着我们来看怎么根据开源项目中提供的测试用例调试源码。\n\n### 2.2 根据测试用例调试 compose 源码\n\n用`VSCode`（我的版本是 `1.60` ）打开项目，找到 `compose/package.json`，找到 `scripts` 和 `test` 命令。\n\n```json\n// compose/package.json\n{\n    \"name\": \"koa-compose\",\n    // debug （调试）\n    \"scripts\": {\n        \"eslint\": \"standard --fix .\",\n        \"test\": \"jest\"\n    },\n}\n```\n\n在`scripts`上方应该会有`debug`或者`调试`字样。点击`debug`(调试)，选择 `test`。\n\n![VSCode 调试](./images/scripts-test-debugger.png)\n\n接着会执行测试用例`test/test.js`文件。终端输出如下图所示。\n\n![koa-compose 测试用例输出结果](./images/jest-ternimal.png)\n\n接着我们调试 `compose/test/test.js` 文件。\n我们可以在 `45行` 打上断点，重新点击 `package.json` =\u003e `srcipts` =\u003e `test` 进入调试模式。\n如下图所示。\n\n![koa-compose 调试](./images/test-compose-debugger.png)\n\n接着按上方的按钮，继续调试。在`compose/index.js`文件中关键的地方打上断点，调试学习源码事半功倍。\n\n[更多 nodejs 调试相关 可以查看官方文档](https://code.visualstudio.com/docs/nodejs/nodejs-debugging)\n\n顺便详细解释下几个调试相关按钮。\n\n- 1. 继续（F5）: 点击后代码会直接执行到下一个断点所在位置，如果没有下一个断点，则认为本次代码执行完成。\n- 2. 单步跳过（F10）：点击后会跳到当前代码下一行继续执行，不会进入到函数内部。\n- 3. 单步调试（F11）：点击后进入到当前函数的内部调试，比如在 `compose` 这一行中执行单步调试，会进入到 `compose` 函数内部进行调试。\n- 4. 单步跳出（Shift + F11）：点击后跳出当前调试的函数，与单步调试对应。\n- 5. 重启（Ctrl + Shift + F5）：顾名思义。\n- 6. 断开链接（Shift + F5）：顾名思义。\n\n接下来，我们跟着测试用例学源码。\n\n## 3. 跟着测试用例学源码\n\n分享一个测试用例小技巧：我们可以在测试用例处加上`only`修饰。\n\n```js\n// 例如\nit.only('should work', async () =\u003e {})\n```\n\n这样我们就可以只执行当前的测试用例，不关心其他的，不会干扰调试。\n\n### 3.1 正常流程\n\n打开 `compose/test/test.js` 文件，看第一个测试用例。\n\n```js\n// compose/test/test.js\n'use strict'\n\n/* eslint-env jest */\n\nconst compose = require('..')\nconst assert = require('assert')\n\nfunction wait (ms) {\n  return new Promise((resolve) =\u003e setTimeout(resolve, ms || 1))\n}\n// 分组\ndescribe('Koa Compose', function () {\n  it.only('should work', async () =\u003e {\n    const arr = []\n    const stack = []\n\n    stack.push(async (context, next) =\u003e {\n      arr.push(1)\n      await wait(1)\n      await next()\n      await wait(1)\n      arr.push(6)\n    })\n\n    stack.push(async (context, next) =\u003e {\n      arr.push(2)\n      await wait(1)\n      await next()\n      await wait(1)\n      arr.push(5)\n    })\n\n    stack.push(async (context, next) =\u003e {\n      arr.push(3)\n      await wait(1)\n      await next()\n      await wait(1)\n      arr.push(4)\n    })\n\n    await compose(stack)({})\n    // 最后输出数组是 [1,2,3,4,5,6]\n    expect(arr).toEqual(expect.arrayContaining([1, 2, 3, 4, 5, 6]))\n  })\n}\n```\n\n大概看完这段测试用例，`context`是什么，`next`又是什么。\n\n在[`koa`的文档](https://github.com/koajs/koa/blob/master/docs/guide.md#writing-middleware)上有个非常代表性的中间件 `gif` 图。\n\n![中间件 gif 图](./images/middleware.gif)\n\n而`compose`函数作用就是把添加进中间件数组的函数按照上面 `gif` 图的顺序执行。\n\n#### 3.1.1 compose 函数\n\n简单来说，`compose` 函数主要做了两件事情。\n\n- 1. 接收一个参数，校验参数是数组，且校验数组中的每一项是函数。\n- 2. 返回一个函数，这个函数接收两个参数，分别是`context`和`next`，这个函数最后返回`Promise`。\n\n```js\n/**\n * Compose `middleware` returning\n * a fully valid middleware comprised\n * of all those which are passed.\n *\n * @param {Array} middleware\n * @return {Function}\n * @api public\n */\nfunction compose (middleware) {\n  // 校验传入的参数是数组，校验数组中每一项是函数\n  if (!Array.isArray(middleware)) throw new TypeError('Middleware stack must be an array!')\n  for (const fn of middleware) {\n    if (typeof fn !== 'function') throw new TypeError('Middleware must be composed of functions!')\n  }\n\n  /**\n   * @param {Object} context\n   * @return {Promise}\n   * @api public\n   */\n\n  return function (context, next) {\n    // last called middleware #\n    let index = -1\n    return dispatch(0)\n    function dispatch(i){\n      // 省略，下文讲述\n    }\n  }\n}\n```\n\n接着我们来看 `dispatch` 函数。\n\n#### 3.1.2 dispatch 函数\n\n```js\nfunction dispatch (i) {\n  // 一个函数中多次调用报错\n  // await next()\n  // await next()\n  if (i \u003c= index) return Promise.reject(new Error('next() called multiple times'))\n  index = i\n  // 取出数组里的 fn1, fn2, fn3...\n  let fn = middleware[i]\n  // 最后 相等，next 为 undefined\n  if (i === middleware.length) fn = next\n  // 直接返回 Promise.resolve()\n  if (!fn) return Promise.resolve()\n  try {\n    return Promise.resolve(fn(context, dispatch.bind(null, i + 1)))\n  } catch (err) {\n    return Promise.reject(err)\n  }\n}\n```\n\n值得一提的是：`bind`函数是返回一个新的函数。第一个参数是函数里的this指向（如果函数不需要使用`this`，一般会写成`null`）。\n这句`fn(context, dispatch.bind(null, i + 1)`，`i + 1` 是为了 `let fn = middleware[i]` 取`middleware`中的下一个函数。\n也就是 `next` 是下一个中间件里的函数。也就能解释上文中的 `gif`图函数执行顺序。\n测试用例中数组的最终顺序是`[1,2,3,4,5,6]`。\n\n#### 3.1.3 简化 compose 便于理解\n\n自己动手调试之后，你会发现 `compose` 执行后就是类似这样的结构（省略 `try catch` 判断）。\n\n```js\n// 这样就可能更好理解了。\n// simpleKoaCompose\nconst [fn1, fn2, fn3] = stack;\nconst fnMiddleware = function(context){\n    return Promise.resolve(\n      fn1(context, function next(){\n        return Promise.resolve(\n          fn2(context, function next(){\n              return Promise.resolve(\n                  fn3(context, function next(){\n                    return Promise.resolve();\n                  })\n              )\n          })\n        )\n    })\n  );\n};\n```\n\n\u003e也就是说`koa-compose`返回的是一个`Promise`，从`中间件（传入的数组）`中取出第一个函数，传入`context`和第一个`next`函数来执行。\u003cbr\u003e\n\u003e第一个`next`函数里也是返回的是一个`Promise`，从`中间件（传入的数组）`中取出第二个函数，传入`context`和第二个`next`函数来执行。\u003cbr\u003e\n\u003e第二个`next`函数里也是返回的是一个`Promise`，从`中间件（传入的数组）`中取出第三个函数，传入`context`和第三个`next`函数来执行。\u003cbr\u003e\n\u003e第三个...\u003cbr\u003e\n\u003e以此类推。最后一个中间件中有调用`next`函数，则返回`Promise.resolve`。如果没有，则不执行`next`函数。\n这样就把所有中间件串联起来了。这也就是我们常说的洋葱模型。\u003cbr\u003e\n\n![洋葱模型图如下图所示：](./images/middleware.png)\n\n**不得不说非常惊艳，“玩还是大神会玩”**。\n\n### 3.2 错误捕获\n\n```js\nit('should catch downstream errors', async () =\u003e {\n  const arr = []\n  const stack = []\n\n  stack.push(async (ctx, next) =\u003e {\n    arr.push(1)\n    try {\n      arr.push(6)\n      await next()\n      arr.push(7)\n    } catch (err) {\n      arr.push(2)\n    }\n    arr.push(3)\n  })\n\n  stack.push(async (ctx, next) =\u003e {\n    arr.push(4)\n    throw new Error()\n  })\n\n  await compose(stack)({})\n  // 输出顺序 是 [ 1, 6, 4, 2, 3 ]\n  expect(arr).toEqual([1, 6, 4, 2, 3])\n})\n```\n\n相信理解了第一个测试用例和 `compose` 函数，也是比较好理解这个测试用例了。这一部分其实就是对应的代码在这里。\n\n```js\ntry {\n    return Promise.resolve(fn(context, dispatch.bind(null, i + 1)))\n} catch (err) {\n  return Promise.reject(err)\n}\n```\n\n### 3.3 next 函数不能调用多次\n\n```js\nit('should throw if next() is called multiple times', () =\u003e {\n  return compose([\n    async (ctx, next) =\u003e {\n      await next()\n      await next()\n    }\n  ])({}).then(() =\u003e {\n    throw new Error('boom')\n  }, (err) =\u003e {\n    assert(/multiple times/.test(err.message))\n  })\n})\n```\n\n这一块对应的则是：\n\n```js\nindex = -1\ndispatch(0)\nfunction dispatch (i) {\n  if (i \u003c= index) return Promise.reject(new Error('next() called multiple times'))\n  index = i\n}\n```\n\n调用两次后 `i` 和 `index` 都为 `1`，所以会报错。\n\n`compose/test/test.js`文件中总共 300余行，还有很多测试用例可以按照文中方法自行调试。\n\n## 4. 总结\n\n虽然`koa-compose`源码 50行 不到，但如果是第一次看源码调试源码，还是会有难度的。其中混杂着高阶函数、闭包、`Promise`、`bind`等基础知识。\n\n通过本文，我们熟悉了 `koa-compose` 中间件常说的洋葱模型，学会了部分 [`jest`](https://github.com/facebook/jest) 用法，同时也学会了如何使用现成的测试用例去调试源码。\n\n**相信学会了通过测试用例调试源码后，会觉得源码也没有想象中的那么难**。\n\n开源项目，一般都会有很全面的测试用例。除了可以给我们学习源码调试源码带来方便的同时，也可以给我们带来的启发：自己工作中的项目，也可以逐步引入测试工具，比如 [`jest`](https://github.com/facebook/jest)。\n\n此外，读开源项目源码是我们学习业界大牛设计思想和源码实现等比较好的方式。\n\n看完本文，非常希望能自己动手实践调试源码去学习，容易吸收消化。另外，如果你有余力，可以继续看我的 `koa-compose` 源码文章：[学习 koa 源码的整体架构，浅析koa洋葱模型原理和co原理](https://juejin.cn/post/6844904088220467213)\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fruochuan12%2Fkoa-compose-analysis","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fruochuan12%2Fkoa-compose-analysis","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fruochuan12%2Fkoa-compose-analysis/lists"}