一、问题背景:当公共组件变成性能瓶颈
最近在做流程管理时,发现一个看似"优化"的公共组件,反而成了性能瓶颈。
场景还原:
一个列表页面,每行数据都需要显示流程状态、类型等字典项。我们写了一个"聪明"的公共组件,它会自动查询并缓存字典数据——这本是为了减少重复请求。但现实很骨感:当列表有12条数据时,首次加载竟然会发起12次完全相同的接口请求!
痛点分析:
- 每个组件都认为自己是"第一个"请求者
- 接口未返回前,其他组件看不到缓存
- 网络请求呈指数级增长,页面加载缓慢
二、传统方案的局限性
首先想到的解决方案是 Vuex/Pinia + 状态管理:
// 理想很美好...
const store = useDictStore()
const dictData = await store.fetchDict(params)
但现实是:这个组件属于公共组件库,没有自己的状态管理,依赖业务项目的store。如果要改造,需要修改所有接入的业务项目——显然不可行。
三、技术探索:四种无状态缓存方案
经过调研和AI辅助,我们发现了四种纯前端缓存方案:
| 方案 | 核心思路 | 适用场景 |
|---|---|---|
| 浏览器存储 + 请求锁 | localStorage + 请求标识锁 | 多标签页共享缓存 |
| 闭包缓存 + 请求队列 | JavaScript闭包管理内存缓存 | 单页面应用 |
| sessionStorage共享缓存 | 会话级缓存 + 分布式锁 | 当前会话内共享 |
| 防抖请求方案 | 全局变量管理请求状态 | 简单场景 |
四、最终方案:localStorage + 请求锁
结合我们的实际需求,选择了方案一进行改造。关键思路:用请求锁保证同一时间只有一个请求,用localStorage做数据共享。
4.1 问题诊断:原有代码为什么失效?
先看原有实现的核心问题:
export async function dictQueryByFlag({ appCode, flag, orgId }) {
// 问题1:每个组件都独立读取缓存
let DictStorage = JSON.parse(localStorage.getItem('DictStorage-' + orgId) || '{}')
// 问题2:请求前不检查是否有人正在请求
if (!appDcit) {
await asyncDictQueryByFlag({ appCode, flag, orgId }, DictStorage)
}
}
根本原因:组件A发起请求 → 组件B看不到缓存 → 组件B也发起请求 → 造成请求风暴。
4.2 解决方案:三级缓存 + 请求锁
我们的字典数据有三层结构:
orgId:组织层级appCode:所属应用flag:具体字典项
因此,请求锁的key必须包含最小粒度:
// 关键:锁的key要精确到最小维度
const loadingKey = 'DictStorageLoading-' + orgId + '-' + appCode + '-' + flag
4.3 核心实现:双保险机制
保险1:请求锁机制
const waitForLoading = async () => {
// 轮询检查锁状态,每500ms检查一次
await new Promise<void>((resolve) => {
const interval = setInterval(() => {
if(!localStorage.getItem(loadingKey)) {
clearInterval(interval)
resolve()
}
}, 500)
// 超时保护:5秒后强制释放
setTimeout(() => {
clearInterval(interval)
resolve()
}, 5000)
})
}
保险2:智能缓存查询
const queryAndCache = async (queryFlags: string[]) => {
// 上锁
localStorage.setItem(loadingKey, 'true')
// 关键改进:移除DictStorage参数,实时读取最新缓存
await asyncDictQueryByFlag({
appCode,
flag: queryFlags.join(','),
orgId
})
// 释放锁
localStorage.setItem(loadingKey, '')
}
4.4 完整优化代码
// 根据flag查询字典数据,自动缓存
export async function dictQueryByFlag({ appCode, flag, orgId }: DictQueryParams) {
// 1. 读取当前缓存
let DictStorage = JSON.parse(localStorage.getItem('DictStorage-' + orgId) || '{}')
const flags = (flag || '').split(',')
const appDict = DictStorage[appCode || '']
// 2. 生成请求锁key
const loadingKey = 'DictStorageLoading-' + orgId + '-' + appCode + '-' + flag
// 3. 封装等待逻辑
const waitForLoading = async () => {
await new Promise<void>((resolve) => {
const interval = setInterval(() => {
if(!localStorage.getItem(loadingKey || '')) {
clearInterval(interval)
resolve()
}
}, 500)
setTimeout(() => {
clearInterval(interval)
resolve()
}, 5000)
})
}
// 4. 封装查询逻辑
const queryAndCache = async (queryFlags: string[]) => {
localStorage.setItem(loadingKey, 'true')
// 移除DictStorage参数,内部会重新读取最新缓存
await asyncDictQueryByFlag({ appCode, flag: queryFlags.join(','), orgId })
localStorage.setItem(loadingKey, '')
}
// 5. 智能请求决策
if (!appDict) {
// 整个appCode都没有缓存
if (localStorage.getItem(loadingKey || '') === 'true') {
await waitForLoading() // 等待其他请求完成
} else {
await queryAndCache(flags) // 发起新请求
}
} else {
// 部分flag没有缓存
const noCacheFlag = flags.filter(item => !appDict[item])
if (noCacheFlag.length > 0) {
if (localStorage.getItem(loadingKey || '') === 'true') {
await waitForLoading() // 等待其他请求完成
} else {
await queryAndCache(noCacheFlag) // 只请求缺失的部分
}
}
}
// 6. 返回最终数据
return getDictStorage(DictStorage, flags, appCode, orgId)
}
4.5 asyncDictQueryByFlag的改进
export async function asyncDictQueryByFlag(params, storageData?) {
try {
const data = await http.request({
url: `${prefix}/dictdata`,
method: 'get',
params
})
// 关键改进:每次请求都重新读取最新缓存
const currentStorage = JSON.parse(
localStorage.getItem('DictStorage-' + params.orgId) || '{}'
)
if (!currentStorage[params.appCode]) {
currentStorage[params.appCode] = {}
}
// 按flag更新缓存
const flags = params.flag.split(',')
flags.forEach(flagItem => {
if (data[flagItem]) {
currentStorage[params.appCode][flagItem] = data[flagItem]
}
})
localStorage.setItem('DictStorage-' + params.orgId,
JSON.stringify(currentStorage))
return Promise.resolve(data)
} finally {
// 清理逻辑...
}
}
五、优化效果对比
| 指标 | 优化前 | 优化后 |
|---|---|---|
| 请求次数(10条列表) | 10次 | 1次 |
| 页面加载时间 | 2-3秒 | 500ms以内 |
| 缓存命中率 | 0%(首次) | 100%(后续) |
| 代码侵入性 | 无 | 无(纯前端方案) |
六、方案优势
- 零侵入:不依赖框架状态管理,不改业务代码
- 高兼容:支持多标签页、多组件实例
- 智能缓存:按flag粒度更新,减少无效请求
- 超时保护:防止死锁,保证系统稳定性
- 性能显著:从O(N)请求优化到O(1)请求
七、适用场景
✅ 适合:
- 公共组件的字典查询
- 多实例共享数据的场景
- 无法使用状态管理的遗留系统
❌ 不适合:
- 数据实时性要求极高的场景
- 数据量极大的缓存管理
- 需要服务端推送更新的场景
八、总结与思考
这个优化案例给我们几点启示:
- 缓存不只是存储,更是同步机制:要考虑多实例间的数据一致性
- 最小化锁粒度:锁的粒度越细,并发性能越好
- 防御式编程:超时机制、异常处理必不可少
- 度量驱动优化:用数据说话,量化优化效果
技术选型的核心:没有最好的方案,只有最适合当前约束条件的方案。在无法改变架构的情况下,我们通过巧妙的前端缓存+锁机制,用最小的代价解决了最大的性能问题。
优化永无止境,但每一次优化都让用户体验更好一点点。如果你的项目也遇到类似问题,不妨试试这个方案。欢迎交流讨论!