压缩中间件

要点

  • compress() 中间件对响应体进行 gzip 或 deflate 压缩,减少传输体积
  • 压缩只对有 Accept-Encoding: gzip 头的请求生效
  • 小于阈值的响应不压缩(压缩小文件反而更大)
  • 已经压缩的内容(图片、视频)不重复压缩
  • 压缩消耗 CPU,适合文本类响应(JSON、HTML、CSS)
  • CDN 和反向代理(Nginx)通常已经处理压缩,应用层可以不做

1. 基本用法

compress() 中间件压缩响应体:

// src/index.ts
import { Hono } from 'hono'
import { compress } from 'hono/compress'
 
const app = new Hono()
 
app.use('*', compress())
 
app.get('/api/large-data', (c) => {
  // 返回大 JSON 响应
  const data = Array.from({ length: 1000 }, (_, i) => ({
    id: i,
    name: `Item ${i}`,
    description: 'This is a long description that benefits from compression',
  }))
  return c.json(data)
})
 
export default app

客户端请求带 Accept-Encoding: gzip 时,响应自动压缩:

HTTP/1.1 200 OK
Content-Encoding: gzip
Content-Type: application/json
Vary: Accept-Encoding
 
[gzip compressed data]

压缩后传输体积可能减少 60-80%,显著降低带宽消耗和传输延迟。

2. 压缩算法

compress() 支持两种算法:

2.1 gzip

最常用的压缩算法,兼容性好:

app.use('*', compress({
  encoding: 'gzip',
}))

2.2 deflate

压缩率稍高,但兼容性不如 gzip:

app.use('*', compress({
  encoding: 'deflate',
}))

默认 compress() 会根据客户端 Accept-Encoding 头选择最优算法(优先 gzip)。

现代浏览器还支持 Brotli(br),但 Hono 内置的 compress() 不支持 Brotli。如果需要 Brotli,可以使用第三方库或在 CDN/Nginx 层面处理。

3. 压缩阈值

默认 compress() 对大于 1KB 的响应压缩。小于阈值的响应不压缩(压缩后可能更大):

app.use('*', compress({
  threshold: 1024,  // 1KB 以下不压缩
}))

调整阈值需要根据实际响应大小权衡:

  • 阈值太低:小文件压缩后可能更大,浪费 CPU
  • 阈值太高:中等大小的文件错过压缩机会

常见值是 1KB-10KB。

4. 不压缩的内容类型

某些内容类型不适合压缩:

app.use('*', compress({
  // 不压缩已经压缩过的内容
  skip: (c) => {
    const contentType = c.res.headers.get('Content-Type') ?? ''
    // 图片、视频、字体等已经压缩过
    return contentType.startsWith('image/')
      || contentType.startsWith('video/')
      || contentType.startsWith('font/')
  },
}))

适合压缩的内容类型:

  • application/json
  • text/html
  • text/css
  • text/plain
  • application/javascript

不适合压缩的内容类型:

  • image/jpegimage/pngimage/webp(已经压缩)
  • video/mp4audio/mpeg(已经压缩)
  • font/woff2(已经压缩)
  • application/pdf(部分压缩)
  • application/zip(已经压缩)

5. 压缩与缓存

压缩后的响应可以被缓存,但需要注意 Vary 头:

Vary: Accept-Encoding

Vary 告诉 CDN 和浏览器,响应内容取决于 Accept-Encoding 头。同一个 URL,带 gzip 和不带 gzip 的请求,返回不同的缓存版本。

如果 CDN 忽略了 Vary 头,可能把压缩版本返回给不支持 gzip 的客户端,导致乱码。

5.1 压缩在缓存之前

如果先缓存后压缩,同一个响应只需要压缩一次:

// 先 cache,后 compress
app.use('/api/*', cache({ cacheControl: 'max-age=3600' }))
app.use('/api/*', compress())

但这样缓存的是未压缩版本,每个客户端请求都要重新压缩。

5.2 压缩在缓存之前(应用层)

如果先压缩后缓存,缓存的是压缩版本,不同 Accept-Encoding 的客户端需要不同的缓存:

// 先 compress,后 cache
app.use('/api/*', compress())
app.use('/api/*', cache({ cacheControl: 'max-age=3600' }))

需要 Vary: Accept-Encoding 头让缓存正确区分。

实际选择取决于部署架构。如果 CDN 已经处理了压缩,应用层可以只做缓存不做压缩。

6. 压缩性能

压缩消耗 CPU。高并发场景下,压缩可能成为瓶颈:

// 监控压缩耗时
app.use('*', async (c, next) => {
  const start = Date.now()
  await next()
  const duration = Date.now() - start
 
  if (c.res.headers.get('Content-Encoding') === 'gzip') {
    console.log(`Compression took ${duration}ms`)
  }
})

优化方向:

  1. 提高阈值:小文件不压缩,减少 CPU 消耗
  2. 使用更快的算法:gzip level 1(最快)vs level 9(最高压缩率)
  3. 在 CDN 层压缩:应用层不压缩,交给 CDN 处理
  4. 预压缩:构建时压缩静态文件,运行时直接返回

7. 与 CDN 和反向代理的配合

大部分 CDN(Cloudflare、CloudFront)和反向代理(Nginx、Caddy)默认开启压缩。如果上游已经压缩,应用层再压缩是浪费。

判断是否需要应用层压缩:

  1. CDN 开启压缩:应用层不需要压缩
  2. Nginx 开启 gzip:应用层不需要压缩
  3. 直接暴露应用:应用层需要压缩
// 只在直接暴露应用时开启压缩
if (process.env.DEPLOY_MODE === 'direct') {
  app.use('*', compress())
}

8. 压缩与流式响应

流式响应(c.stream())也可以压缩,但需要注意缓冲区:

app.get('/api/stream', compress(), (c) => {
  return c.stream(async (stream) => {
    for (let i = 0; i < 100; i++) {
      await stream.write(`data: chunk ${i}\n\n`)
      await new Promise((resolve) => setTimeout(resolve, 100))
    }
  })
})

压缩中间件会缓冲数据,直到达到一定大小或流结束才发送。对于实时性要求高的流式响应,可能需要调整缓冲区大小或禁用压缩。

9. 测试压缩

// tests/compress.test.ts
import { describe, it, expect } from 'vitest'
import { Hono } from 'hono'
import { compress } from 'hono/compress'
 
describe('compress middleware', () => {
  it('压缩响应体', async () => {
    const app = new Hono()
    app.use('*', compress())
    app.get('/', (c) => c.text('a'.repeat(10000)))
 
    const res = await app.request('/', {
      headers: { 'Accept-Encoding': 'gzip' },
    })
 
    expect(res.headers.get('Content-Encoding')).toBe('gzip')
    expect(res.headers.get('Vary')).toBe('Accept-Encoding')
  })
 
  it('小响应不压缩', async () => {
    const app = new Hono()
    app.use('*', compress({ threshold: 1024 }))
    app.get('/', (c) => c.text('small'))
 
    const res = await app.request('/', {
      headers: { 'Accept-Encoding': 'gzip' },
    })
 
    expect(res.headers.get('Content-Encoding')).toBeNull()
  })
 
  it('不支持 gzip 的客户端不压缩', async () => {
    const app = new Hono()
    app.use('*', compress())
    app.get('/', (c) => c.text('a'.repeat(10000)))
 
    const res = await app.request('/')  // 没有 Accept-Encoding 头
 
    expect(res.headers.get('Content-Encoding')).toBeNull()
  })
})

总结

压缩中间件减少响应体的传输体积,降低带宽消耗和传输延迟。Hono 内置的 compress() 中间件支持 gzip 和 deflate 算法。

这一节涉及到的几个层次:

  1. 基本用法compress() 自动压缩响应体
  2. 压缩算法:gzip(默认)vs deflate,Brotli 需要第三方支持
  3. 压缩阈值:小文件不压缩,避免压缩后更大
  4. 内容类型:文本类适合压缩,图片/视频/字体不适合
  5. 压缩与缓存Vary: Accept-Encoding 确保缓存正确区分
  6. 性能考量:压缩消耗 CPU,高并发场景需要权衡
  7. 与 CDN 配合:CDN 已开启压缩时,应用层可以不做

压缩是一个低成本的优化。如果部署架构里没有 CDN 或反向代理处理压缩,应用层开启 compress() 可以显著减少带宽消耗。

下一篇看中间件组合模式——工厂模式、compose、依赖注入、作用域隔离。