@eggjs/koa-static-cache:面向 Koa 的静态缓存中间件全解析
【免费下载链接】egg🥚🥚🥚🥚 Born to build better enterprise frameworks and apps with Node.js & Koa. https://307.run/eggcode项目地址: https://gitcode.com/gh_mirrors/eg/egg
@eggjs/koa-static-cache是 Egg 框架团队从 koajs/static-cache fork 而来、并以 TypeScript 重写的 Koa 静态资源缓存中间件,同时支持 CommonJS 与 ESM。它以"启动时缓存 + 内存缓冲 + 按需 gzip + MD5 ETag"为设计主线,为静态资源服务提供了比传统流式静态中间件更高的缓存命中率与更低的重复 I/O。读完本文,你将掌握它的全部配置项语义、底层请求处理链路,以及如何将其作为@eggjs/static插件的底座在 Egg 应用中使用。
与同类静态中间件的核心差异
中间件的官方文档开篇即声明了它与 koajs/static 等库的本质区别,理解这五点有助于你决定何时选用它:
- 不支持目录列表与
index.html自动回退——它只服务具体存在的文件; - 可选将文件内容驻留内存(
buffer),而非每次请求都从磁盘流式读取; - 默认在初始化阶段(preload)就把目录资产扫描进缓存,因此正常情况下需要重启进程才能感知资产更新(可通过
options.preload = false关闭); - 使用 MD5 哈希摘要作为 ETag,同时输出
Content-MD5响应头,便于强缓存与内容校验; - 优先使用磁盘上已存在的
.gz预压缩文件,行为类似 nginx 的gzip_static模块,避免运行时重复压缩开销。
安装与基本使用
包名发布在@eggjs作用域下:
npm install @eggjs/koa-static-cache根据 package.json,当前版本引擎要求node >= 22.18.0,包以 ESM 为默认模块类型("type": "module"),同时通过exports与构建产物兼容两类模块系统。
最小可用示例(CommonJS):
const path = require('path'); const { staticCache } = require('@eggjs/koa-static-cache'); app.use( staticCache(path.join(__dirname, 'public'), { maxAge: 365 * 24 * 60 * 60, }), );staticCache在 src/index.ts 中提供了一组函数重载,支持四种调用形态,最终全部收敛为staticCache(dir, options, files):
staticCache()——目录默认取process.cwd();staticCache(dir);staticCache(options)——目录取自options.dir;staticCache(dir, options);staticCache(dir, options, files)——第三参数是外部文件存储(见下文 Files 章节)。
需要特别说明的参数优先级:第一个字符串参数dir的优先级高于options.dir,这由源码中if (!dir && options.dir) dir = options.dir;的逻辑决定,对应测试用例 "should dir priority than options.dir" 也验证了这一点(见 test/index.test.ts)。目录最终会经过path.normalize归一化。
API 选项逐一详解
以下选项与官方文档一一对应,并补充源码层面的默认值与行为细节(类型定义见 src/index.ts)。
| 选项 | 类型 | 默认值 | 说明 |
|---|---|---|---|
dir | string | process.cwd() | 静态资源根目录 |
maxAge | number | 0 | Cache-Control的max-age秒数 |
cacheControl | string \| (path) => string | undefined | 自定义缓存控制头,优先级高于maxAge |
buffer | boolean | false | 是否将文件内容读入内存,替代每次请求流式读取 |
gzip | boolean | false | 客户端accept-encoding含 gzip 时,运行时压缩响应 |
usePrecompiledGzip | boolean | false | 优先使用磁盘上的.gz预压缩文件(类似 nginxgzip_static) |
alias | object | {} | URL 别名映射,见 Aliases 章节 |
prefix | string | '' | URL 前缀,见下文 |
dynamic | boolean | false | 是否允许动态加载初始化时未缓存的文件 |
filter | function \| string[] | undefined | 初始化扫描目录时的文件过滤;数组形式则仅白名单这些文件 |
preload | boolean | true | 是否在初始化时扫描并缓存全部资产,常与dynamic配合使用 |
files | object | undefined | 外部文件存储(普通对象或 LRU 实例),见 Files 章节 |
dir:服务根目录
默认process.cwd()。实践中建议始终显式传入绝对路径,避免进程启动目录不确定导致服务错乱。
maxAge 与 cacheControl:缓存控制头
maxAge默认0,最终以public, max-age=<n>的形式写入Cache-Control响应头。而cacheControl支持两种形态:
- 字符串:直接作为
Cache-Control的值,覆盖maxAge; - 函数:接收文件绝对路径为参数,返回字符串,实现"按文件差异化"缓存策略。源码在
loadFile中执行typeof options.cacheControl === 'function' ? options.cacheControl(filename) : options.cacheControl(见 src/index.ts)。
测试用例 "should support cacheControl function"(见 test/index.test.ts)展示了函数形态的用法:对index.ts返回public, max-age=1000,对其他文件返回public, max-age=0,两个请求分别得到对应的Cache-Control头。
buffer:内存缓冲模式
默认false时,中间件按需以createReadStream流式发送文件,内存占用低但每次请求都有磁盘 I/O;设为true后,文件内容在loadFile阶段通过readFileSync一次性读入内存(obj.buffer = buffer),后续请求直接发送 Buffer,适合体积小、访问量高的场景。
gzip 与 usePrecompiledGzip:压缩策略双通道
两者语义不同:
gzip: true:运行时压缩。源码中只有当file.length > 1024(超过 1KB)且文件 MIME 类型可压缩(通过@eggjs/compressible判断)、且客户端声明支持 gzip 时才会压缩,压缩结果缓存到zipBuffer后复用,避免重复压缩;usePrecompiledGzip: true:优先使用磁盘上的.gz文件。当请求命中 gzip 时,若缓存中存在<filename>.gz的预压缩内容(gzFile && gzFile.buffer),直接取用而不重新压缩——这正是 nginxgzip_static的思路。
无论哪种压缩,中间件都会在启用 gzip 时设置Vary: Accept-Encoding(ctx.vary('Accept-Encoding')),保证缓存代理不会把 gzip 响应误发给不支持 gzip 的客户端。测试用例 "should serve files with gzip buffer" 验证了 gzip 响应同时携带Content-Encoding: gzip、Vary: Accept-Encoding与Content-Length头(见 test/index.test.ts)。
prefix:URL 前缀
prefix默认空字符串。源码会对它做归一化处理:(options.prefix ?? '').replace(/\/*$/, '/'),保证前缀以单个/结尾,并取filePrefix = path.normalize(options.prefix.replace(/^\//, ''))用于动态加载时裁剪路径。请求处理首先校验ctx.path.startsWith(options.prefix),不匹配则直接next()放行(见 src/index.ts)。测试用例 "should serve files with prefix" 验证了/static/src/index.ts形态的访问。
filter:初始化扫描过滤
filter支持两种形态(见 src/index.ts):
- 函数
(filePath) => boolean:返回true的文件才会被预加载,常用于跳过源码、构建中间产物等; - 字符串数组:等价于白名单,仅当文件名存在于数组中才加载。
测试用例分别验证了函数形态(排除node_modules)与数组形态(filter: ['index.js']时请求/README.md返回 404)的行为(见 test/index.test.ts)。
preload 与 dynamic:初始化缓存与动态加载
preload默认true,中间件在创建时通过fs-readdir-recursive递归扫描dir,自动跳过以.开头的隐藏文件与node_modules目录,然后逐个调用loadFile将文件元数据(含 MD5)写入缓存。
dynamic默认false,开启后,请求未命中缓存的文件时,中间件会在运行时尝试加载:
- 拒绝隐藏文件(
path.basename(filename)[0] === '.'); - 裁剪
prefix前缀得到相对路径; - 拼接完整路径并做目录逃逸防护:
fullpath.startsWith(dir)不成立则直接放行(对应测试 "should loadFile under options.dir",对/%2E%2E/package.json的路径穿越请求返回 404); - 校验文件存在且为普通文件后,调用
loadFile写入缓存。
两个选项的配合关系是:关闭 preload、开启 dynamic 即"懒加载"模式——启动时不扫描目录,首次请求才加载并缓存;而preload: true, dynamic: false则是"启动全量缓存 + 拒绝新文件"的经典生产模式。测试 "should options.dynamic and options.preload works fine" 验证了preload: false, dynamic: true时初始files为空对象、请求后缓存被填充(见 test/index.test.ts)。
files:外部文件存储
files可以传入普通对象,也可以是实现了get(key)/set(key, value)两个方法的存储实例(如lru-cache或ylru)。FileManager类在构造时会通过typeof store.set === 'function' && typeof store.get === 'function'区分两类存储并统一封装(见 src/index.ts)。
这一设计带来三个实用能力:
1. 将多个目录合并进单个中间件
与其挂载两次中间件:
app.use(staticCache('/public/js')); app.use(staticCache('/public/css'));不如共享同一个files对象,让两个目录的缓存落在同一份映射里,减少一次函数栈调用与一次哈希查找:
const files = {}; // 挂载中间件 app.use(staticCache('/public/js', {}, files)); // 追加目录到同一存储 staticCache('/public/css', {}, files);2. 运行时编辑缓存元数据
由于files暴露了每个路径的元数据对象,你可以事后修改单个文件的缓存策略。例如把/package.json的maxAge从一年改成一个月:
const files = {}; app.use( staticCache( '/public', { maxAge: 60 * 60 * 24 * 365, }, files, ), ); files['/package.json'].maxAge = 60 * 60 * 24 * 30;测试 "should be configurable via object" 正是通过修改files['/package.json'].maxAge = 1并断言响应头变为Cache-Control: public, max-age=1来验证该能力(见 test/index.test.ts)。
3. 使用 LRU 缓存避免动态模式下的 OOM
动态模式下缓存会无限增长,官方文档建议注入带容量上限的 LRU 实例:
const LRU = require('lru-cache'); const files = new LRU({ max: 1000 }); app.use( staticCache({ dir: '/public', dynamic: true, files, }), );测试 "should work fine when new file added in dynamic mode with LRU"(见 test/index.test.ts)使用容量为 1 的ylru实例验证了 LRU 淘汰行为:连续请求a.js、b.js、c.js后,a.js、b.js依次被挤出缓存,再次访问a.js又挤掉了c.js。
alias:URL 别名
alias是 URL 路径到真实文件路径的映射,不产生重定向,内部直接改写查找键。典型场景是 favicon:站内多处引用/favicon.png,磁盘上只保留一张favicon-32.png:
const options = { alias: { '/favicon.png': '/favicon-32.png', }, };请求/favicon.png时实际返回/favicon-32.png的内容。源码在路径解码与归一化之后执行别名替换:if (options.alias && options.alias[filename]) filename = options.alias[filename](见 src/index.ts)。测试用例同时覆盖了 POSIX 与 Windows 路径分隔符两种别名键(见 test/index.test.ts)。
请求处理链路与缓存原理解析
将 src/index.ts 中的中间件主流程展开,一次请求的完整路径如下:
- 方法过滤:仅处理
GET与HEAD,其余方法直接next()放行(测试 "should 404 Not Found for other Methods" 表明PUT请求会落到下游路由,返回 404); - 前缀校验:
ctx.path不以prefix开头则放行; - 路径归一化:先
safeDecodeURIComponent解码(支持/%E4%B8%AD%E6%96%87这类编码路径),再做path.normalize(容忍//index这类异常路径,对应测试 "should accept abnormal path"); - 别名替换:命中
alias则改写文件名; - 查缓存:
files.get(filename)命中则直接使用;未命中时按上文 dynamic 章节的流程决定是否动态加载,否则放行; - 新鲜度校验:非 buffer 模式下先用
fs.stat对比mtime,若磁盘文件时间戳变化则失效 MD5 并刷新长度;随后设置Last-Modified与ETag,若ctx.fresh为真则返回304 Not Modified(对应测试 "should support conditional HEAD/GET requests"); - 响应组装:设置
Content-Type、Content-Length(有 gzip 时用zipBuffer.length)、Cache-Control、Content-MD5;HEAD请求在此结束; - 内容发送:按 buffer / 预压缩 gzip / 流式 / 运行时 gzip 四种分支发送响应体。
ETag 与 Content-MD5:MD5 双头校验
loadFile在加载文件时即计算obj.md5 = crypto.createHash('md5').update(buffer).digest('base64')(见 src/index.ts),它同时被用作:
ETag响应头:ctx.response.etag = file.md5;Content-MD5响应头:ctx.set('content-md5', file.md5)。
测试 "should set the etag and content-md5 headers" 用同一 MD5 算法独立计算package.json的摘要,断言响应头ETag为"<base64 md5>"且Content-MD5与之相等(见 test/index.test.ts)。
流式模式下文件未被整体读入内存,MD5 无法在加载时计算,中间件会延迟到首次流式发送时通过stream.on('data'/'end')增量计算并缓存,之后即可提供 ETag 支持(见 src/index.ts)。
304 条件请求
中间件完整支持基于Last-Modified与ETag的条件请求:客户端携带If-None-Match或If-Modified-Since访问时,命中缓存校验后由ctx.fresh判定直接返回304,不发送正文。测试覆盖了 GET 与 HEAD 两种方法的条件请求(见 test/index.test.ts)。
在 Egg 应用中的集成:@eggjs/static 插件
@eggjs/koa-static-cache是 Egg 内置静态服务插件@eggjs/static的底层实现。在 packages/egg/src/config/plugin.ts 中,static插件默认启用,其配置类型声明位于 plugins/static/src/types.ts,实际逻辑见 plugins/static/src/app/middleware/static.ts。
@eggjs/static透传koa-static-cache的全部选项,并在此基础上定义了自己的默认值(见 plugins/static/src/config/config.default.ts 与 plugins/static/README.md):
prefix: '/public/';dir: path.join(appInfo.baseDir, 'app/public');dynamic: true(懒加载);preload: false;maxAge:生产环境31536000,其他环境0;buffer:生产环境true,其他环境false;- 额外选项
maxFiles: 1000:动态模式下缓存条目上限,插件内部在dynamic开启且未传入files时自动注入new LRU(newOptions.maxFiles)(见 plugins/static/src/app/middleware/static.ts)。
由此带来两个对开发体验影响深远的行为:
- 非生产环境:资源不做缓存,修改即生效,方便开发调试;
- 生产环境:资源被访问后才缓存,更新资产需要重启进程——这正是底层
preload缓存模型在 Egg 侧的体现。
dir还支持多目录形式:dir: [dir1, dir2, ...]或dir: [dir1, { prefix: '/static2', dir: dir2 }],插件会为每个目录分别实例化一个staticCache中间件并用koa-compose组合,同时通过koa-range提供 Range 分片支持(见 plugins/static/src/app/middleware/static.ts)。
在 Egg 中自定义静态资源配置:
// {app_root}/config/config.default.ts export default { static: { // 覆盖缓存时长:maxAge: 31536000, }, };实战建议与注意事项
综合文档、源码与测试,给出几条可落地的使用建议:
- 生产环境优先
buffer: true:将高频小文件驻留内存,配合 ETag/304 大幅降低带宽与磁盘 I/O;超大文件建议保持流式(buffer: false),避免撑爆内存。 - 善用
cacheControl函数按文件差异化缓存:例如index.html不缓存、带哈希指纹的静态资源长缓存,这是maxAge全局配置无法做到的。 - 开发期使用
preload: false, dynamic: true:文件即时生效;生产期配合buffer: true获得最佳性能,代价是更新需重启进程——发布前可借助带内容哈希的文件名规避。 - 多目录合并时共享
files对象:既减少中间件栈深度,又能通过编辑元数据实现细粒度缓存策略。 - 动态模式务必配置容量受限的 LRU:无上限的缓存增长会带来 OOM 风险,这正是官方文档专门给出 LRU 示例的原因。
- 路径安全:中间件内置了前缀裁剪与
fullpath.startsWith(dir)双重防线,但自定义prefix与alias时应保持路径规范,避免意外暴露目录外文件。
总结
@eggjs/koa-static-cache通过"启动预加载 + 内存缓冲 + 增量 MD5 ETag + 双通道 gzip"的组合,把静态资源服务从"每次请求读磁盘"优化为"初始化一次、按需零拷贝复用",同时通过files外部存储、filter、cacheControl等设计保持了极高的灵活性。无论是作为 Koa 应用的独立中间件直接使用,还是作为 Egg 内置@eggjs/static插件的底座,它都是一份值得研读的静态缓存实现范本——其完整的参数语义、条件请求与路径安全实现,均可在 源码 与 测试 中得到印证。
【免费下载链接】egg🥚🥚🥚🥚 Born to build better enterprise frameworks and apps with Node.js & Koa. https://307.run/eggcode项目地址: https://gitcode.com/gh_mirrors/eg/egg
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考