
你有没有这样的疑惑?花了一周时间优化JavaScript逻辑,用了最新的打包工具,代码跑得飞快,但用户还是吐槽"应用怎么还这么卡"。
我要告诉你一个扎心的真相:现代Web应用的性能瓶颈99%不在JavaScript本身,而在于它们和网络的对话方式。
想象这个场景:你有个仓库管理员,你需要查询五样东西——库存、订单、权限、活动规则和统计数据。
错误的方式(像大多数前端开发者一样):
来一次
再来一次
又来一次
再来一次
再来一次
五趟来回,每趟都要等门卫查一遍工作证(DNS查询)、排队进门(TCP连接建立)、报数说自己是谁(HTTP头信息)、等待管理员找东西(服务器处理)、拿着东西回来(响应解析)。
即使这五趟是"并行"的(浏览器同时发五个请求),总延迟还是蛮高的。在北京的办公室可能感受不明显,但用户用着移动网络,这就是地狱。
正确的方式(高手的选择):
一次来,告诉管理员"我要这五样",管理员一次都给你。
这就是请求批处理(Request Batching)的核心思想——改变沟通的形状,而不仅仅是加快速度。
假设一个仪表盘需要加载用户信息、通知、权限、功能开关和使用统计。
最直白的实现(别这样):
// 相当于五个独立的车轮子,虽然转得快,但是红绿灯要过五次
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())
批处理的做法:
// 用一条绿色通道,一次过关
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个项目:
// 这会导致浏览器成为一个"请求枪手"
projects.forEach(project => {
fetch(`/api/projects/${project.id}/meta`)
fetch(`/api/projects/${project.id}/activity`)
fetch(`/api/projects/${project.id}/access`)
})
// 瞬间产生 60 个请求!你的服务器:???
批处理的优雅解法:
// 客户端只表达意图,不决定实现细节
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服务量看起来那么少——他们都在用批处理。
看一下实际的通信流程对比:
【单独请求的情况】
客户端 网络 服务器
|
|--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:
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。
// 伪代码示意,实际要用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 脚本
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。
单独用批处理很不错,单独用缓存也不错,但两者结合就是核弹级别的。
第一次访问:
客户端 --[批处理请求]--> 服务器
服务器返回所有数据
客户端存到 IndexedDB + 浏览器缓存
第二次访问(5分钟后):
缓存命中?YES!直接从本地读取
同时触发后台验证:有新数据吗?
新数据来了 -> 更新UI -> 用户无感知
第三次访问(缓存过期了):
缓存过期?YES!
客户端发送:只给我这些ID更新的数据
批处理请求:{ids: [1,2,3], since: timestamp}
服务器只返回变化的部分
客户端合并新旧数据
数据流图示:
┌─────────────────────────────────────────────────────────┐
│ 应用首次加载 │
├─────────────────────────────────────────────────────────┤
│ 客户端 ──[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都支持类似的批处理。
没有批处理时的尴尬情况:
// 这五个请求并行跑,完成时间不一样
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的)
批处理的魔法:
// 一个请求,服务器在同一时刻、同一个数据库快照下查询
// 用户数据、帖子、评论全是 12:00:05 这一刻的完全一致视图
// 不会出现时间错位的诡异bug
这对电商、金融、协作编辑等实时性强的应用特别重要。
缓存看似简单,但坑遍地都是。
// 错误做法:存了就不管
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
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)
}
// 用户在标签页A修改了个人资料
// 标签页B的缓存还是旧的,用户看到的是过期信息
// 需要用 StorageEvent 或 SharedWorker 同步
window.addEventListener('storage', (event) => {
if (event.key === 'user:profile') {
// 刷新本地缓存
cache.delete('user:profile')
}
})
// 新手常犯的错误
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
}
}
先看看客户端的智能请求层:
// 类型定义
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 示例):
// 服务器:批处理端点
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)
})
现在你应该明白了:
字节跳动、阿里、腾讯 的移动应用为什么在糟糕的网络下还能用?
看看微信、抖音的网络请求就知道了——用Network面板,你会发现请求数量少得可怜,但数据量反而很大。这说明他们把很多东西打成一个请求了。
错!恰恰相反。即使单个请求的响应时间多了 50ms(因为要等更多的查询),总体时间还是更快:
单独请求:200ms + 180ms + 220ms + 200ms + 150ms = 950ms
批处理:200ms + 50ms额外处理时间 = 250ms
快了 4 倍!
缓存本身不会,不恰当的失效策略才会。用 TTL(生存时间)+ Stale While Revalidate 就能解决大多数问题。
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 倍以上,而且成本几乎为零。
你会发现,做一个真正快的应用和写优雅的代码没太大关系。快的应用:
这不是JavaScript性能优化,这是架构思维。
如果你的应用还在逐个发请求、没有缓存、网络差就完全卡住,现在就改。效果会让你惊讶。
如果这篇文章对你有启发,欢迎关注《前端达人》,我会持续分享这样的硬核技术干货。有问题、有想法,欢迎在评论区留言讨论——每一条认真的反馈都能帮我们做得更好。
点赞、转发,让更多开发者了解应用真正变快的原因! 👍