通信方案设计与实战中的关键问题解析

博主树辰 前端 阅读 544
赞 55 收藏
二维码
手机扫码查看
反馈

优化前:卡得不行

上个月接手一个老项目,首页加载慢得离谱。用户点进去,白屏5秒起步,滚动还卡成PPT。我本地跑 dev 环境都忍不住想关掉 tab。最离谱的是,明明页面结构不复杂,就是一堆卡片列表,但每次滚动到底部加载新数据时,整个页面就“卡住”半秒——不是网络问题,是 UI 线程被占满了。

通信方案设计与实战中的关键问题解析

一开始我以为是 React 渲染太重,结果用 Performance 面板一录,发现瓶颈全在通信上:前端每拉一次数据,就要发 3 个串行请求,每个请求回来还要处理嵌套结构,再 setState 触发 re-render。更糟的是,有些接口返回的数据字段巨多,但实际只用其中 20%,剩下的全是冗余 payload。

找到瓶颈了!

我打开 Chrome DevTools 的 Network 面板,先看 Waterfall。果然,第一个请求(/api/list)花了 1.2s,第二个(/api/details?id=xxx)又等它回来才发,第三个同理。串行请求 + 大量无用字段,直接把首屏时间干到 5s+。

接着用 Performance 录了一次滚动到底部的交互,发现主线程在解析 JSON 和 diff 虚拟 DOM 时 CPU 占用飙到 90% 以上。点开具体函数,看到 JSON.parse 耗时 300ms+,而实际用到的字段不到 1/5。这哪是渲染问题,分明是通信方案太糙了。

试了几种方案,最后这个效果最好

我先尝试把串行请求改成并行。比如原来这样:

// 优化前:串行地狱
const list = await fetch('/api/list').then(r => r.json());
const details = await Promise.all(
  list.map(item => fetch(/api/details?id=${item.id}).then(r => r.json()))
);

改成并行后,总时间从 3s 降到 1.5s,但还是不够快。而且 /api/details 这个接口本身设计就有问题——每个 item 都要单独查,N+1 查询的经典反模式。

于是我和后端同学对齐了一下,把详情接口改成批量查询:

// 优化后:一次请求搞定
const list = await fetch('/api/list').then(r => r.json());
const ids = list.map(item => item.id).join(',');
const details = await fetch(/api/details/batch?ids=${ids}).then(r => r.json());

这下网络请求从 N+1 变成 2 次,但还不够。因为 /api/list 返回的字段里包含大量用户头像、描述、标签等,而首页只显示标题和缩略图。我直接让后端加了个 fields 参数,只返回需要的字段:

// 再优化:按需返回字段
const list = await fetch('/api/list?fields=id,title,cover').then(r => r.json());
const ids = list.map(item => item.id).join(',');
const details = await fetch(/api/details/batch?ids=${ids}&fields=price,stock).then(r => r.json());

到这里,首屏数据请求从 3 次串行变成 2 次并行,且 payload 缩小了 70%。但我觉得还能压一压——既然首页只需要 list 的基础信息,details 其实可以懒加载。用户滚动到某个 item 附近时再拉详情,而不是一进页面就全拉完。

于是引入 Intersection Observer 做懒加载:

// 核心代码就这几行
const observer = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      const item = entry.target;
      const id = item.dataset.id;
      // 防止重复请求
      if (!item.dataset.loaded) {
        fetchDetails(id).then(data => {
          renderDetail(item, data);
          item.dataset.loaded = 'true';
        });
        observer.unobserve(item);
      }
    }
  });
}, { threshold: 0.1 });

document.querySelectorAll('.item').forEach(el => observer.observe(el));

这里注意我踩过好几次坑:一是 threshold 别设太高,否则用户还没看到就触发了;二是一定要标记 loaded 状态,不然快速滚动会重复请求。另外,如果列表很长,记得在组件卸载时 observer.disconnect(),避免内存泄漏。

性能数据对比

折腾完这一套,效果立竿见影:

  • 首屏加载时间从 5.2s 降到 800ms(Lighthouse 测)
  • 滚动 FPS 从 20~30 提升到稳定 60
  • 总传输体积从 1.8MB 降到 400KB(gzip 后)
  • 主线程 JSON.parse 耗时从 300ms+ 降到 50ms 以内

最关键的是,用户反馈“终于不卡了”。虽然懒加载导致部分 item 刚进入视口时价格会闪一下,但比起之前卡死,这点延迟完全可以接受。而且我们加了 skeleton loading,体验更平滑。

补充一点:如果你们用的是 GraphQL,其实字段按需获取天然支持,不用手动拼 fields。但我们这项目是 RESTful,只能靠约定。另外,如果后端不配合改接口,前端也可以用 transformResponse 在 axios 里过滤字段,但不如源头裁剪高效。

还有个小问题没解决

现在懒加载依赖 DOM 元素,如果列表是虚拟滚动(比如 react-window),就得结合 visible range 来触发请求。我试过用 onItemsRendered 回调,但边界情况有点多,暂时先用原生滚动凑合了。毕竟这个页面列表不会超过 100 条,没必要上重型方案。

另外,批量接口 /api/details/batch 在 ID 太多时 URL 会超长(GET 请求限制)。后来我们改成 POST,body 传 ids 数组,完美解决。这个细节千万别忽略,线上真遇到过 414 URI Too Long 的报错。

以上是我的优化经验,有更好的方案欢迎交流

这次优化核心就三点:串行变并行、接口按需返回、非关键数据懒加载。没有黑科技,都是老办法,但组合起来效果拔群。如果你也在搞通信性能优化,不妨先抓包看看是不是请求太多、数据太肥。有时候砍掉 80% 的冗余字段,比你调一百次 React.memo 都管用。

以上是我踩坑后的总结,希望对你有帮助。有更优的实现方式欢迎评论区交流,比如你们怎么处理超长列表的懒加载?或者有没有自动裁剪字段的工具?

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论
皇甫瑞君
这篇文章帮我建立了系统的学习框架,以后学习新技术会更有条理。
点赞
2026-03-20 16:26