资讯详情

资讯详情

Egg.js扩展机制详解:五个挂载点与实战避坑指南

15天计划走到第9天说真的这个进度于我而言正好是“渐入佳境”的时候。前8天把eggjs的路由、控制器、Service、中间件、插件、配置这些主线都打了一遍基本能动手写业务了。可写法还是有点“素”——想统一返回格式我第一版是把success和fail封装成一个工具文件每个controller里手动require想格式化时间我在好几个页面里各写了一个dateFormat函数。表面能用日子久了就乱。后来我翻了一些开源项目发现成熟的eggjs代码里到处都是ctx.success(...)、ctx.helper.formatTime(...)这种写法干净得不像话。顺着这条线找下去发现了今天的主角框架扩展extend。这篇第9天的记录不打算讲太玄乎的底层源码就把extend机制掰开揉碎讲清楚五个扩展点分别干什么、怎么挑场景、代码怎么写以及我实测下来最容易被坑的几个点。无论你是跟着这个系列一路走过来的还是半路看到这篇想补课的只要前8天的基础还在今天的内容你花一天时间读完、敲完、跑通是可以做到的。别小看这一天之后你的代码风格会明显“进阶”一大截。1. 第9天的坐标为什么eggjs把“自定义能力”设计成扩展1.1 前8天你掌握了什么还缺什么先简单复盘一下前面的内容。第1到第3天搭好了环境把目录规范、配置加载这些“规定动作”跑通第4到第6天重点在路由、控制器、Service这条请求链路第7天是中间件第8天是插件。到了这个节点一个普通的CRUD接口基本能写出来了但写出来的代码“能跑”和“好用”之间还有一段距离。距离在哪里呢我拿实际项目打比方。做了两个接口之后你会发现每个接口都要返回{ code: 0, data, msg }这种统一结构每个需要登录的接口都要从header里取token并解析用户列表页的时间字段要转成“2026-07-08 15:30:00”这种格式。这种逻辑如果都写成函数放进一个utils/xx.js里倒是不难但你已经能感觉到哪里不对劲了——eggjs强调“约定优于配置”它把路由、中间件、插件都安排了固定目录为什么这种最常用的“公共能力”反而要我自己满项目寻找归宿Eggjs之所以被很多人称为“适合团队长期维护的Node框架”很重要的一个原因就是它把“在哪里放什么代码”这件事固化下来了。业务公共能力也有专门的归宿app/extend目录。这是第9天必须先建立起来的心理模型。从这一天开始你要逐渐跳出“写业务功能”的视角站在“定义一个团队该怎样写业务功能”的视角去看代码。1.2 五个核心对象就是你要扩展的五个“挂载点”在eggjs里所有请求处理都围绕五个核心对象展开Application、Context、Request、Response、Helper。这五个对象不是模块而是运行时的“骨架”它们各自有不同的生命周期和职责范围。先说Application它是进程级单例整个应用共享一份适合放全局客户端、全局配置、上报方法。Context则是每个请求创建一个实例生命周期只有一次请求天然隔离了用户数据。Request和Response分别封装了请求与响应的内容通常用得少但特定场景很合适。Helper则可以理解为工具函数的集合模板层和逻辑层都能调用。扩展机制extend就是让你自定义地往这五个骨架上挂能力。每当你觉得“这个能力要是框架自带的就好了”你都可以通过extend让它看起来像框架自带的一样。比如写完ctx.success方法后所有controller里都可以直接调用完全不需要import也不需要传ctx参数因为方法本来就长在ctx上。用起来就像eggjs原生支持的一样顺滑。这里有一个关键认知eggjs不是让你去改node_modules里的源码来实现自定义而是通过loader机制启动时扫描项目里的app/extend目录然后把导出对象合并进对应原型。这个设计保持了框架升级的兼容性同时又把定制权利完全还给了业务层。这一点和“继承父类再覆写方法”的套路不同它更像是“给五脏六腑挂上新的器官”不需要动原有骨架。2. 五个扩展点一次讲透该往哪里放代码2.1 Context扩展每个请求的“专属便签”Context扩展我建议第一个学因为它的语义最直观每个请求都会生成一个新的ctx对象我在ctx上扩展的东西天然只能在当前请求里生效不会跨请求串数据。最常见的用法是统一返回格式封装比如ctx.success(data)、ctx.fail(msg, code)封装当前登录用户比如ctx.currentUser让所有controller、service里都能轻松拿到请求级缓存比如同一个请求里多次调用的重复查询结果这里需要特别注意一个设计倾向扩展Context时如果计划存放的数据跟具体请求有关就使用getter或者方法如果是一锤子的初始化操作放在application扩展里更合适。为什么因为ctx的生命周期就一次请求request里有header、query等每个请求都不同如果用属性直接写死等于把不同用户的数据搞混了。再往深一层说Context里密密麻麻装满了框架和插件的能力extend机制让你可以继续往里塞东西但千万别真的把Context当成一个垃圾桶。我在评审代码时经常看到有人把一些鸡毛蒜皮的计算塞进ctx扩展比如ctx.getOrderAmount()这种明显只有订单模块才用的方法。扩展Context应该是“通用的、跨模块的、每个请求都可能用到”的能力而不是某个特定业务的私有方法。2.2 Helper扩展纯函数的“工具箱”Helper是eggjs为模板渲染准备的工具集合但实战里大家普遍把它当公共工具库用格式化时间、脱敏手机号、截断字符串、解析文件大小等。它的调用路径是ctx.helper.xxx在controller、service和模板里都统一团队里任何人都不需要猜工具函数放在哪个目录。给你一个判断准则如果你要写的是一个“给定输入稳定返回输出”的纯函数且跟某个请求上下文无关那放helper就对了。它不依赖于数据库、不依赖于登录态、不依赖于网络请求只要传参就能计算这种函数放进helper是最合适的。反过来如果函数内部要查数据库、调远程接口那它应该待在service层而不是helper。再补充一个很多人不知道的小细节eggjs的helper不是只在模板里可用在service和controller里同样通过ctx.helper拿得到。很多新手以为helper是前端模板专属其实是误解。模板、controller、service三处共用一套helper意味着你只要把工具函数写进helper整个应用都能用同样的方式调用不需要各自维护一份工具库这也是我推荐团队用helper统一工具函数的核心原因。2.3 Request、Response与Application扩展各自的边界在哪里Request和Response扩展用得比Context少但有些场景非常实用。比如Request扩展里可以自定义解析前端传参格式把某个固定格式的header自动解析成字段Response扩展里可以统一给所有响应追加X-Request-Id或者在序列化JSON时统一处理日期字段。Application扩展处理的是进程级能力。典型场景启动时创建某个全局客户端比如redis实例、私有推送通道注册一个全局的告警上报方法业务里出错时app.reportError(...)一声调用挂载来自远程配置中心的动态配置项有一句话我建议记下来Application扩展是“全局服务注册中心”适合放那些整个进程只需要一份、启动后可以一直复用的东西。跨请求共享、全应用可见这是它的核心价值。但它也是最容易“过度设计”的地方不是每个项目都需要在app上挂redis、挂推送、挂各种客户端。只有当资源确实需要进程级单例时才值得上application扩展。针对这五个扩展点我整理了一个小表格先记下来写代码时对照着看扩展文件this指向生命周期典型用途app/extend/application.jsapp进程级全局客户端、配置、上报方法app/extend/context.jsctx单次请求统一响应、当前用户、请求级缓存app/extend/request.jsctx.request单次请求自定义请求解析app/extend/response.jsctx.response单次请求响应头、响应序列化app/extend/helper.jsctx.helper单次请求无状态纯函数工具库等这张表刻进脑子里后面写代码就是条件反射了。3. 动手实操给“会员中心”加上统一能力3.1 场景目标与目录准备下面进入代码环节。为了贴合实际情况我设计了一个小场景做一个“会员中心”模块需要实现三个能力通过请求header里的token解析出当前用户登录后才能查询会员信息所有接口统一返回{ code, msg, data }结构会员信息的“最近登录时间”需要格式化成可读字符串这三个能力分别对应Context扩展、Helper扩展、以及Controller侧的调用。先建好目录结构app/extend/context.js app/extend/helper.js app/controller/member.js app/service/member.js app/router.jseggjs扩展文件的核心写法其实是直接导出对象。比如app/extend/context.js最外层不需要包function直接module.exports即可。这一点和中间件、插件的写法不同要特别注意新手经常在这里卡壳以为要导出一个函数或者一个类结果发现eggjs只把导出对象上的属性合并到原型上。3.2 写ctx.success、ctx.fail与ctx.currentUser我们打开app/extend/context.js把统一响应方法和当前用户解析写进去。// app/extend/context.js use strict; const jwt require(jsonwebtoken); module.exports { success(data, msg ok) { this.body { code: 0, msg, data, }; this.status 200; }, fail(msg 服务异常, code -1, status 200) { this.body { code, msg, data: null, }; this.status status; }, get currentUser() { const token this.get(x-token); if (!token) return null; try { return jwt.verify(token, this.app.config.jwt.secret); } catch (error) { return null; } }, };这里有三个细节要说明。第一success和fail里的this指向的是ctx本身不是其他东西。因为eggjs在加载扩展时会用Object.assign把方法挂到原型上真正执行时方法内部的this会由调用方决定也就是ctx对象。这一点和普通类的方法完全相同。如果你在这里写箭头函数this就会丢失后面我还会专门讲这个坑。第二currentUser我写成getter而不是方法。这样controller里可以写成ctx.currentUser读起来就像在读取一个属性。而且getter是每次访问都会重新求值天然适合这种“每次请求都要现算”的逻辑。如果用户没有登录就返回nullcontroller里判断一下就好。第三为什么我不直接写ctx.user ...而是用getter你可以试试看如果写一个ctx.loadUser()方法然后在每个controller里第一行都要记得调用这就成了“纪律问题”。只要有一个接口忘记调用就会出现this.user为undefined。而用getter你访问ctx.currentUser的那一刻令牌解析才执行不存在忘记初始化的场景。这就是框架设计的精妙之处也是extend推荐用getter的核心原因。Controller里的调用方式如下// app/controller/member.js use strict; const Controller require(egg).Controller; class MemberController extends Controller { async info() { const { ctx } this; const user ctx.currentUser; if (!user) { ctx.fail(请先登录, 401, 401); return; } const member await ctx.service.member.getInfo(user.id); const lastLoginAt ctx.helper.formatTime(member.lastLoginAt); ctx.success({ nickname: member.nickname, level: member.level, lastLoginAt, }); } } module.exports MemberController;router里加一行router.get(/api/member/info, member.info); 然后启动应用你就能感受到扩展机制带来的爽感了。controller只有三件事拿用户、查数据、归一化输出。能够达到这种效果完全是因为把“公共能力”下沉到了扩展层。前8天你写的控制器可能还有工具函数、token解析、返回值处理堆在一块的“杂烩”今天之后可以彻底告别这种写法了。3.3 helper里的formatTime与脱敏工具接下来看helper扩展。打开app/extend/helper.js// app/extend/helper.js use strict; const moment require(moment); module.exports { formatTime(time, pattern YYYY-MM-DD HH:mm:ss) { if (!time) return ; return moment(time).format(pattern); }, maskMobile(mobile) { if (!/^1\d{10}$/.test(mobile)) return mobile; return mobile.replace(/^(\d{3})\d{4}(\d{4})$/, $1****$2); }, };这里的逻辑很简单但有两个值得思考的点。第一为什么日期格式化用moment而不是自己写padStart拼接因为实际项目里会持续遇到各种边界时间戳是秒还是毫秒、时区、跨年的月份进位。自己写一个临时函数看起来节省了依赖但一旦边角情况踩中排查成本远超引入成熟库的成本。我在很多项目里见过手写格式化函数结果一到夜里零点、月底、年末就出bug。moment虽然体积不小但胜在稳定如果在意包体积也可以换成轻量级的dayjs方法签名几乎一致。第二maskMobile这类脱敏函数放进helper价值不只是“少写几行代码”而是所有模板输出、日志打印都走同一个出入口这样即使将来脱敏规则变严格也只需要改helper一个文件不需要全局搜索。对于涉及用户信息的项目这类函数几乎是必备的。这里也要提一个helper的“禁区”不要在helper里做IO操作比如发HTTP请求、查数据库。用一句话概括helper应该是“纯函数区”不应该有副作用。这是我的真实体会一开始我把登录用户的查询写进helper代码确实能跑但测试mock都麻烦排查问题时也分不清这个helper到底是纯计算还是要依赖上下文。后来全部搬到service层helper清清爽爽整体代码反而更容易维护。3.4 application级扩展把redis客户端挂到app上最后演示application扩展。假设会员信息部分数据放在redis里我们希望整个进程只创建一次redis客户端所有service能通过this.app.redis来访问// app/extend/application.js use strict; const Redis require(ioredis); let client null; module.exports { get redis() { if (!client) { client new Redis(this.config.redis); } return client; }, };这段代码里有一个非常容易踩的坑为什么redis变量要定义在模块的闭包里而不是直接写在getter里面因为getter每次访问都会执行写成this.redis new Redis()这种属性覆盖逻辑也说得通但容易出现多次初始化或者属性名混乱。而把client放在模块闭包里本质上是在“这个worker进程内部”只会初始化一次。这一点必须要注意每个worker进程都各自加载一份extend文件所以这里的“进程内单例”不等于“整个机器只有一个redis客户端”。在cluster模式下如果有多个worker进程每个进程会各有一个redis连接这点是正常且符合预期的。如果你希望“整个应用只建连一次、多进程共享”那就复杂了通常还会引入agent进程来做统一管理。但对绝大多数项目来说进程内单例完全够用不要为了技术而技术。这个cli节点记下来后面第11天聊进程模型时还会用到。到这里一个带统一返回结构、自动用户解析、helper工具库、全局redis客户端的会员中心雏形就齐了。这半天时间花得很值因为你构建的不只是一个功能而是一套“以后写任何接口都能复用”的基础设施。4. 第9天的隐藏BOSSextend加载原理与几个坑4.1 Object.assign背后的加载顺序很多同学问extend到底是怎么“混”进原型的简单说eggjs的loader在启动时会扫描app/extend目录下所有文件把它们require进来然后把导出对象与对应原型进行合并类似Object.assign(Application.prototype, require(./app/extend/application.js)); Object.assign(Context.prototype, require(./app/extend/context.js));因为是“后加载的覆盖先加载的”所以项目里写的扩展方法会覆盖eggjs内置方法。比如框架内置了某个helper方法如果你在helper里也导出了同名方法那后者生效。这个机制的启动时机是应用启动过程不是每个请求都扫描一次。所以对你代码的影响是扩展文件加载后方法立即可用改了扩展文件开发环境会热重载但生产环境部署时改动必须重新构建、重启进程才会生效还有一个和加载顺序相关的隐蔽问题不要在扩展文件的“模块顶层”执行this.xxx或者访问app的某个属性。因为Object.assign是在require完成之后才合并的如果文件顶层就用了this.redis此时this还不指向app闭包里的client也没有被赋值很容易拿到undefined。正确写法是把初始化的动作放进getter、方法、或者app.on(ready)等生命周期钩子里。这条我特别强调因为真的有人踩过而且报错信息还不直观排查起来很费劲。既然扩展最终是合并到原型上那你就应该把extend里的方法理解成“这个框架原生就有的方法”而不是一个额外的工具类。这个心智迁移很重要它决定了你写代码时的组织方式。比如你在service里使用ctx.helper.formatTime()时不会去想“我是不是漏require了”因为框架已经在启动时帮你准备好了。4.2 实测遇到的4个典型问题我把踩过的坑整理成一个速查表基本覆盖了大多数初学者会遇到的情况。症状原因解决办法扩展方法里this是undefined或外层对象使用了箭头函数定义方法改成普通function定义确保this绑定到调用方多个接口之间数据串了在application扩展里存了请求级数据请求级数据一律放ctx扩展application只放进程级资源扩展文件热更新后不生效生产环境或某些缓存机制开发阶段使用dev模式自动重载生产改扩展必须重启调用了ctx.success但仍返回404状态方法内部只设置了body没有设置status在方法里同步写入this.status第一条最典型。我见过好几个同事在context.js扩展里写// 错误示范 module.exports { success: data { this.body data; // this不是ctx }, };箭头函数的好处是不绑定this但在这里恰恰是致命缺点。扩展方法必须写成普通函数this才会指向调用时的ctx对象。如果你在扩展里用了箭头函数大概率会得到一个“this.body is not a function”或者更隐蔽的undefined错误。这也是不少新手初次上手extend最容易卡住的地方。第二条更隐蔽。有同学在application扩展里定义了一个users {}在controller里直接往this.app.users里塞当前请求的用户数据看起来能跑但cluster模式下多个请求并发时这个对象被所有worker共享每个worker一份于是出现A用户的缓存串到B用户身上。这类问题排查起来特别痛苦因为不是必现只有并发压力下才冒出来。我的建议是任何带“请求唯一性”的数据放ctx任何“进程内真全局”的数据放app别再想别的弯弯绕。第三条的问题是环境差异导致的。本地开发时eggjs的loader会在文件变化时自动让扩展生效但如果你部署在生产代码是打包好的不会自动重载所以不要因为本地能热更就忽略了重启节奏。有些同学本地改了extend文件开心地跑通结果上生产发现“新方法不存在”其实就是这个原因。第四条其实是响应语义问题。ctx.body设置后框架一般返回200但如果你之前在其他位置写了一个ctx.status 404而那行代码在success之前执行了最终响应就可能是404。所以success/fail方法里显式重置this.status非常有必要这也是一种“防御性编程”。同理fail方法里如果业务需要返回200但code非0也可以通过第三参数显式控制。4.3 我的第9天最佳实践清单最后送你一份我总结的“扩展写法五问”每次写代码前问自己一遍这个能力是进程级还是请求级进程级放app/extend/application.js请求级放app/extend/context.js。它是纯函数还是有副作用纯函数放helper有IO放service。它需要访问当前用户或其他请求数据吗需要就放ctx并用getter不需要就考虑是否真的要扩展。是否存在跨请求串数据的风险有就坚决放ctx。是否在模块顶层做了初始化所有初始化动作要放到getter或生命周期里不要在require时执行。我自己的习惯是统一响应是每个新项目第一步就加上的扩展currentUser这种跟鉴权强绑定的能力会紧跟其后helper则按业务演进遇到重复三次以上的格式化逻辑就往里面放application扩展则只在“真的需要进程级单例资源”时才使用。另外如果扩展文件越来越大也不要硬塞。比如context.js里有了七八个方法之后你完全可以把同一类方法拆成子模块再用Object.assign合并进来。我见过一种不错的组织方式app/extend/context/success.js和app/extend/context/login.js然后在context.js里require后混入这样既保持了扩展机制的统一入口又避免了单个文件膨胀。这套扩展体系跑顺以后你再回头看自己前几天写的那版“到处都是工具函数import”的代码会有一种豁然开朗的感觉。扩展机制就是这样一个东西不学的时候觉得框架够用了学了之后才发现之前错过了多少顺手的设计。今天的内容就到这边下次进入第10天我准备聊一聊eggjs的日志体系与单元测试的组合打法那又是一个15天计划里的隐藏重点。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →