1、Koa2初体验:Node.js 异步进化史

大家好,欢迎来到《Koa2框架实战》的第一章。

说实话,每次带新人入门Node.js后端,我都会先问一个问题:「你被回调地狱折磨过吗?」 如果你写过一段时间的Node.js,大概率会心一笑。今天我们就从这段「血泪史」开始,一步步走到Koa2的世界里。

1.1 异步进化史:从回调到async/await

Node.js从诞生起就带着异步基因。但早期的异步写法,嗯,确实有点「原始」。

1.1.1 回调函数时代

我刚入行那会儿,项目里到处都是嵌套的回调。举个例子,读取一个文件再处理内容:

const fs = require('fs');

fs.readFile('data.txt', 'utf8', (err, data) => {
    if (err) {
        console.error('读取失败:', err);
        return;
    }
    // 处理数据...
    console.log(data);
});

单看这个还好。但如果你要依次读取三个文件呢?

fs.readFile('a.txt', 'utf8', (err, dataA) => {
    fs.readFile('b.txt', 'utf8', (err, dataB) => {
        fs.readFile('c.txt', 'utf8', (err, dataC) => {
            // 终于拿到所有数据了...
            console.log(dataA, dataB, dataC);
        });
    });
});

这就是传说中的「回调地狱」。代码往右偏移,逻辑越来越乱。我在项目中遇到过最夸张的一次,嵌套了7层回调,改个bug花了整整一下午。为什么会这样?因为回调函数把控制流打散了,你很难一眼看出执行顺序。

避坑指南: 我曾经因为回调里忘了处理错误,导致线上服务静默崩溃。记住:每个回调都要检查 err 参数,别偷懒。

1.1.2 Promise 登场

后来社区搞出了 Promise,说白了就是把回调「扁平化」。还是刚才的例子:

const fs = require('fs').promises;

fs.readFile('a.txt', 'utf8')
    .then(dataA => {
        return fs.readFile('b.txt', 'utf8');
    })
    .then(dataB => {
        return fs.readFile('c.txt', 'utf8');
    })
    .then(dataC => {
        console.log(dataC);
    })
    .catch(err => {
        console.error('出错了:', err);
    });

你看,嵌套变成了链式调用。错误处理也统一到了 catch 里。我个人习惯用 Promise 处理一些简单的异步流程,比如并发请求用 Promise.all:

Promise.all([
    fs.readFile('a.txt', 'utf8'),
    fs.readFile('b.txt', 'utf8'),
    fs.readFile('c.txt', 'utf8')
]).then(([dataA, dataB, dataC]) => {
    console.log(dataA, dataB, dataC);
});

但 Promise 也有个小问题——then 链一长,可读性还是会下降。你想想看,如果中间有 5 个异步步骤,每个步骤还要处理数据,代码就变成了一长串 .then()。

1.1.3 async/await 终极方案

ES2017 带来了 async/await,这简直就是异步编程的「语法糖炸弹」。它让异步代码长得像同步代码:

const fs = require('fs').promises;

async function readFiles() {
    try {
        const dataA = await fs.readFile('a.txt', 'utf8');
        const dataB = await fs.readFile('b.txt', 'utf8');
        const dataC = await fs.readFile('c.txt', 'utf8');
        console.log(dataA, dataB, dataC);
    } catch (err) {
        console.error('出错了:', err);
    }
}

readFiles();

是不是清爽多了?await 关键字会暂停函数执行,直到 Promise 完成。 注意,await 只能在 async 函数里用。我建议你从现在开始,所有异步操作都优先考虑 async/await,除非你非要兼容老掉牙的 Node.js 版本。

小技巧: 如果你需要并发执行多个异步任务,别傻傻地一个个 await。用 Promise.all 配合 await,性能会好很多:
const [a, b, c] = await Promise.all([
    fs.readFile('a.txt'),
    fs.readFile('b.txt'),
    fs.readFile('c.txt')
]);

1.2 Koa2 核心概念:洋葱模型

好,异步进化史讲完了。现在聊聊 Koa2 最核心的东西——洋葱模型

Koa2 的中间件执行机制,就像切洋葱一样。请求从外层中间件进入,经过内层,再一层层返回。我画个简单的示意图:

请求 --> 中间件1(进入) --> 中间件2(进入) --> 中间件3(处理)
                                    <-- 中间件2(返回) <-- 中间件3(返回)
                    <-- 中间件1(返回) --> 响应

每个中间件都有两次执行机会:一次在请求进入时,一次在响应返回时。 这是通过 next() 函数实现的。

来看个例子:

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

app.use(async (ctx, next) => {
    console.log('1. 请求进入第一个中间件');
    await next();  // 交给下一个中间件
    console.log('5. 响应返回第一个中间件');
});

app.use(async (ctx, next) => {
    console.log('2. 请求进入第二个中间件');
    await next();
    console.log('4. 响应返回第二个中间件');
});

app.use(async (ctx) => {
    console.log('3. 处理请求');
    ctx.body = 'Hello Koa2';
});

app.listen(3000);

访问一下,控制台输出顺序是:1 → 2 → 3 → 4 → 5。这就是洋葱模型!

我在项目中遇到过一个问题:有人把 await next() 写成了 next(),结果中间件顺序全乱了。记住:一定要加 await,否则 next 返回的是 Promise,不会等待它完成。

核心要点: 洋葱模型让 Koa2 的中间件可以「前置处理」和「后置处理」。比如日志中间件可以在请求进入时记录开始时间,在响应返回时计算耗时。这种设计比 Express 的线性中间件灵活得多。

1.3 搭建第一个 Koa2 应用

理论说完了,咱们动手搭一个真正的 Koa2 应用。别怕,就几步。

1.3.1 环境准备

首先确保你装了 Node.js(建议 v12 以上)。然后创建项目目录:

mkdir my-koa-app
cd my-koa-app
npm init -y

安装 Koa2:

npm install koa

嗯,就这么简单。Koa2 本身非常轻量,核心代码才几百行。它不像 Express 那样内置了路由、模板引擎等,但这也给了我们最大的自由度。

1.3.2 编写入口文件

创建 app.js:

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

// 第一个中间件:记录请求耗时
app.use(async (ctx, next) => {
    const start = Date.now();
    await next();
    const ms = Date.now() - start;
    console.log(`${ctx.method} ${ctx.url} - ${ms}ms`);
});

// 第二个中间件:设置响应头
app.use(async (ctx, next) => {
    ctx.set('X-Powered-By', 'Koa2');
    await next();
});

// 第三个中间件:返回响应
app.use(async (ctx) => {
    ctx.body = 'Hello, Koa2 World!';
});

app.listen(3000, () => {
    console.log('Server running at http://localhost:3000');
});

运行一下:

node app.js

打开浏览器访问 http://localhost:3000,你会看到「Hello, Koa2 World!」。控制台会输出类似:

GET / - 3ms

我个人习惯把中间件按功能拆分到不同文件,但刚开始学,先写在一起就好。

1.3.3 理解 ctx 对象

上面的代码里有个 ctx,它是 Koa2 的上下文对象,封装了 Node.js 的 request 和 response。常用属性:

属性 说明 示例
ctx.request 请求对象 ctx.request.method, ctx.request.url
ctx.response 响应对象 ctx.response.body, ctx.response.set()
ctx.method 请求方法(快捷方式) ctx.method === 'GET'
ctx.url 请求路径(快捷方式) ctx.url === '/api/users'
ctx.body 响应体(快捷方式) ctx.body = { name: 'Koa' }
ctx.query 查询参数 ctx.query.id

注意,ctx.body 可以赋多种类型:字符串、对象、Buffer、Stream 等。Koa2 会自动设置 Content-Type。比如赋对象时,会自动转为 JSON 并设置 application/json。

避坑指南: 我曾经犯过一个错——在中间件里直接修改了 ctx.request 的属性,结果影响了后续中间件。记住:ctx 是每个请求独立的,但如果你不小心改了原型链上的东西,那就出大事了。建议只通过 ctx 提供的 API 操作。

1.4 本章小结

这一章我们走完了 Node.js 异步编程的进化史:从回调地狱到 Promise 链式调用,再到优雅的 async/await。然后深入理解了 Koa2 的洋葱模型——这种设计让中间件可以灵活地做前置和后置处理。最后,我们亲手搭建了第一个 Koa2 应用,跑通了从请求到响应的完整流程。

下一章,我们会深入中间件机制,教你如何编写可复用的中间件,以及如何处理错误。到时候见!

课后练习: 试着在 app.js 里再加一个中间件,在响应返回时给 ctx.body 追加一段文字。看看洋葱模型的执行顺序是不是和你预期的一样。