“Make It Long, Keep It Fast”这句话,是我在一次大促专题页重构时写在自己的调试记录里的。当时运营提了一个看起来很贪心的需求:要在一个页面里放下近千个商品,让用户能顺着一个长列表一直刷,不许分页,最好还能首屏秒开。我第一反应是劝她拆 tab,但业务方非常坚持:这个专题的核心就是“逛”,一次只给几十个是没法形成氛围的。
于是问题就变成了一个典型的长页面性能优化命题:内容可以做得很长很完整,但响应速度和滚动体验必须一直保持快。最后上线时,这个页面从原本的 6.8 秒可交互降到了 1.1 秒,滚动帧率稳定在 60fps,内存占用也砍掉了接近一半。这篇文章就是那次调优的完整复盘,适合正在做电商列表页、内容型产品或者富信息单页的同学参考。
整个项目的核心思路其实就一句话:“Make It Long”讲的是产品形态,“Keep It Fast”讲的是工程手段,两者不是鱼和熊掌,而是同一道拆分题。你想让页面足够长,就不能让浏览器一次性处理所有事情;你想让页面足够快,就必须把内容分成浏览器可以随手处理的小块。把长页面当成一张大图去加载,是很多性能问题的根源。
1. 为什么长页面总是越做越慢
1.1 不是内容太多,而是渲染任务一次性太大
先说我当时测到的数据。专题页联调完成后,我随手用 Chrome DevTools 的 Performance 面板录制了一次“从输入 URL 到滚动页面”的完整过程,数据很难看:
- DOM 节点数从最初的 800 多个一路涨到 24000 多个;
- 首次可交互时间 TTI 达到了 6.8 秒;
- 快速滚动时帧率只有 12fps,肉眼可见的卡顿;
- 页面内存占用超过 300MB。
很多同学遇到长页面卡顿,第一反应是“内容太多了,砍内容吧”。但仔细看 Performance 面板里的火焰图,真正的问题不是内容本身,而是主线程在页面加载初期被一整块巨大的渲染任务占满了。React 渲染出近千个商品卡片、浏览器计算样式、重新布局,这些工作全部挤在脚本执行阶段,用户在这一大堆任务跑完之前,连滚动都没法操作。
长内容页面的本质矛盾就在这里。产品希望用户在页面上停留更久、看到更多信息,无意中把一个本来应该“一直有得看”的浏览过程,变成了“先等 6 秒加载完,再开始逛”。体验上有点像去一家餐厅,后厨非要等所有菜都做好了才一起端上来,结果前面的菜早凉了,你还饿着肚子干着急。
1.2 “快”其实有三层含义
很多性能优化文章把“快”简单理解为“加载快”,但在长内容场景里,快是分层的。如果只优化首屏加载,滚动的时候照样卡死,用户照样会关掉页面。
我总结出来的三层快是这样的:
- 第一层:首屏可用快。用户打开页面后,必须很快看到有意义的画面,而不是白屏或者一个巨大的加载菊花。这部分对应 FCP、LCP 这些指标。
- 第二层:后续内容出现快。长页面是一层层往下刷的,用户滑到哪一屏,哪一屏的内容就得基本就绪,不能出现长时间空白或者图片加载半天没反应。
- 第三层:滚动交互快。页面已经渲染出来的部分,在滚动时不能掉帧、不能抖动、不能让用户感觉“页面很重”。
三层都做到,才是真正的“Keep It Fast”。我经常看到一些项目只做首屏优化,结果用户往下滚几屏后浏览器卡死,这种优化是自欺欺人的。
1.3 我最终确定的整体解决思路
想通三层快之后,方案的骨架基本就出来了。我把整个页面从空间和时间两个维度做了拆分:
- 空间上拆:屏幕外的东西不渲染,只渲染可视区附近的内容,核心工具是虚拟滚动。
- 时间上拆:首屏只加载首屏需要的东西,屏幕外图片全部懒加载,核心工具是 IntersectionObserver。
- 网络层拆:接口返回的数据不要求一次全部到位,改成流式或分页拉取;静态资源用长缓存,二次进入直接秒开。
你可以把这种拆法理解成“分批上菜”。做一桌宴席,后厨永远不会等所有菜都出锅才叫客人入座。先上几个冷盘,然后热菜一道一道来,客人边吃边等,体验远好于干坐着等一小时等一桌菜。长页面优化也一样,核心就是让浏览器“手里永远只有一小块任务”,而不是一次性把上千个卡片塞进渲染管线。
2. 工具选型与方案取舍:不是只要上虚拟滚动就够了
2.1 虚拟滚动不是长列表的唯一解
说到长列表优化,很多人第一反应是“用 vue-virtual-scroller / react-window”。但实际动手前,要先判断你的页面对“快”的要求到底有多高。
如果只是数据量中等,比如一两百条卡片,其实没必要上虚拟滚动,用分页加载 + 图片懒加载就足够。虚拟滚动会引入动态高度计算、滚动定位、键盘无障碍等一系列复杂性,为了 200 条数据去引入这些复杂度,性价比很低。
但当数据量到了千级、DOM 节点数可能突破一万的时候,虚拟列表几乎是必选项。原因很简单:浏览器的布局、样式计算、内存占用,和 DOM 节点数量是明显正相关的。节点多到一定程度,哪怕什么都不做,光滚动时重排一次,代价都会很高。
我当时的判断标准可以给你参考:
- 数据条数 < 300:懒加载 + 分段渲染即可;
- 数据条数 300 ~ 1000:上虚拟滚动,动态高度方案;
- 数据条数 > 1000:虚拟滚动 + 窗口化裁剪 + 服务端分页,前端一次性拿全量数据不再合适。
这个标准不是绝对的,不同页面卡片复杂度差别很大,但可以帮你避免“过早优化”和“过度设计”两头踩坑。
2.2 图片懒加载:别只盯着 loading="lazy"
长页面里图片占的体积通常最大。对于首屏以下的图片,浏览器原生loading="lazy"确实好用,但它有一个问题:触发时机不够精准,且无法和自定义动画、占位符做深度配合。
我当时用的是双保险策略。首屏内的图片正常加载;首屏外的图片通过 IntersectionObserver 控制src的注入,组件进入视口前只渲染占位容器。
给图片容器设置明确的宽高比是特别重要的一步。很多页面布局偏移(CLS)就是因为图片加载前高度为 0,加载后突然把下面的内容挤下去。避免方案很朴素:
.card { aspect-ratio: 3 / 4; }这个 CSS 属性在现代浏览器里已经普及得比较好了。设置了固定宽高比,图片哪怕还没加载,容器也占据了稳定的空间,懒加载触发时视觉上不会跳动。
还有一个容易忽略的细节:给img加上decoding="async"。这个属性可以让图片解码不阻塞主线程,尤其是大图多的时候,对滚动流畅度帮助很明显。
2.3 虚拟列表里高度不同怎么办
如果你用固定高度的虚拟列表,实现非常简单:总高度 = 每条高度 × 总数,滚动偏移除以每条高度就能算出当前要渲染的起始索引。问题是真实业务里,商品卡片高度受标题行数、价格标签、促销角标影响,很难固定。
我的做法是先估算,再实测修正。估算高度用于计算滚动位置,真正渲染后通过ResizeObserver拿到实际高度,维护一个高度数组;滚动时累加高度来定位起始索引。
听起来有点绕,给你一个简化版的 React 示例看看思路:
function VirtualList({ items, estimateHeight }) { const containerRef = useRef(null); const [scrollTop, setScrollTop] = useState(0); const [heights, setHeights] = useState<number[]>([]); const viewportHeight = 800; const bufferCount = 5; const getHeight = (index: number) => heights[index] || estimateHeight; const getOffset = (index: number) => { let offset = 0; for (let i = 0; i < index; i++) { offset += getHeight(i); } return offset; }; const totalHeight = items.reduce((sum, _, i) => sum + getHeight(i), 0); let startIndex = 0; while (startIndex < items.length && getOffset(startIndex) < scrollTop) { startIndex++; } startIndex = Math.max(0, startIndex - bufferCount); let endIndex = startIndex; while ( endIndex < items.length && getOffset(endIndex) < scrollTop + viewportHeight ) { endIndex++; } endIndex = Math.min(items.length - 1, endIndex + bufferCount); return ( <div ref={containerRef} style={{ height: viewportHeight, overflowY: "auto" }} onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)} > <div style={{ height: totalHeight, position: "relative" }}> {items.slice(startIndex, endIndex + 1).map((item, i) => { const index = startIndex + i; return ( <div key={item.id} style={{ position: "absolute", top: 0, transform: `translateY(${getOffset(index)}px)`, width: "100%", }} > <Item data={item} onHeightChange={(h) => setHeights((prev) => { const next = [...prev]; next[index] = h; return next; }) } /> </div> ); })} </div> </div> ); }这个例子没有用任何第三方库,核心是想说明两件事。第一,虚拟列表的关键是维护“每个元素的偏移量”而不是“滚动了几条”,因为元素高度各不相同。第二,渲染出来的元素用transform做位移,比频繁修改top属性性能好得多,因为transform不会触发 layout,只触发合成。
动态高度每次滚动都要做累加计算,数据量上万时 O(n) 的循环也不能说完全没有开销,所以实际项目里会用“分段高度表”做优化,把 10000 个元素分成 100 段,每段记录高度总和,这样查询偏移量从 O(n) 变成 O(段数)。这个优化细节我不展开,但你如果觉得循环导致滚动卡顿,优先往这个方向想。
2.4 缓存策略才是二次访问“快”的关键
很多人优化长页面只盯着首次加载,忽略了二次进入。实际上,对于一个接近千个商品的专题页,用户大概率会来回进出。如果每次都重新下载资源,前面所有努力都会被打折。
缓存的关键词和标题其实很呼应——“Make It Long”可以用在缓存时长上。静态资源文件只要带了内容哈希,文件名一变就说明内容变了,那么缓存时间可以设得很长,一年甚至更久都没问题。很多团队不敢开长缓存,是怕更新后用户看到旧文件,其实只要走 hash 命名,这个问题根本不存在。
接口层的情况复杂一些。长列表数据不会完全不变,缓存太短没用,缓存太长又可能让用户看到过期价格。我当时用了两个策略:
- 本地内存缓存 + TTL,设 5 分钟。页面被反复打开,5 分钟内的数据直接命中缓存,接口压力小,用户也感知不到数据过期。
- 对可能发生价格变动的商品,在首屏接口里附带一个
version字段,全局监听一个消息通道。运营后台改了价,客户端收到事件后主动清空本地缓存。这就是主动失效机制。
缓存的核心不是“永久不更新”,而是默认相信数据短时间内不变,同时保留一条立即失效的高速通道。这样既快又不会错。
3. 实操过程:把“长”和“快”落进每一个环节
3.1 第一步先量基线,性能优化不能靠猜
没有基线数据就开始优化,等于盲人摸象。我每次都会先把页面按现有状态完整测一遍,记录关键指标,优化完再测一遍,对比出效果。
测量方法是这样的:
- 打开 Chrome DevTools 的 Network 面板,把网络调到 Slow 4G,CPU 降频 4 倍模拟低端手机;
- 打开 Performance 面板,录制从刷新页面到滚动 3 屏的全过程;
- Lighthouse 跑一轮,记录 Performance 分数和 LCP、CLS 等核心指标;
- 用 Performance monitor 观察滚动时的帧率和内存变化。
我记录下来的基线大致是:
| 指标 | 优化前 |
|---|---|
| 首次加载请求数 | 147 |
| 页面总大小 | 9.8MB |
| FCP | 3.2s |
| LCP | 5.4s |
| TTI | 6.8s |
| 滚动帧率 | 12fps |
| 内存峰值 | 320MB |
这个表非常关键。没有这张表,后续你可能根本说不清某个改动到底有没有效果,也很容易陷入“我改了好像快了,又好像没快”的玄学状态。
3.2 首屏节点拆分,先让用户“有得看”
第一刀切在首屏。原来的页面是把整个专题的产品数据一次性请求回来后,全部交给前端渲染。我改成先请求首屏接口,只返回首屏可视区能放下的商品数量,并把首屏渲染所需的关键 CSS 以内联方式放进 HTML。
减少首屏请求其实是很多团队忽略的点。请求数越多,慢速网络下的排队时间越长。我做了几件事:
- 把首屏内需要用到的几段 CSS 直接内联进 HTML,其余 CSS 走异步加载;
- 首屏的 JavaScript 执行放到 DOM 解析完成后,用
defer,不阻塞首次渲染; - 不需要在首屏出现的模块统一降级为动态加载,等用户快滚动到对应区域时才去拉取组件代码和数据。
首屏优化后,FCP 从 3.2 秒降到了 1.4 秒左右。用户不会再对着白屏等,而是先看到一个带骨架屏的商品列表框架,接着图片和内容逐步填满。
骨架屏的实现不复杂,就是先渲染一些灰色占位块,模拟真实内容排版,等数据来了再替换成真实内容。它最大的价值是消除“页面打开了但什么都没发生”的焦虑感,用户愿意多等 1 秒,前提是他确定页面在干活。
3.3 服务端加一个流式分页接口
首屏只返回一部分数据之后,客户端的虚拟滚动还需要源源不断的数据。如果所有数据都必须一次拿完,前面拆 DOM 的努力就白费了。所以我把原来的全量接口改造成了支持游标分页的流式接口。
接口改造的一个原则是:永远不要在接口层让客户端等待一个巨大的响应体。服务端如果一次性查全量数据,即使带宽够,也要等数据库把所有商品信息都查出来、序列化完毕才返回。数据量一大,接口响应时间会线性上升,这直接违背“Keep It Fast”。
改造后的接口协议这样约定:
{ "cursor": "eyJvZmZzZXQiOjEwMH0=", "items": [ { "id": 101, "title": "...", "price": 199 } ], "hasMore": true }请求参数里带上pageSize,比如每页 20 条。客户端滚动到还剩 10 条的时候,就自动发起下一页请求,用 cursor 而不是 page 作为翻页参数。cursor 的好处是,如果数据库中间有数据删改,游标依然能精确定位,不容易出现重复或跳漏。
初始版本我加了个并发控制,防止用户快速滚动时一次性发十几个请求。同一个时间点只允许一个进行中的下一页请求,请求完成前再触发只记录一个 pending 标志,避免请求风暴。
3.4 滚动流畅度优化,关注合成层而不是死磕 JS
页面能滚动后,你可能会遇到一个新的卡顿来源:滚动时每一帧都在重排重绘。Performance 面板里会看到大量紫色 Layout 事件或者绿色 Paint 事件。
解决这个问题的通用导向是,让浏览器尽量走合成层,也就是单独把某些元素提升到一个独立层,滚动时只移动这个层,不触发整页布局。
实际操作中我会注意这几点:
- 滚动的容器使用
overflow-y: auto,而不是让整个body滚; - 列表子项定位后一律用
transform: translateY(),不要用top; will-change: transform只用在已经滚动起来的元素上,不要给成千上万个元素都加上,否则会浪费大量内存;- 减少滚动监听事件里的计算。滚动回调里不要读
offsetTop、getBoundingClientRect这类强制同步布局的属性,需要判断滚动方向时,直接使用e.currentTarget.scrollTop配合节流即可。
这里有一个常踩的坑:很多虚拟滚动库会在滚动事件里频繁修改每个元素的height和top,导致每帧都发生大量 layout。改成用绝对定位 + transform 后,合成器只需要移动一个图层,代价非常小。
优化后我再拿 Performance 面板录制滚动过程,主线程上的长任务基本消失,帧率稳定在 60fps。低频手机上虽然偶尔掉到 45fps 左右,但已经不再有那种明显“卡一下”的感觉。
3.5 二次进入的秒开:缓存和预加载两手抓
首屏和滚动都顺了以后,我把注意力放到二次访问的体验上。
静态资源因为都带 hash,配置了长期缓存。接口数据做了 TTL 缓存。真正让二次进入接近秒开的是“内容预加载”:当用户在第一页停留超过 3 秒,判断他大概率会继续往下逛,我就会悄悄在空闲时间提前拉取第二页、第三页的数据。
用requestIdleCallback是一个实现预加载的好办法:
if ("requestIdleCallback" in window) { requestIdleCallback(() => { fetchNextPageIfNeeded(); }); } else { setTimeout(() => { fetchNextPageIfNeeded(); }, 500); }预加载有一个原则:不能影响当前正在进行的页面行为。如果网络状况差,或主线程任务还很重,就不要启动预加载。否则宁可晚点出现下一页,也不能让首屏滚动卡顿来交换。
我这里做一个补充:预加载数据需要和虚拟列表的数据融合层配合好。所有页面数据放在一个统一的 store 里,预加载拿到的新页面直接追加到 store 尾部,虚拟滚动组件不需要感知“这批数据是怎么来的”,它只要知道“后面还有数据”就可以继续往下滚。这个解耦做得越干净,后续加其他数据源越省事。
4. 常见问题与排查技巧实录
4.1 一组高频问题速查表
做性能优化时遇到的大多数问题,其实都有比较固定的症状和成因。我把这次调优过程中的问题整理成了一张表,以后你遇到类似页面可以先对照排查:
| 问题现象 | 可能原因 | 解决方向 |
|---|---|---|
| 首屏白屏时间很长 | 关键 JS 在 HTML 前同步执行 | 加 defer,优先内联关键 CSS |
| 滚动到底部时长时间空白 | 数据请求滞后,没有预加载 | 提前预取下一页数据,增加可视区外 buffer |
| 快速滑动后先白屏再恢复 | 虚拟列表的可视区外缓冲条数太少 | 增大 overscan / bufferCount |
| 图片加载时页面大幅度跳动 | 图片容器没有锁定宽高比 | 为容器设置 aspect-ratio 或固定宽高 |
| 滚动时页面持续卡顿 | 滚动的列表项频繁触发 layout | 改用 transform,避免动态改 top |
| 页面内存不断上涨 | 虚拟列表缓存了过多节点,或节点没有正常释放 | 限制渲染节点数,缩小 overscan 范围 |
| 二次进入还是重新下载资源 | 静态资源没有长缓存,或者 URL 不带 hash | 文件名加 contenthash,Cache-Control 设宽松时间 |
| 页面数据很旧,更新不及时 | 接口本地缓存 TTL 过长 | 缩短 TTL,建立主动失效机制 |
这张表无法涵盖所有场景,但大部分长页面卡顿都不是什么黑魔法,翻来覆去就是任务太集中、节点太多、图片请求太多这几个根因。
4.2 排查时值得养成的几个小习惯
第一,性能面板里优先看“长任务”。Performance 录制完成后,主事件最底部超过 50ms 的任务都值得点开看看。真正的问题通常是一段高约几百毫秒的红色长条。你顺着长条找到调用堆栈,基本就能顺藤摸瓜找到罪魁祸首。
第二,验证虚拟列表是否真的在渲染:在 Rendering 面板勾选 “Paint flashing” 以及 “Layer borders”,再滚动页面。如果整屏都在闪绿,说明渲染区域太大;如果只有可视区和 buffer 区域在闪,说明裁剪生效了。这个方法每次都能给我非常直观的判断。
第三,移动端低端机测试不能用高端旗舰机替代。同一个页面,中端 Android 机上出现掉帧,在 iPhone 上可能毫无感知。所以我会在调试时主动用 DevTools 的 CPU 降频功能模拟低端设备,很多问题只有在这种环境下才能暴露。
4.3 这次优化的实际收益
整个优化做完以后,我又按同样方法重新测了一遍指标:
| 指标 | 优化前 | 优化后 |
|---|---|---|
| 首次加载请求数 | 147 | 63 |
| 页面总大小 | 9.8MB | 3.5MB |
| FCP | 3.2s | 1.4s |
| LCP | 5.4s | 1.6s |
| TTI | 6.8s | 1.1s |
| 滚动帧率 | 12fps | 57fps |
| 内存峰值 | 320MB | 168MB |
看到这组数字的时候,我更确信了一件事:很多看起来“内容太庞大所以慢”的问题,本质上都不是内容的问题,而是工程结构的问题。把一张大饼切成小块,每块都能轻松嚼动,用户很快吃完了还想再点。
我个人在实际操作里最大的体会是,优化长页面永远不要迷信某一个技术,虚拟滚动、懒加载、缓存、接口分页,它们各自解决的是不同环节的问题。真正值钱的是先把页面拆出清晰的时间线和空间线,理清“哪些内容必须马上出现、哪些可以等滚到再出现、哪些可以提前准备好”,然后让每一步都有对应的技术方案去承接。
最后再分享一个小技巧:你在优化长页面时,不妨在调试记录顶部写下“Make It Long, Keep It Fast”这句话,然后把身边的同事都拉来一起念一遍。产品经理说要长,研发说要快,念完这句话再开会,你会发现大家的目标其实是一致的。页面的长度是给用户的,页面的速度也是给用户的,我们需要做的,只是把长度留给内容,把速度还给代码。