为 Koa 框架封装 webpack-dev-middleware 中间件

这篇文章能带给你什么

我见到有不少朋友在 SegmentFault 上面问一些不太好回答的问题,“JavaScript/Node 学好了能作什么?”,“前端架构师天天都作些什么?”等等。这些问题并不是不能回答,可是第1、问题自己太过泛泛,很难回答的既针对又具体;第2、面对这样的问题一时也想不出从何处着手来回答。我本身以为若是能配合一个实例来讲明一下会比泛泛而谈更有价值,因此这篇文章等待了很久,就为了等一个合适的例子。javascript

恰逢最近一个项目应用了 React+Redux 的同构化应用程序架构,在实施这个项目的过程当中也使用了 express+webpack 的组合,在整个过程当中特别是 SSR(服务端渲染)这一起解决了很多的技术问题,我以为值得总结一下经验。项目临近结束,我打算把里面的核心部分抽取出来重构一下作个开源项目,因而这里面就有了一些值得拿出来分享的例子,而且分享的目的不仅是问题的解决方法自己,更重要的是一些额外的东西,那些能多多少少回答上述问题的东西。好比思路分析、原理阐释、源代码阅读、编写/调试技巧等等。html

若是接下来我能有比较充裕的时间,那就会不止这一篇,个人目标对象是那些初学者和处于进阶门槛的人,但愿能对大家的胃口。接下来是第一个例子:为 Koa 框架封装 webpack-dev-middleware 中间件。前端

关于 Koa

我并不是经验丰富的全栈型工程师,UI 编程方面还能够,服务端则只会些皮毛。前面提到的项目是我第一次用 express 编写真实上线的后端服务,而且也只是作了 SSR 而已。对于 express 我没什么特别的感受——既没以为很差,也没以为出色,所以在重构的时候我打算试试历来没有玩过的 Koa。java

不少人都会问“学一个新的框架/工具最好的方法/途径是什么?”,我历来不回答这类的问题,由于我认为这是一个见仁见智的问题,并且我还认为只会遵从于别人的规划是学不到东西的,所谓“因材施教”就是这个意思。固然你能够说“我是为了借鉴你们的经验”,然而如果为此的话其实能够问得更巧妙或是具体一些。webpack

在 Koa 这个具体的例子上个人方法其实很简单,就是把一个用 express 写过的项目用 Koa 重构一遍。不过对此个人要求很高,这些要求是方方面面的,其中有一个和本文有关,即:使用到的全部第三方的库都要读懂其原理,若不费事的话就本身造一遍。学习的方法千千万,不过里面总有些通用的法则,个人法则就是求稳不求快。事实上这个法则在后面帮了个人大忙,由于熟悉了几个典型的 Koa 中间件后,在处理 webpack-dev-middleware 的封装时就以为简单不少。git

关于 webpack-dev-middleware

对于 webpack-dev-middleware,最直观简单的理解就是一个运行于内存中的文件系统。你定义了 webpack.config,webpack 就能据此梳理出全部模块的关系脉络,而 webpack-dev-middleware 就在此基础上造成一个微型的文件映射系统,每当应用程序请求一个文件——好比说你定义的某个 entry,它匹配到了就把内存中缓存的对应结果做为文件内容返回给你,反之则进入到下一个中间件。程序员

由于是内存型的文件系统,因此 rebuilding 的速度很是快,所以特别适合于开发阶段用做静态资源服务器;又由于 webpack 能够把任何一种资源都看成是模块来处理,所以它彻底能够取代其余的 HTTP 服务器。事实上,大多数 webpack 用户用过的 webpack-dev-server 就是一个 express+webpack-dev-middleware 的实现。两者的区别仅在于 webpack-dev-server 是封装好的,除了 webpack.config 和命令行参数以外,你很难去作定制型开发,因此它是适合纯前端项目的辅助工具。而 webpack-dev-middleware 是中间件,你能够编写本身的后端服务而后把它整合进来,相对而言就自由得多了。咱们作的是一个先后同构的应用,所以 webpack-dev-server 就不予考虑了。github

问题所在

问题在于 webpack-dev-middleware 是 express 标准的中间件,并不能直接用于 Koa。web

一个标准的 express 中间件是这样的:express

expressApp.use((req, res, next) => {
  if (nextNeeded) {
    // do what you want
    // until you need down-stream middleware(s)
    next();
  } else {  
    // anything else, e.g. sending response
  }
});

而一个标准的 Koa(v2.x)中间件是这样的:

server.use((context, next) => Promise.resolve(() => doSomething()
  .then(() => {/* before next middleware */})
  .then(() => next())
  .then(() => {/* ... and more */})
  .then(() => {/* after down-stream middleware(s) */})
));

为何上面要用 Promise.resolve 包一层?……交给你本身探索了。

或者是它的姊妹版,基于 async 的函数形式:

koaApp.use(async (context, next) => {
  const beforeNextMiddleware = await doSomething();
  try {
    await next();
  } catch (error) {
    context.body = { message: error.message };
    context.status = error.status || 500;
  }
  return andMore().then(() => evenAfterDownStreams());
});

虽然以上只是一些最基本的概念,真实的中间件还有不少编写方法与技巧,不过咱们已经看到两者最显著的不兼容之处,即它们的参数签名。若是直接把 webpack-dev-middleware 用于 Koa,显然因为 res 不是一个函数是没有办法调用的,所以 Koa 会告诉你:next is not a function

看来要想把 webpack-dev-middleware 用在 Koa 里,须要封装一层中间件来协调两种不一样的参数签名。如上所示,我使用的是 Koa v2,在此以前 Koa 的中间件是基于 ES2015 Generator 来编写的,Github 上能够找到适合 Generator 的 webpack-dev-middleware,可是找不到适合 Promise/Async 的现成中间件,因此咱们来本身造轮子吧。

Koa 中间件的基础骨架

基本上,定义一个返回 Promise 的函数或是一个 Async 函数均可以直接拿来用做 Koa 中间件。不过大多数中间件都会须要 options,因此惯例上都会用高阶函数包一层来传参:

export default (compiler, options) => async (context, next) => {
  await next();
}

webpack-dev-middleware 须要两个参数:

  • compiler:能够经过 webpack(webpackConfig) 获得

  • options:补充 webpack-dev-middleware 须要的特定选项,其中 publicPath 是必须的,而且其值应该等于 webpackConfig.output.publicPath

所以咱们能够帮用户检查 options 是否有效,若不传 options 就用 compiler 里的默认构造一份,有的话就沿用。严格一点的话你还能够检查 publicPath 是否正确,不然抛出异常中断处理也能够——这个我就不写了。另外我还添加了一点点我的偏好的 options 进去,这个是可选的,能够彻底交给用户来传参。

import webpackDevMiddleware from 'webpack-dev-middleware';

// personal taste, totally optional
const stats = {chunkModules: false, colors: 'debug' != process.env.NODE_ENV};

export default (compiler, options = {}) => {
  // this is how we get the original webpack dev middleware, also totally
  // optional if you willing to let user pass in everything.
  const {publicPath} = compiler.options.output;
  const defaults = options.publicPath ? options : {publicPath, stats};
  const middleware = webpackDevMiddleware(compiler, Object.assign({}, defaults, options));
  
  // CAUTION: explicitly return middleware here because we don't want to
  // initialize webpackDevMiddleware instance through every request.
  return async (context, next) => {
    await next();
  };
}

我补充了比较详细的注释来解释 what & how,初学者应该仔细读一下里面的经验之谈,顺便就当练习英文读写了;其实读源码的时候常常能获得这些 tips。另外别忘了看看命令行的输出,此时若无误 webpack 自己应该已经正常工做了。

如今咱们手头上拥有了 express 版本的中间件了,接下来就是考虑如何让其既能适合 Koa 对于中间件定义的要求,又能作好本身的本职工做。咱们先来看看两方的参数如何匹配:

  • express 的 req:等价于 Koa 的 context.req

  • express 的 res:等价于 Koa 的 context.res

  • express 的 next():形式上等价于 Koa 的 next() 可是二者的内涵不一样,Koa 的 next() 须要返回 Promise 对象(Async 函数是基于 Promise 的语法糖),但 express 的 next() 只是单纯的回调函数

好吧,咱们先来试试很天真的作法:

// ...same as above, just pass to async function

return async (context, next) => {
  middleware(context.req, context.res, next);
  await next();
};

直接调用会让咱们看到这样的错误:Error: next() called multiple times,从错误的蛛丝马迹来推断问题的缘由是每一位初学者的必修课,接下来咱们重温一遍这个过程看看在没什么经验的状况下如何处理这个状况(我不会讲很深的东西,由于我也不懂……)。

调试与源代码阅读

若是你试图截断抛出的异常而后向回追踪调用栈会发现看不出什么蛛丝马迹,顶多就是找到抛出 Error: next() called multiple times 的那一行代码,然而对解决这个问题并无什么帮助。思考一下 next() 为何会屡次(不正确的)调用?此时我并无完全想清楚,可是我获得了两条思路:

  1. 回顾咱们的代码,next() 会在 middleware 里面调用,还会在 middleware 执行以后由 await next(),注意这两个 next 的调用场景是不一样的,并且在这时咱们并不能肯定 middleware 里面到底有没有调用 next() 或者能不能正确处理 next(),这些事情前面分析过。因此去步进一下 middleware 里的过程应该是比较明显的线索;

  2. 按照中间件的逻辑,它们以栈的方式从上到下(按声明顺序,也就是 app.use(middleware))的顺序执行,须要继续的就调用 next(),已经处理完成的话就能够直接返回响应,因此后面的 await next() 应该是有条件调用的,由于在我写这个中间件的时候它以后就只剩下 SSR 的中间件了(调试时,把确认正常且不相干的中间件注释掉会是一个很好的办法)。若是它不停的 await next(),而 SSR 又不能所有处理为响应的话,出现前面的错误也不奇怪。

第二点其实很值得深刻说明,不过因为第一点能够当即动手试试,因此咱们先动手看看可否带领咱们理清第二点的细节。

断点打到这里:https://github.com/webpack/webpack-dev-m...,准备步进看看怎么走的(webpack-dev-middleware 的源码我就不贴了,Github 上有现成的,后续指示的断点位置都来自于 Github)。

对于陌生的代码在阅读/调试的时候不要急着钻细节,应该由表及里,先宏观再微观的逐层深刻。像这个例子,断点打好以后我压根没看过程,只是不断的步进而后留意两个重点:

  1. 有没有大块代码在步进时被跳过。一般这是条件分支/异常处理等情形出现的特征,留意一下这些地方,在后续调试的时候会是节省时间而且帮助你宏观理解代码结构的好帮手;

  2. 跳出的位置,一旦跳出了就回退一步(调用栈)看看是什么状况。正常的方法调用?(留意一下函数名字大体判断一下干什么去了)仍是抛出异常?(留意一下错误对象看看什么缘由)。

直到步进至前文错误出现的地方为止,我观察到 webpackDevMiddleware 几乎就没作什么事儿(其实这是很显然的,我刻意写了前面那个天真的代码就为了经过调试引导到这里……),从第二行开始就 next() 出去了,此后直到异常重现都再没有它参与其中。好!咱们仔细看看前两行,为何啥都不干就 next()

var filename = getFilenameFromUrl(req.url);
if (filename === false) return next();

很显然,若是从请求的 url 里匹配不到文件,那么就跳出 webpackDevMiddleware 剩下的处理逻辑直接进入到下一个中间件了。问题来了:

  1. 为何没有 next() 到下一个中间件(SSR)而是抛出异常了呢?

  2. 什么状况下能匹配到文件而不是跳出呢?

第一个问题有兴趣能够步进 next() 一路走下去看看什么状况,这里先告诉你们结论:前面说了 Koa v2.x 的中间件是基于 Promise 的,而此处是直接执行了一个空函数而没有返回 promise 对象,因此无法顺利过渡到后面的中间件。这个问题是不只限于本文的例子的,只要你在 Koa v2.x 下开发早晚都会意识到这一点。咱们在这里不作继续的深刻是由于个人环境是使用 babel-transform-async-to-module-method 将 async 函数转为 bluebird Promise 实现的,而每一个项目使用的 Promise 实现都未必同样,不具有通用性,所以这个问题点到为止。

至于第二个问题这里要插播一些背景:我在写这个中间件的时候,用于测试的页面构成以下:

  1. 一个 SSR 渲染的首页 index.html,只是最基本的 HTML5 模版,里面渲染了一个 React 组件作测试

  2. 一个 client.js,前面的组件在这里调用的,用于接手客户端渲染

  3. 一个 vendor.js, 包含了一些模块,好比 React、fetch、bluebird 之类经常使用的工具库

index.html 天然是第一个请求了,它的里面引了后两个脚本,这俩脚本是使用 webpack 打包的,所以它们是应该在 webpack-dev-middleware 里被匹配到的,而 index.html 则应该经过调用 next() 交由 SSR 渲染。

此时此刻,index.html 第一个到达 webpack-dev-middleware 并走到了调用 next() 这里,又由于前面提到的非 promise 的 next() 问题致使了异常的抛出,因而下面就……木有了。

行文为了流畅,以上的阐述天然省却了一些分析源码和调试的过程,不过不用担忧,所谓“眼过千遍不如手过一遍”,要学会给本身找合适的机会亲身尝试一下。不用多,相似的事情作个两三次就会找到感受,用不了多久就能把貌似一团乱麻的问题梳理的清清楚楚。这里顺便提个小故事,《实用主义程序员》提到的橡胶小黄鸭调试法,建议你们去读一读,我的以为它很是有效。其精髓很简单:分析问题的时候要一句一句,一点一点的说出来,说给小黄鸭、奥特曼、恐龙特级克塞号……都无所谓,哪怕只是自言自语,可是必定要说出来,要出声!信不信一试便知。

分而治之,逐个攻破

如今,咱们尝试一步一步让 webpack-dev-middleware 和 Koa 和谐共处吧。

首先,若是咱们让 next() 返回 Promise 的话会如何?

// ...same as above, just pass to async function

return async (context, next) => {
  middleware(context.req, context.res, () => Promise.resolve())
  await next();
};

咱们把传入的 next 替换为一个返回 promise 的匿名函数再试试看?啊哈~ SSR 成功渲染!

若是你没有后续的 SSR 中间件也无妨,随便返回点 Hello World 的简单中间件也是同样的:
app.use(function(context, next) { context.body = "Hello World" })
放在后面就行。

可是 client.jsvendor.js 的请求返回的都是 404,这又是为啥嘞?像这样的问题下意识的都会觉得 webpack-dev-middleware 是否是有问题?不过请等,像这种知名的开源项目出现这么“明显”问题的几率是很低的,不确信的话能够扫一下 issues 说不定也能找到答案。不过既然都已经读了一些源码了,索性咱继续日后走看看到底如何吧。

当请求走到两个脚本文件的时候,前面的 filename 检查就会跳过 next() 调用了。Line 177-189 之间是一些选项检查和缓存处理,不用细究。

要注意 Line 191 开始的逻辑,L192 的 processRequest() 是对匹配成功的请求的主要逻辑,因为首次请求要等待 building 完成才能返回完整的内容,因此 L191 又一个 ready() 作延迟处理(源代码有注释),所以断点要提早打到函数内部,不然眼一闭一睁——没了~

在这以后我陷入了一段长时间的困惑,由于根据调试的结果,咱们有了表明文件内容的 content,也执行了必要的 res.setHeader(),最后尽管 res.send() 方法不存在,但 webpack-dev-middleware 也 fallback 到了 res.end(content)。按理来讲应该是成功走完了才对,为何会是 404 呢?

其实早前我曾经提到过,await next() 这句应该是有条件调用的,具体来讲:若是 res.end(content) 正确执行了,那么咱们就应该终止下一个中间件的继续调用。但目前咱们在 middleware() 执行事后不管如何都会继续 await next(),因而个人 SSR 又接手了这些请求。鉴于 SSR 的设计是不去处理脚本等外链静态资源请求的,因此返回 404 也就不难理解了。(涉及 SSR 的部分之后有时间再分享)

讨厌的是 webpack-dev-middleware 处理到最后并不会返回什么,因此咱们拿不到可靠的条件来跳过 await next() 这一句。不过想一想咱们前面处理 next() 的方式吧,咱们不是让它顺利返回了 promise 对象吗?那么是否是也可让 middleware() 也返回些什么东西呢?咱们须要以下的结构:

// ...same as above, just pass to async function

return async (context, next) => {
  const hasNext = await middleware(context.req, context.res, () => Promise.resolve(true));
  if (hasNext === true) { await next(); }
};

注意:这一点改动并不能解决前面 404 的问题,由于它只能保证在 next() 被调用时让咱们经过 await promise 拿到 true 而已。可是这一改动暗示着,若是咱们能让 middleware() 在不走 next() 的时候最终返回 Promise.resolve(false),那么就能够跳过不须要的 await next() 了。也就是说,咱们须要包装 res.send()res.setHeader() 方法让它们代理 webpack-dev-middleware 里的同名方法,同时让 res.send() 返回 Promise.resolve(false)

export default (compiler, options = {}) => {
  // omit options processing...
  
  return async (context, next) => {
    const hasNext = await applyMiddleware(middleware, context.req, {
      send: content => context.body = content,
      setHeader: function() {context.set.apply(context, arguments)}
    });
    hasNext && await next();
  };
};

function applyMiddleware(middleware, req, res) {
  const _send = res.send;
  return new Promise((resolve, reject) => {
    try {
      res.send = function() {_send.apply(res, arguments) && resolve(false)};
      middleware(req, res, resolve.bind(null, true));
    } catch (error) {
      reject(error);
    }
  });
}

这一段变化较大同时又有点烧脑,且容我一一道来:

首先咱们须要单独写一个 applyMiddleware 函数用于返回可包含两种情形的 promise(并且更容易处理异常),且因为 promise 分离出去处理了,也就不须要单独封装 next() 了。

context.res 不能原封不动的传进去了,不然又走回了 webpack-dev-middleware 的老路数,所以咱们“伪造”了一个恰好够用的对象,仅实现了 send()setHeader() 这俩方法。前文说过,这是 webpack-dev-middleware 惟二调用的两个属于 res 对象的成员。这两个方法做用不变,可是内部使用的是 Koa 的对等 API,也就是说,当 webpack-dev-middleware 调用它们的时候,咱们将会代理给 Koa 来进行等价处理。

状态符 hasNext 或真或假,将会由 applyMiddleware 返回,因而 middleware 的调用转入其中执行。

再看 applyMiddleware 里面。首先咱们拷贝了一份代理的 res.send,这是由于该方法须要的 content 参数咱们是没法直接获取到的,须要由 webpack-dev-middleware 调用时帮咱们传进来,而后咱们重写它并在其中 apply 调用。注意,Koa 等价的 send() 实际上是针对 context.body 的直接赋值,所以 apply(null) 是没有问题的,可是 setHeader 须要 apply(context),不然 this 的指向会出问题。

在这以后,咱们才真正调用 middleware(),此时 res 已是通过处理的代理对象了,但 webpack-dev-middleware 再次走到 setHeadersend 那里的时候,这俩方法已经“叛变”成了 Koa 的等价处理逻辑,因而真正发挥做用的是 context.body=context.set,最后 context.body= 实际上是一个 setter,它最终返回的是实际的 content,咱们知道它是一个真值,因此直接短路至 resolve(false)

Tips: resolve.bind(null, true) 等价于 function() { resolve(true) }——若是你还不清楚这一点的话,它的做用就是帮助 next() 返回结果为真的 promise

再次返回 await applyMiddleware(...) 那里,这一次 hasNext 会在须要后续中间件介入时为真,前面 404 的问题迎刃而解。

Bonus

但愿这篇文章絮絮不休的风格多少能带给初学者一些帮助或引导,Github 上尚未完成咱们这个例子的开源项目,而我也不打算去作这件事情。我想把这个机会留给读完本文而且尚未在 npm 上发布过模块软件包的新手朋友们,若是你有这个热情和精力来维护它那就干吧,在这里我就权当开源了。等你发布后若是有问题我也乐意给予力所能及的帮助,衷心但愿各位能在编程的道路上越走越远。

相关文章
相关标签/搜索