前端进阶之旅前端进阶之旅
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片
  • 场景篇按分类整理的大前端场景考点
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
  • 动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
  • AI 助手随时提问,即时解析
  • AI 模拟面试模拟真实面试 + 报告
  • AI 知识地图串起全站知识点
  • AI 定制路线按你的简历现排
AI 热点
旧版
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片
  • 场景篇按分类整理的大前端场景考点
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
  • 动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
  • AI 助手随时提问,即时解析
  • AI 模拟面试模拟真实面试 + 报告
  • AI 知识地图串起全站知识点
  • AI 定制路线按你的简历现排
AI 热点
旧版

nodejs系列之Koa2 洋葱模型与中间件实战

首页2018-12-23 19:10:43Back-End
NodeKoa2中间件

第一次从 Express 切到 Koa 的时候,我卡在了一个很蠢的地方:中间件里写了 next(),后面的代码却在响应发出去之后才执行,日志里的耗时统计全是 0 毫秒。排查了一下午才反应过来,Koa 的 next() 返回的是 Promise,得 await 它,否则你拿到的只是「我把执行权交出去了」这一瞬间,而不是「下游全部跑完了」。

这篇把 Koa2 从起服务到中间件、错误处理、表单文件上传整条线过一遍。重点放在洋葱模型上,因为那是 Koa 和 Express 真正分道扬镳的地方,也是面试里最爱问的一块。每段代码前后都会说清楚它在解决什么问题,而不是只贴一个能跑的片段。

在本篇文章中,我们将从浅入深,和大家一起学习以下知识:

  • 用三行代码起一个 Koa 服务,以及脚手架 koa-generator 怎么用
  • Context 到底包了什么,ctx.request 和 ctx.response 的分工
  • 返回类型怎么协商,网页模板怎么用流的方式吐出去
  • 原生路由的写法,以及 koa-route / koa-static / 重定向
  • 洋葱模型的执行顺序,为什么 next() 前面是「进」后面是「出」
  • 异步中间件必须 async 的原因,漏了 await 会发生什么
  • 用最外层中间件做统一错误兜底,配合 app.on('error') 收底
  • Cookie、表单、文件上传这些 Web App 的常规能力
  • 这些 2018 年的写法在今天该换成什么

# 一、三行代码起一个 HTTP 服务

Koa 是 Express 原班团队做的第二代 Web 框架,特点是核心极小。Express 把路由、静态资源、模板全塞进了框架本体,Koa 把这些全砍了,只留下一个中间件引擎和一个上下文对象,剩下的自己按需装。

起服务确实只要三行。

const Koa = require('koa');
const app = new Koa();

app.listen(3000);
@前端进阶之旅: 代码已经复制到剪贴板

跑起来之后打开浏览器访问 http://127.0.0.1:3000,页面上是 Not Found。这不是报错,是因为我们一个中间件都没注册,Koa 找不到任何人给这次请求写响应体,就按默认逻辑返回了 404。

这个默认行为其实挺说明问题的:Koa 本身什么都不做,所有能力都来自你注册进去的中间件。

真要开一个新项目,手写目录结构挺费劲,官方有脚手架。

npm install koa-generator -g
@前端进阶之旅: 代码已经复制到剪贴板

装完之后创建项目,koa2 -e hello-koa2,其中 -e 表示用 ejs 模板语法,不写这个参数默认是 jade(也就是后来改名的 pug)。

koa-generator 脚手架创建项目后的目录结构

生成出来的骨架里已经把 koa-router、koa-static、模板引擎、日志中间件都装好接上了,npm start 之后同样访问 http://127.0.0.1:3000 就能看到欢迎页。拿它当学习用的沙盒很合适,真做项目的话我一般还是自己从空目录搭,因为脚手架带的依赖版本往往落后好几个大版本。

# 二、Context 对象,一次对话的全部上下文

Koa 给每次请求都造一个 Context 对象,习惯上叫 ctx。它把这一次 HTTP 请求和这一次 HTTP 响应打包在一起,你改它,用户就能看到变化。

最常用的就是 ctx.response.body,它是发送给用户的内容。

const Koa = require("koa");
const app = new Koa();

app.use(ctx => { //处理请求的中间件
    ctx.response.body = "hello world";
}).listen(3000);
@前端进阶之旅: 代码已经复制到剪贴板

app.use 用来注册中间件,传进去的这个函数就是中间件本体。这里它做的事只有一件,给 ctx.response.body 赋值。

ctx.response 代表 HTTP Response,ctx.request 代表 HTTP Request,这两个是 Koa 自己封装的对象,不是 Node 原生的那两个。原生的挂在 ctx.req 和 ctx.res 上,需要直接操作底层流的时候才用得着。这个命名区别很多人第一次都会搞混,短的是原生,长的是 Koa 封装的。

另外 Koa 在 ctx 上做了一堆代理,ctx.body 等价于 ctx.response.body,ctx.status 等价于 ctx.response.status,ctx.path 等价于 ctx.request.path。写业务的时候用短的,读别人代码时知道它们是一回事就行。

# 2.1 返回类型的协商

Koa 默认的返回类型是 text/plain。想返回别的,可以先用 ctx.request.accepts 看看客户端能接受什么(依据是请求头里的 Accept 字段),再用 ctx.response.type 指定。

const Koa = require("koa");
const app = new Koa();

app.use(ctx => {
    if (ctx.request.accepts('xml')) {
        ctx.response.type = 'xml';
        ctx.response.body = '<data>Hello World</data>';
    } else if (ctx.request.accepts('json')) {
        ctx.response.type = 'json';
        ctx.response.body = { data: 'Hello World' };
    } else if (ctx.request.accepts('html')) {
        ctx.response.type = 'html';
        ctx.response.body = '<p>Hello World</p>';
    } else {
        ctx.response.type = 'text';
        ctx.response.body = 'Hello World';
    }
}).listen(3000);
@前端进阶之旅: 代码已经复制到剪贴板

这里有个坑要注意:accepts 的判断顺序是你写的 if 顺序,不是客户端偏好的权重顺序。浏览器发过来的 Accept 一般长这样 text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8,因为末尾有 */*,上面这段代码里第一个分支 xml 就会命中,浏览器直接收到一坨 XML。真要按权重挑,得写成 ctx.accepts('html', 'json', 'xml'),把候选一次性传进去让 Koa 自己算。

顺带说一句,如果 body 赋的是一个普通对象,Koa 会自动把 type 设成 application/json 并序列化,上面那个 ctx.response.type = 'json' 其实可以省。

# 2.2 网页模板用流的方式返回

实际项目里返回给用户的页面通常是模板文件。可以让 Koa 读文件再吐出去。

const Koa = require("koa");
const app = new Koa();
const fs = require('fs');

app.use(ctx => {
    ctx.response.type = 'html';
    ctx.response.body = fs.createReadStream('./demos/template.html');
}).listen(3000);
@前端进阶之旅: 代码已经复制到剪贴板

注意这里 body 赋的是一个可读流,不是字符串。Koa 支持给 body 赋字符串、Buffer、Stream、对象和 null 这几种类型,赋流的好处是不用把整个文件读进内存,大文件下载场景基本都这么写。

代价是流的错误处理要额外操心。文件不存在时,createReadStream 不会立刻抛错,错误是在流开始读的时候异步冒出来的,这时候响应头可能已经发出去了,你没法再改状态码。生产环境更稳的做法是先 fs.promises.access 判断一下,或者干脆交给 koa-static 这类成熟中间件。

# 三、路由怎么写

网站总不止一个页面。最土的办法是自己判断 ctx.request.path。

const Koa = require("koa");
const app = new Koa();
const fs = require('fs');

app.use(ctx => {
    if (ctx.request.path !== '/') {
        ctx.response.type = 'html';
        ctx.response.body = '<a href="/">Index Page1</a>';
    } else {
        ctx.response.body = 'Hello World';
    }
}).listen(3000);
@前端进阶之旅: 代码已经复制到剪贴板

这段能跑,但路由一多就变成一长串 if...else,而且没法处理 /user/:id 这种带参数的路径,也区分不了 GET 和 POST。

# 3.1 koa-route 模块

把路由这件事交给专门的模块。

const Koa = require("koa");
const app = new Koa();
const fs = require('fs');
const route = require('koa-route');

const main = route.get("/", ctx => {
    ctx.response.type = 'html';
    ctx.response.body = '<a href="/">Index Page1</a>';
})
const about = route.get("/about", ctx => {
    ctx.response.body = 'Hello World';
})

app.use(main);
app.use(about);
app.listen(3000);
@前端进阶之旅: 代码已经复制到剪贴板

route.get(path, fn) 返回的还是一个普通中间件,路径不匹配时它就调 next() 放行,匹配时才执行你的处理函数。理解了这一点你就明白,Koa 里根本没有「路由系统」这个概念,路由只是一种特殊的中间件。

koa-route 现在基本没人维护了,实际项目里用的是 @koa/router,它支持路由前缀、嵌套路由、router.param 这些能力,写法是先 new Router() 再 app.use(router.routes())。功能多了不少,但底层原理和上面这段是一样的。

# 3.2 静态资源

图片、字体、样式表一个个写路由不现实,koa-static 把这部分包了。

// 访问 http://localhost:3000/test.json
const Koa = require("koa");
const app = new Koa();

const path = require('path');
const serve = require('koa-static');

const main = serve(path.join(__dirname, "../public/"));

app.use(main);
app.listen(3000);
@前端进阶之旅: 代码已经复制到剪贴板

它做的事是拿请求路径去指定目录下找文件,找到就返回并带上 Content-Type 和缓存头,找不到就 next() 交给后面的中间件。所以静态资源中间件通常放在整个中间件链的靠前位置,让静态文件请求尽早短路,不用往下走一堆业务逻辑。

fe
  • 一、三行代码起一个 HTTP 服务
  • 二、Context 对象,一次对话的全部上下文
    • 2.1 返回类型的协商
    • 2.2 网页模板用流的方式返回
  • 三、路由怎么写
    • 3.1 koa-route 模块
    • 3.2 静态资源
    • 3.3 重定向
  • 四、中间件与洋葱模型
    • 4.1 和 Express 的分工,差别到底在哪
    • 4.2 异步中间件必须写成 async 函数
    • 4.3 中间件的合成
  • 五、错误处理
    • 5.1 ctx.throw 与状态码
    • 5.2 用最外层中间件统一兜底
    • 5.3 监听 error 事件收底
  • 六、Web App 的常用能力
    • 6.1 Cookies
    • 6.2 表单
    • 6.3 文件上传
  • 七、这些写法在今天还成立吗
  • 总结
  • 参考

← nodejs系列之express中间件机制与路由用法详解Object.defineProperty详解与Vue2响应式原理 →