如何在 Koa 中正确使用路由?@koa/router 详解与实战
Koa 核心不包含路由功能,需要通过中间件实现。最常用的路由中间件是 @koa/router(注意不是已停维的 koa-router),它提供了完整的路由定义、参数捕获、中间件编排等能力。
本文从安装配置开始,逐步覆盖参数处理、路由嵌套、中间件链、模块化拆分这些实际项目必用的功能,同时补充常见踩坑点。
安装和基本用法
bashnpm install @koa/router
@koa/router 是 Koa 官方维护的路由库,原版 koa-router 已停止维护,新项目统一用 @koa/router。如果你的项目还在用 koa-router,建议尽快迁移——API 几乎一致,替换成本很低。
最简路由示例:
javascriptconst Koa = require("koa"); const Router = require("@koa/router"); const app = new Koa(); const router = new Router(); router.get("/", async (ctx) => { ctx.body = "Hello Koa"; }); router.get("/users/:id", async (ctx) => { ctx.body = `User ${ctx.params.id}`; }); app.use(router.routes()); app.use(router.allowedMethods()); app.listen(3000);
两个关键点:router.routes() 注册路由中间件,router.allowedMethods() 自动处理不支持的 HTTP 方法(返回 405 或 501)。漏掉 allowedMethods() 不会报错,但未匹配的方法会静默返回 404,排查起来很困惑。
路由参数的三种形式
路径参数
路径参数用 :param 语法捕获,通过 ctx.params 获取:
javascriptrouter.get("/users/:id", async (ctx) => { const { id } = ctx.params; ctx.body = `User ID: ${id}`; }); // 多个参数 router.get("/posts/:postId/comments/:commentId", async (ctx) => { const { postId, commentId } = ctx.params; ctx.body = `Post: ${postId}, Comment: ${commentId}`; });
踩坑提醒:@koa/router v15+ 不再支持路径参数中内嵌正则(如 :id(\\d+))),因为底层 path-to-regexp 升级到了 v8。如果你需要校验参数格式,把校验逻辑放到路由处理函数或中间件里:
javascript// v15+ 正确做法:在 handler 中校验 router.get("/users/:id", async (ctx) => { const { id } = ctx.params; if (!/^\d+$/.test(id)) { ctx.throw(400, "id 必须是数字"); } ctx.body = `User ID: ${id}`; });
查询参数
查询参数通过 ctx.query 获取,它会自动解析为对象:
javascriptrouter.get("/search", async (ctx) => { const { keyword, page = "1", limit = "10" } = ctx.query; ctx.body = { keyword, page: Number(page), limit: Number(limit) }; });
注意 ctx.query 的值都是字符串,需要手动转数字。ctx.querystring 则是原始查询字符串。
正则表达式路由
如果路径匹配逻辑比较特殊,可以直接用正则:
javascriptrouter.get(/^\/users\/(\d+)$/, async (ctx) => { const id = ctx.params[0]; // 通过索引取捕获组 ctx.body = `User ID: ${id}`; });
正则路由用得不多,大部分场景路径参数就够了。它的捕获组通过 ctx.params[0]、ctx.params[1] 按序获取。
HTTP 方法与路由注册
@koa/router 支持所有常用 HTTP 方法:
javascriptrouter.get("/resource", handler); // 查询 router.post("/resource", handler); // 创建 router.put("/resource/:id", handler); // 全量更新 router.patch("/resource/:id", handler); // 部分更新 router.delete("/resource/:id", handler); // 删除 router.all("/resource", handler); // 匹配所有方法
router.all() 适合给一组路由加统一的预处理逻辑,比如鉴权:
javascriptrouter.all("/admin/*", authMiddleware);
路由前缀与嵌套
路由前缀
设置前缀后,该路由器下所有路径自动加上前缀:
javascriptconst apiRouter = new Router({ prefix: "/api/v1" }); apiRouter.get("/users", handler); // 实际匹配 /api/v1/users apiRouter.post("/login", handler); // 实际匹配 /api/v1/login
也可以用 router.prefix() 方法动态设置:
javascriptrouter.prefix("/api/v2");
路由嵌套
路由嵌套是把子路由挂载到父路由下,形成清晰的 URL 层级:
javascriptconst userRouter = new Router({ prefix: "/users" }); userRouter.get("/", async (ctx) => { ctx.body = "User list"; }); userRouter.get("/:id", async (ctx) => { ctx.body = `User ${ctx.params.id}`; }); const commentRouter = new Router({ prefix: "/:userId/comments" }); commentRouter.get("/", async (ctx) => { ctx.body = `Comments for user ${ctx.params.userId}`; }); userRouter.use(commentRouter.routes()); app.use(userRouter.routes());
嵌套路由中,子路由可以访问父路由的路径参数(如上面的 ctx.params.userId),这一点很实用。
路由中间件
路由中间件是在特定路由上挂载的处理函数,可以挂一个或多个,按顺序执行:
单路由中间件
javascriptrouter.get("/protected", authMiddleware, async (ctx) => { ctx.body = "Protected content"; });
多个中间件串联
javascriptrouter.post("/admin", authMiddleware, // 先鉴权 adminCheckMiddleware, // 再检查权限 async (ctx) => { ctx.body = "Admin content"; } );
中间件链的核心是 await next()——只有调用 next,后面的中间件才会执行。忘记 await next 是最常见的 bug 之一:
javascript// 错误:没有 await next(),后续中间件不会执行 async function badMiddleware(ctx, next) { console.log("before"); next(); // 缺少 await console.log("after"); // 会在后续中间件完成前就执行 } // 正确写法 async function goodMiddleware(ctx, next) { console.log("before"); await next(); console.log("after"); // 后续中间件完成后才执行 }
路由级中间件
用 router.use() 给整个路由器加中间件,作用于该路由器下所有路由:
javascriptrouter.use(async (ctx, next) => { const start = Date.now(); await next(); const ms = Date.now() - start; console.log(`${ctx.method} ${ctx.url} - ${ms}ms`); });
路由模块化拆分
项目稍大一些,把所有路由写在一个文件里就很难维护了。标准做法是按功能模块拆分路由文件:
javascript// routes/users.js const Router = require("@koa/router"); const router = new Router({ prefix: "/users" }); router.get("/", async (ctx) => { ctx.body = "User list"; }); router.get("/:id", async (ctx) => { ctx.body = `User ${ctx.params.id}`; }); router.post("/", async (ctx) => { ctx.body = { success: true, user: ctx.request.body }; }); module.exports = router;
javascript// app.js const userRoutes = require("./routes/users"); const orderRoutes = require("./routes/orders"); app.use(userRoutes.routes()); app.use(userRoutes.allowedMethods()); app.use(orderRoutes.routes()); app.use(orderRoutes.allowedMethods());
如果模块很多,可以用 require-directory 自动加载:
javascriptconst requireDirectory = require("require-directory"); const modules = requireDirectory(module, "./routes"); for (const name in modules) { const router = modules[name]; if (router.routes) { app.use(router.routes()); app.use(router.allowedMethods()); } }
路由命名和重定向
给路由起名字,可以通过名字生成 URL 或做重定向:
javascriptrouter.get("user", "/users/:id", async (ctx) => { ctx.body = `User ${ctx.params.id}`; }); // 重定向 router.redirect("/old-path", "/new-path"); router.redirect("/old-user", "user", { id: 123 }); // 访问 /old-user 会 301 跳转到 /users/123
路由命名在模板渲染时特别有用——改了路径只需改一处定义,所有引用自动更新。
错误处理
路由内抛错
javascriptrouter.get("/users/:id", async (ctx) => { const user = await getUserById(ctx.params.id); if (!user) { ctx.throw(404, "User not found"); } ctx.body = user; });
ctx.throw() 会中断后续执行,Koa 会把这个错误传给全局错误处理中间件。
全局错误处理
在路由之前注册一个 try-catch 中间件,统一捕获所有路由中的错误:
javascriptapp.use(async (ctx, next) => { try { await next(); } catch (err) { ctx.status = err.status || 500; ctx.body = { error: err.message, status: ctx.status }; // 触发 Koa 的 error 事件,方便日志记录 ctx.app.emit("error", err, ctx); } }); app.use(router.routes());
allowedMethods 的错误处理
router.allowedMethods() 会自动处理不支持的 HTTP 方法:
javascriptapp.use(router.allowedMethods({ throw: true, // 不设置则返回 404,设置后抛出异常 notImplemented: () => new NotImplemented(), methodNotAllowed: () => new MethodNotAllowed() }));
常见踩坑
1. 忘记 await 导致 404
这是最频繁遇到的问题。路由处理函数里用了 Promise 但没有 await,Koa 会在 Promise resolve 之前就结束请求:
javascript// 总是返回 404 router.get("/data", async (ctx) => { db.query("SELECT * FROM users").then(users => { ctx.body = users; // 太晚了,请求已经结束 }); }); // 正确写法 router.get("/data", async (ctx) => { ctx.body = await db.query("SELECT * FROM users"); });
2. 中间件注册顺序
Koa 中间件是洋葱模型,注册顺序决定了执行顺序。如果 router.routes() 在业务中间件之前注册,业务中间件就拿不到路由处理后的 ctx:
javascript// 日志中间件放在路由之后,能记录到响应状态 app.use(router.routes()); app.use(logger()); // logger 拿不到路由设置的 ctx.status // 正确顺序 app.use(logger()); app.use(router.routes());
3. 多个路由器的 allowedMethods
每个路由器都应该调用自己的 allowedMethods(),否则一个路由器的路由匹配不上时,其他路由器的 HTTP 方法校验也不会生效。
4. 路由参数是字符串
ctx.params 和 ctx.query 的值永远是字符串类型,直接当数字用会出问题:
javascriptrouter.get("/users/:id", async (ctx) => { // ctx.params.id 是字符串 "123",不是数字 123 const id = Number(ctx.params.id); // 需要手动转换 });
koa-router 与 @koa/router 的区别
旧项目里可能还在用 koa-router,两者的主要差异:
| 对比项 | koa-router | @koa/router |
|---|---|---|
| 维护状态 | 已停维 | 官方维护 |
| TypeScript | 需要 @types/koa-router | 内置类型定义 |
| Node.js 版本 | 无明确要求 | v15+ 需要 Node ≥ 20 |
| 路径参数正则 | 支持 :id(\\d+) | v15+ 不支持内嵌正则 |
| API | 基本一致 | 基本一致 |
迁移很简单:把 require("koa-router") 改成 require("@koa/router"),然后把 package.json 里的依赖名换掉。如果用了内嵌正则参数,需要把校验逻辑移到处理函数里。