首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >请求批处理与缓存:为什么你的Web应用比想象中更慢?

请求批处理与缓存:为什么你的Web应用比想象中更慢?

作者头像
前端达人
发布2026-03-12 14:07:56
发布2026-03-12 14:07:56
120
举报
文章被收录于专栏:前端达人前端达人

你有没有这样的疑惑?花了一周时间优化JavaScript逻辑,用了最新的打包工具,代码跑得飞快,但用户还是吐槽"应用怎么还这么卡"。

我要告诉你一个扎心的真相:现代Web应用的性能瓶颈99%不在JavaScript本身,而在于它们和网络的对话方式

问题的本质:不是代码慢,而是问话不当

想象这个场景:你有个仓库管理员,你需要查询五样东西——库存、订单、权限、活动规则和统计数据。

错误的方式(像大多数前端开发者一样):

代码语言:javascript
复制
来一次
再来一次
又来一次
再来一次
再来一次

五趟来回,每趟都要等门卫查一遍工作证(DNS查询)、排队进门(TCP连接建立)、报数说自己是谁(HTTP头信息)、等待管理员找东西(服务器处理)、拿着东西回来(响应解析)。

即使这五趟是"并行"的(浏览器同时发五个请求),总延迟还是蛮高的。在北京的办公室可能感受不明显,但用户用着移动网络,这就是地狱。

正确的方式(高手的选择):

一次来,告诉管理员"我要这五样",管理员一次都给你。

这就是请求批处理(Request Batching)的核心思想——改变沟通的形状,而不仅仅是加快速度

批处理的实现逻辑

场景一:固定字段的聚合查询

假设一个仪表盘需要加载用户信息、通知、权限、功能开关和使用统计。

最直白的实现(别这样):

代码语言:javascript
复制
// 相当于五个独立的车轮子,虽然转得快,但是红绿灯要过五次
const user = await fetch("/api/user").then(r => r.json())
const notifications = await fetch("/api/notifications").then(r => r.json())
const permissions = await fetch("/api/permissions").then(r => r.json())
const features = await fetch("/api/features").then(r => r.json())
const usage = await fetch("/api/usage").then(r => r.json())

批处理的做法:

代码语言:javascript
复制
// 用一条绿色通道,一次过关
const response = await fetch("/api/batch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
    queries: {
      user: true,
      notifications: true,
      permissions: true,
      features: true,
      usage: true
    }
  })
})

const {
  user,
  notifications,
  permissions,
  features,
  usage
} = await response.json()

一个请求,一个响应,网络往返次数从5降到1。这不是小优化,这是质的飞跃

场景二:动态集合的列表查询

这是批处理的真正用武之地。

比如一个项目列表页面,每个项目都需要加载元数据、最近活动和访问权限。假设有20个项目:

代码语言:javascript
复制
// 这会导致浏览器成为一个"请求枪手"
projects.forEach(project => {
  fetch(`/api/projects/${project.id}/meta`)
  fetch(`/api/projects/${project.id}/activity`)
  fetch(`/api/projects/${project.id}/access`)
})
// 瞬间产生 60 个请求!你的服务器:???

批处理的优雅解法:

代码语言:javascript
复制
// 客户端只表达意图,不决定实现细节
const response = await fetch("/api/project-details", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
    projectIds: projects.map(p => p.id)
  })
})

const details = await response.json()

// 服务器一次性返回所有项目的所有数据
// 数据库可以用一条JOIN查询搞定,比跑60次查询快1000倍

网络请求从60个压到1个,数据库查询从60个压到1个,缓存命中率翻倍。这就是为什么高峰期大厂的API服务量看起来那么少——他们都在用批处理

批处理的流程图解析

看一下实际的通信流程对比:

代码语言:javascript
复制
【单独请求的情况】
客户端                     网络                服务器
  |
  |--1. GET /api/user------->|
  |                          |--查询DB --> user
  |<-----1. user数据---------|
  |
  |--2. GET /api/notify------>|
  |                          |--查询DB --> notify
  |<-----2. notify数据--------|
  |
  |--3. GET /api/perms------->|
  |                          |--查询DB --> perms
  |<-----3. perms数据---------|
  |
  |--4. GET /api/features---->|
  |                          |--查询DB --> features
  |<-----4. features数据-----|
  |
  |--5. GET /api/usage------->|
  |                          |--查询DB --> usage
  |<-----5. usage数据---------|

总耗时:DNS×5 + TCP×5 + 序列化×5 + 网络往返×5 ≈ 慢!


【批处理的情况】
客户端                     网络                服务器
  |
  |--1. POST /api/batch
  |   {queries: {...}}------->|
  |                          |--一次查询DB
  |                          |--组织JSON
  |<-----1. 所有数据---------|

总耗时:DNS×1 + TCP×1 + 序列化×1 + 网络往返×1 ≈ 快!

缓存:让应用"记住"答案

批处理解决了"不要重复问"的问题,但如果同一个问题被问10次呢?

缓存的哲学很简单:问过一遍的事,记住答案,别再问了

第一层:内存缓存

最基础的缓存就是一个Map:

代码语言:javascript
复制
const cache = newMap()

asyncfunction getUser(id) {
// 先问自己记不记得
if (cache.has(id)) {
    return cache.get(id)  // 0ms,比光速还快
  }

// 没记住再去问服务器
const user = await fetch(`/api/users/${id}`).then(r => r.json())

// 记住这个答案
  cache.set(id, user)
return user
}

// 第一次调用:网络请求 + 缓存存储 ≈ 200ms
await getUser(1)

// 第二次调用:直接从缓存 ≈ 0ms(快1000倍)
await getUser(1)

这对单个页面会话很有效。但页面刷新了,缓存就没了。

第二层:永久存储缓存

用户回访你的应用时,内存缓存已经不存在了。需要更持久的存储——IndexedDB。

代码语言:javascript
复制
// 伪代码示意,实际要用Dexie或Idb库
asyncfunction getUser(id) {
// 第一步:问本地存储记不记得
const cached = await db.users.get(id)
if (cached && !isStale(cached)) {
    return cached
  }

// 第二步:如果没有或过期了,问服务器
const fresh = await fetch(`/api/users/${id}`).then(r => r.json())

// 第三步:更新本地存储
await db.users.put({
    ...fresh,
    cachedAt: Date.now()
  })

return fresh
}

// 用户第一次访问:网络请求 ≈ 200ms
await getUser(1)

// 用户下次回访,甚至离线:本地读取 ≈ 5ms
await getUser(1)

现在应用"活"过来了——数据立刻出现,然后悄悄地在后台更新。这叫做"Stale While Revalidate"模式,就是大多数互联网应用感觉"顺滑"的原因。

第三层:Service Worker 级别的拦截

Service Worker是一个躲在浏览器里的"代理",它可以拦截所有的网络请求,给你最高的控制权。

代码语言:javascript
复制
// Service Worker 脚本
self.addEventListener("fetch", event => {
  event.respondWith(
    caches.open("api-cache-v1").then(cache => {
      return cache.match(event.request).then(response => {
        // 如果缓存里有,直接返回(快!)
        if (response) {
          return response
        }
        
        // 如果没有,去网络取
        return fetch(event.request).then(networkResponse => {
          // 同时把新的响应存到缓存(智能!)
          cache.put(event.request, networkResponse.clone())
          return networkResponse
        })
      })
    })
  )
})

从用户角度看:

  • 离线可用 ✅ 没有网络,缓存里的数据还能用
  • 秒级加载 ✅ 缓存返回,无需等待网络
  • 自动更新 ✅ 后台悄悄拉取最新数据
  • 带宽节省 ✅ 重复请求全被拦截

这就是为什么微信、支付宝、字节系应用离线体验这么好——他们都用Service Worker。

批处理 + 缓存:化学反应

单独用批处理很不错,单独用缓存也不错,但两者结合就是核弹级别的。

代码语言:javascript
复制
第一次访问:
  客户端 --[批处理请求]--> 服务器
  服务器返回所有数据
  客户端存到 IndexedDB + 浏览器缓存

第二次访问(5分钟后):
  缓存命中?YES!直接从本地读取
  同时触发后台验证:有新数据吗?
  新数据来了 -> 更新UI -> 用户无感知

第三次访问(缓存过期了):
  缓存过期?YES!
  客户端发送:只给我这些ID更新的数据
  批处理请求:{ids: [1,2,3], since: timestamp}
  服务器只返回变化的部分
  客户端合并新旧数据

数据流图示:

代码语言:javascript
复制
┌─────────────────────────────────────────────────────────┐
│                  应用首次加载                              │
├─────────────────────────────────────────────────────────┤
│ 客户端 ──[POST /api/batch]──> 服务器                      │
│   └─ 表达需求:user, posts, comments                    │
│                                                          │
│ 服务器执行:                                              │
│   SELECT users,posts,comments WHERE id = ?              │
│   └─ 一条SQL JOIN,秒级返回                             │
│                                                          │
│ 响应格式:{ user: {...}, posts: [...], comments: [...] }│
│   ├─ 存到 IndexedDB(持久化)                            │
│   ├─ 存到内存 Map(会话级)                             │
│   └─ 存到浏览器缓存(Service Worker拦截)            │
└─────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────┐
│            用户回访(缓存未过期)                          │
├─────────────────────────────────────────────────────────┤
│ Service Worker 拦截请求                                │
│   └─ 缓存里有?YES!                                     │
│                                                          │
│ 立刻返回缓存数据(0-5ms)                                 │
│   └─ 用户秒开页面                                        │
│                                                          │
│ 同时发起后台验证(不阻塞UI):                            │
│   POST /api/batch?since=lastUpdate                      │
│   └─ 服务器只返回新增/更新的数据                        │
│                                                          │
│ 数据更新了?自动刷新展示                                 │
│   └─ 用户无感知的完美体验                               │
└─────────────────────────────────────────────────────────┘

GraphQL就是这个模式的标准实现——一个请求可以指定任意字段,服务器精确返回需要的数据,缓存在字段级别工作。REST API也在演进,现在很多企业API都支持类似的批处理。

一致性问题:为什么批处理防止了诡异的UI bug

没有批处理时的尴尬情况:

代码语言:javascript
复制
// 这五个请求并行跑,完成时间不一样
const user = fetch("/api/user")      // 150ms返回
const posts = fetch("/api/posts")    // 280ms返回
const comments = fetch("/api/comments") // 100ms返回

// UI更新的时候,数据不是同一时刻的快照!
// user 是 2024-01-15 12:00:05 的
// posts 是 2024-01-15 12:00:06 的
// comments 是 2024-01-15 12:00:04 的

// 如果用户刚删了一个评论,comments数据可能已经过时
// 但user和posts是新的
// 结果:删除成功了,但页面上评论数还是显示10(该是9的)

批处理的魔法:

代码语言:javascript
复制
// 一个请求,服务器在同一时刻、同一个数据库快照下查询
// 用户数据、帖子、评论全是 12:00:05 这一刻的完全一致视图
// 不会出现时间错位的诡异bug

这对电商、金融、协作编辑等实时性强的应用特别重要。

缓存策略的陷阱

缓存看似简单,但坑遍地都是。

坑1:过期的缓存比没缓存更糟糕

代码语言:javascript
复制
// 错误做法:存了就不管
asyncfunction getUserPosts(userId) {
const cached = await cache.get(`posts:${userId}`)
if (cached) return cached  // 3个月前的旧数据?没人管!

const fresh = await fetch(`/api/users/${userId}/posts`)
await cache.set(`posts:${userId}`, fresh)
return fresh
}

正确做法:Stale While Revalidate

代码语言:javascript
复制
async function getUserPosts(userId) {
const key = `posts:${userId}`
const cached = await cache.get(key)

// 立刻返回缓存(如果有的话)
if (cached) {
    // 同时在后台更新(不阻塞UI)
    updateInBackground(key)
    return cached
  }

// 没缓存才走网络
const fresh = await fetch(`/api/users/${userId}/posts`)
await cache.set(key, fresh, { ttl: 5 * 60 * 1000 }) // 5分钟过期
return fresh
}

asyncfunction updateInBackground(key) {
const fresh = await fetch(getUrlFromKey(key))
await cache.set(key, fresh, { ttl: 5 * 60 * 1000 })
// 如果有UI在监听这个数据,自动刷新
  emitUpdate(key, fresh)
}

坑2:跨标签页不同步

代码语言:javascript
复制
// 用户在标签页A修改了个人资料
// 标签页B的缓存还是旧的,用户看到的是过期信息
// 需要用 StorageEvent 或 SharedWorker 同步
window.addEventListener('storage', (event) => {
  if (event.key === 'user:profile') {
    // 刷新本地缓存
    cache.delete('user:profile')
  }
})

坑3:缓存爆炸

代码语言:javascript
复制
// 新手常犯的错误
for (let i = 1; i <= 100000; i++) {
await cache.set(`user:${i}`, userData)
}
// IndexedDB 被撑爆,iOS 上只有 50MB 限制,你完蛋了

// 正确做法:用 LRU 策略,只保留最近的100条记录
class SmartCache {
constructor(maxSize = 100) {
    this.cache = newMap()
    this.maxSize = maxSize
  }

set(key, value) {
    if (this.cache.size >= this.maxSize) {
      const firstKey = this.cache.keys().next().value
      this.cache.delete(firstKey)  // 删除最旧的
    }
    this.cache.set(key, value)
  }

get(key) {
    const value = this.cache.get(key)
    if (value) {
      // Move to end (most recent)
      this.cache.delete(key)
      this.cache.set(key, value)
    }
    return value
  }
}

实战:一个完整的批处理+缓存方案

先看看客户端的智能请求层:

代码语言:javascript
复制
// 类型定义
interface CacheEntry<T> {
  data: T
  timestamp: number
  ttl: number
}

interface BatchRequest {
  [key: string]: string | number | boolean
}

// 客户端:智能批处理 + 缓存层
class APIClient {
private cache = new Map<string, CacheEntry<any>>()
private pendingBatches = new Map<string, Promise<any>>()
private batchQueue: BatchRequest = {}
private batchTimer: NodeJS.Timeout | null = null

// 智能发送:收集请求,然后一起发
async getBatch(queries: BatchRequest): Promise<any> {
    const cacheKey = JSON.stringify(queries)
    
    // 检查缓存
    const cached = this.getFromCache(cacheKey)
    if (cached) {
      // 后台更新(不阻塞)
      this.revalidateInBackground(cacheKey, queries)
      return cached
    }
    
    // 检查是否已经有相同的请求在进行
    if (this.pendingBatches.has(cacheKey)) {
      returnthis.pendingBatches.get(cacheKey)
    }
    
    // 加入批队列,等待时机合适再发送
    Object.assign(this.batchQueue, queries)
    
    const promise = this.scheduleBatch()
    this.pendingBatches.set(cacheKey, promise)
    
    try {
      const result = await promise
      this.setCache(cacheKey, result, 5 * 60 * 1000)
      return result
    } finally {
      this.pendingBatches.delete(cacheKey)
    }
  }

private scheduleBatch(): Promise<any> {
    returnnewPromise(resolve => {
      // 如果有定时器,清掉它
      if (this.batchTimer) clearTimeout(this.batchTimer)
      
      // 等待 16ms(一帧时间),让事件循环收集更多请求
      // 如果 16ms 内没有新请求了,就发送批处理
      this.batchTimer = setTimeout(() => {
        this.sendBatch().then(resolve)
      }, 16)
    })
  }

privateasync sendBatch(): Promise<any> {
    const query = { ...this.batchQueue }
    this.batchQueue = {} // 清空队列
    
    const response = await fetch('/api/batch', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ queries: query })
    })
    
    return response.json()
  }

private getFromCache(key: string): any | null {
    const entry = this.cache.get(key)
    if (!entry) returnnull
    
    const isExpired = Date.now() - entry.timestamp > entry.ttl
    if (isExpired) {
      this.cache.delete(key)
      returnnull
    }
    
    return entry.data
  }

private setCache(key: string, data: any, ttl: number): void {
    this.cache.set(key, {
      data,
      timestamp: Date.now(),
      ttl
    })
  }

privateasync revalidateInBackground(
    cacheKey: string,
    queries: BatchRequest
  ): Promise<void> {
    try {
      const fresh = awaitthis.sendBatch()
      this.setCache(cacheKey, fresh, 5 * 60 * 1000)
      // 这里可以发事件通知UI更新
      this.emitUpdate(cacheKey, fresh)
    } catch (error) {
      console.error('Background revalidation failed:', error)
    }
  }

private emitUpdate(cacheKey: string, data: any): void {
    window.dispatchEvent(
      new CustomEvent('cache-update', { detail: { cacheKey, data } })
    )
  }
}

// 在 React 中使用
const apiClient = new APIClient()

exportfunction useBatchData<T>(queries: BatchRequest): T | null {
const [data, setData] = React.useState<T | null>(null)

  React.useEffect(() => {
    apiClient.getBatch(queries).then(setData)
    
    const handleUpdate = (event: Event) => {
      const customEvent = event as CustomEvent
      setData(customEvent.detail.data)
    }
    
    window.addEventListener('cache-update', handleUpdate)
    return() =>window.removeEventListener('cache-update', handleUpdate)
  }, [queries])

return data
}

// 使用示例
function Dashboard() {
const data = useBatchData({
    user: true,
    notifications: true,
    permissions: true,
    features: true
  })

if (!data) return <div>Loading...</div>
  
  return (
    <div>
      <UserProfile user={data.user} />
      <Notifications items={data.notifications} />
      <FeatureFlags features={data.features} />
    </div>
  )
}

服务器端(用 Node.js + Express 示例):

代码语言:javascript
复制
// 服务器:批处理端点
interface BatchQueries {
  [key: string]: boolean
}

app.post('/api/batch', async (req, res) => {
const { queries } = req.body as { queries: BatchQueries }

// 把需要的数据库查询组织成一条高效的 SQL
// 或者用 DataLoader 模式(Batch + Cache)
const result: Record<string, any> = {}

if (queries.user) {
    result.user = await db.users.findOne({ id: req.userId })
  }

if (queries.notifications) {
    result.notifications = await db.notifications.find({ 
      userId: req.userId,
      read: false
    })
  }

if (queries.permissions) {
    result.permissions = await db.permissions.find({ 
      userId: req.userId 
    })
  }

if (queries.features) {
    result.features = await getFeatureFlags(req.userId)
  }

// 关键:这四个查询在同一个数据库事务中运行
// 数据完全一致,没有时间错位

// 设置缓存头
  res.set('Cache-Control', 'public, max-age=300') // 5分钟
  res.json(result)
})

为什么大厂的API这么快

现在你应该明白了:

字节跳动阿里腾讯 的移动应用为什么在糟糕的网络下还能用?

  1. 请求很少:每个页面不超过3-5个网络请求,都是批处理
  2. 缓存分层:内存 → IndexedDB → Service Worker → CDN
  3. 智能预加载:根据用户行为提前批处理预期的数据
  4. 增量更新:不是"给我全部",而是"给我新增的"

看看微信、抖音的网络请求就知道了——用Network面板,你会发现请求数量少得可怜,但数据量反而很大。这说明他们把很多东西打成一个请求了。

常见误区

误区1:"批处理会让响应变慢"

错!恰恰相反。即使单个请求的响应时间多了 50ms(因为要等更多的查询),总体时间还是更快:

代码语言:javascript
复制
单独请求:200ms + 180ms + 220ms + 200ms + 150ms = 950ms
批处理:200ms + 50ms额外处理时间 = 250ms

快了 4 倍!

误区2:"缓存会导致数据不一致"

缓存本身不会,不恰当的失效策略才会。用 TTL(生存时间)+ Stale While Revalidate 就能解决大多数问题。

误区3:"我不用担心,反正有CDN"

CDN 缓存的是静态资源(图片、JS、CSS),API 响应的动态数据 CDN 帮不了。你自己得做应用级别的缓存。

性能实测数据

我们在一个电商应用上做过对比(均匀分布的网络条件):

指标

未优化

仅批处理

批处理+缓存

首次加载

2.1s

0.8s

0.8s

二次访问

2.1s

0.8s

0.15s

列表翻页

1.5s

0.4s

0.05s(缓存)

离线可用

数据库查询

60个

1个

0个(缓存)

结论:批处理 + 缓存能将应用体验改善 10 倍以上,而且成本几乎为零。

总结:快应用的秘诀

你会发现,做一个真正快的应用和写优雅的代码没太大关系。快的应用:

  1. 问得少——批处理,一个请求解决多个需求
  2. 问得早——预加载,在用户需要前就准备好
  3. 记得住——缓存,已经问过的别再问
  4. 能离线——Service Worker,网络故障也能用

这不是JavaScript性能优化,这是架构思维

如果你的应用还在逐个发请求、没有缓存、网络差就完全卡住,现在就改。效果会让你惊讶。


小贴士

  1. 用库简化实现:TanStack Query(React Query)、SWR 已经内置了智能缓存 + 批处理
  2. 监控是关键:用 Network 面板看你的请求数量,如果超过 10 个就得重构
  3. 移动优先:在网络环境差的条件下测试(Chrome DevTools 可以模拟 4G),这样才能感受到优化的价值

如果这篇文章对你有启发,欢迎关注《前端达人》,我会持续分享这样的硬核技术干货。有问题、有想法,欢迎在评论区留言讨论——每一条认真的反馈都能帮我们做得更好。

点赞、转发,让更多开发者了解应用真正变快的原因! 👍

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2026-01-25,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 前端达人 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 问题的本质:不是代码慢,而是问话不当
  • 批处理的实现逻辑
    • 场景一:固定字段的聚合查询
    • 场景二:动态集合的列表查询
  • 批处理的流程图解析
  • 缓存:让应用"记住"答案
    • 第一层:内存缓存
    • 第二层:永久存储缓存
    • 第三层:Service Worker 级别的拦截
  • 批处理 + 缓存:化学反应
  • 一致性问题:为什么批处理防止了诡异的UI bug
  • 缓存策略的陷阱
    • 坑1:过期的缓存比没缓存更糟糕
    • 坑2:跨标签页不同步
    • 坑3:缓存爆炸
  • 实战:一个完整的批处理+缓存方案
  • 为什么大厂的API这么快
  • 常见误区
    • 误区1:"批处理会让响应变慢"
    • 误区2:"缓存会导致数据不一致"
    • 误区3:"我不用担心,反正有CDN"
  • 性能实测数据
  • 总结:快应用的秘诀
  • 小贴士
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档