2026-04-25

Question

假设你负责一个面向中老年用户的新闻资讯 App 首页。这个页面主要展示文字新闻卡片,每张卡片包含标题、摘要、配图缩略图和发布日期。

App 在中低端 Android 手机上跑,WiFi 环境不稳定,用户有时会在弱网下刷信息流。

现在你要做两件事:

  1. 首屏加载优化 — 用户打开 App 后何时能看到内容?能多快?
  2. 滚动列表性能 — 长列表上百条新闻时如何保持 60fps 不卡顿?

请从 前端工程视角 说明你的优化策略:

  • 首屏:数据获取、缓存策略、骨架屏、渐进加载
  • 列表:虚拟化、内存控制、图片懒加载与解码优先级
  • 网络:弱网自适应、预加载、离线容灾

Your Answer

首先,新闻的渲染会使用一个虚拟列表,通过虚拟化来控制真实渲染的 DOM 个数,从而优化渲染速度和滑动帧率。

在此基础上,用户滚动的过程当中,如果列表对应的项还没有请求到真实数据,先展示骨架图,等到真实数据返回以后刷新页面。

在用户启动的时候,提前开始请求实时数据,但这样依旧无法保证数据能够很快返回,所以我们需要对历史的新闻做一个缓存。当用户打开 APP 的时候,先展示之前已经看过的数据,同步加载最新的数据。最新的数据加载完成以后,给用户一个滚动到顶的悬浮按钮,用户点击以后可以开始浏览最新的新闻,这样除了用户首次打开需要看骨架屏,其他时间打开时都能看到真正的新闻。

缓存需要有一个上限,比较合适的实现方案是 LRU。缓存的数目我会先拍定为 100 条后续根据实际情况调整。在有缓存的情况下,哪怕用户是弱网或者甚至无网,也还能够浏览曾经已经请求过的新闻。

为了保证滑动过程当中的流畅度,我们需要对图片做一个懒加载。如果用户快速地滑动,被划过的内容要么展示骨架图,要么只展示文本,不展示真实的图片。等到用户操作稳定以后,再异步加载图片。

Follow-up Q1: 新旧数据拼接时的体验问题

用户点悬浮按钮后,新请求的数据拼接在历史数据的前面,不要主动滚动用户的屏幕,让用户点击按钮以后才滚动到最新的新闻,最新的新闻浏览完之后,会无缝衔接历史的新闻。

Follow-up Q2: 缓存的持久化方案

缓存的保存逻辑,必须要保证 APP 重启时还能够拿到缓存,所以内存是肯定不行的。鉴于数据量可能比较大,合适的方案是 IndexedDB。在用户首屏打开的时候,首先还是渲染骨架图,然后异步请求实时数据,同时获取 IndexedDB 数据。本地数据获取完成以后,渲染 IndexedDB 数据。

Follow-up Q3: 图片懒加载的"稳定"判定

对图片的可见性时长做一个阈值。如果图片真实上屏时间小于阈值,则不做图片请求。如果大于阈值,则开始请求。开始请求之后无法打断。

Review

整体评价:用户的回答体现了对首屏优化和长列表性能的实战理解,不是背八股。

优点:

  • ✅ 虚拟列表作为首要优化手段,方向正确
  • ✅ 骨架图 + 缓存优先 → 后台拉新 → 主动切换,典型的 "skeleton on stale cache" 模式,落地过的思路
  • ✅ LRU 缓存 + 快速滑动跳过图片加载,说明考虑过实际细节
  • ✅ 弱网/无网缓存兜底,注意到离线可用性
  • ✅ 新旧数据拼接时不主动滚动用户,保持体验连续性
  • ✅ IndexedDB 作为持久缓存,而不是内存,方向正确
  • ✅ 首屏异步并行加载:骨架图 → 真实数据 / IndexedDB,时序合理

可加强的点:

  1. 图片请求被打断的取消机制 — 用户说"开始请求之后无法打断",实际在高频滑动场景下,不取消离开视口的请求会导致网络带宽被不需要的图片占满。更稳妥的做法是用 IntersectionObserver 记录进入/离开时间,结合 AbortController 在元素离开视口时取消仍在途的请求,释放带宽给真正需要展示的图片。
  2. 新旧数据去重 — 用户提到拼接新数据到顶部,但没有说明如何处理新旧之间可能重复的新闻条目。在实际产品中,头条类 App 的部分置顶新闻可能在缓存和最新请求中重复出现,需要一个按 id 去重的合并机制。
  3. IndexedDB 读取的时序 — 用户提到"首屏先渲染骨架图,再异步拿 IndexedDB 数据",这个时序正确,但没有说明 IndexedDB 读取是否和网络请求并行。在实际实现中,两者应并行发起。

Improved Answer

我会把首屏加载和滚动列表性能作为两套独立但耦合的策略来处理,核心目标是:内容最快可见,列表始终流畅

首屏加载

首屏加载不是"等数据回来再渲染",而是多阶段渐进渲染:

  1. 骨架屏(0ms) — 用户点击进入首页,立即渲染骨架屏结构,避免白屏
  2. 缓存数据(与网络请求并行) — 同时发起两路请求:
    • 从 IndexedDB 读取缓存的新闻列表
    • 向服务端请求最新新闻
  3. 缓存优先渲染(缓存数据就绪时) — IndexedDB 读取完成后,将缓存新闻填充到虚拟列表中,替换骨架图
  4. 后台拉新(网络数据就绪时) — 新数据回来后:
    • 按 id 去重(新旧数据中重复的新闻只保留最新的版本)
    • 将新数据拼接在列表顶部
    • 显示一个"查看最新"的悬浮按钮
    • 用户点击后主动滚到顶部,不自动打断当前浏览
  5. 更新缓存 — 将合并后的最新数据写入 IndexedDB,LRU 上限 100 条

缓存采用 IndexedDB,因为 localStorage 同步读取会阻塞主线程,而且存储上限有限。IndexedDB 异步、容量大,适合存 100 条新闻及其配图元信息。

长列表性能

使用虚拟列表(如 react-window / vue-virtual-scroller),只渲染视口 + 预渲染区域的 DOM 节点。

图片懒加载策略:

用一个 IntersectionObserver 监听每个卡片进入/离开视口的事件:

  1. 卡片进入视口时记录 enterTime
  2. 每隔 300ms 检查视口内的卡片,如果停留时间 > 300ms,触发该卡片的图片加载
  3. 如果卡片在图片加载完成前离开视口,通过 AbortController 取消正在进行的图片 fetch
  4. 用一个优先级队列管理所有图片请求:当前视口内的 > 即将进入的 > 已离开的

这样做的核心逻辑是:不要为已被用户划过的卡片浪费网络带宽,优先保障用户正在看的内容的图片加载速度。

弱网与离线策略

  • 弱网时:降低图片质量(请求缩略图而非原图),延长图片加载超时
  • 离线时:缓存数据直接展示,图片展示占位图
  • 网络恢复时:静默刷新缓存,更新按钮提示用户