前端性能优化:巧用请求锁解决列表渲染中的字典请求风暴

90 阅读5分钟

一、问题背景:当公共组件变成性能瓶颈

最近在做流程管理时,发现一个看似"优化"的公共组件,反而成了性能瓶颈。

场景还原
一个列表页面,每行数据都需要显示流程状态、类型等字典项。我们写了一个"聪明"的公共组件,它会自动查询并缓存字典数据——这本是为了减少重复请求。但现实很骨感:当列表有12条数据时,首次加载竟然会发起12次完全相同的接口请求image.png

痛点分析

  • 每个组件都认为自己是"第一个"请求者
  • 接口未返回前,其他组件看不到缓存
  • 网络请求呈指数级增长,页面加载缓慢

二、传统方案的局限性

首先想到的解决方案是 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%(后续)
代码侵入性无(纯前端方案)

六、方案优势

  1. 零侵入:不依赖框架状态管理,不改业务代码
  2. 高兼容:支持多标签页、多组件实例
  3. 智能缓存:按flag粒度更新,减少无效请求
  4. 超时保护:防止死锁,保证系统稳定性
  5. 性能显著:从O(N)请求优化到O(1)请求

七、适用场景

✅ 适合:

  • 公共组件的字典查询
  • 多实例共享数据的场景
  • 无法使用状态管理的遗留系统

❌ 不适合:

  • 数据实时性要求极高的场景
  • 数据量极大的缓存管理
  • 需要服务端推送更新的场景

八、总结与思考

这个优化案例给我们几点启示:

  1. 缓存不只是存储,更是同步机制:要考虑多实例间的数据一致性
  2. 最小化锁粒度:锁的粒度越细,并发性能越好
  3. 防御式编程:超时机制、异常处理必不可少
  4. 度量驱动优化:用数据说话,量化优化效果

技术选型的核心:没有最好的方案,只有最适合当前约束条件的方案。在无法改变架构的情况下,我们通过巧妙的前端缓存+锁机制,用最小的代价解决了最大的性能问题。


优化永无止境,但每一次优化都让用户体验更好一点点。如果你的项目也遇到类似问题,不妨试试这个方案。欢迎交流讨论!