
1. 优化前先判断你的列表到底卡在哪里做前端几年遇到“列表卡顿”这个问题大家的反应往往只有一个——上虚拟列表。但说实话这个决定下得太早了。我在某后台管理系统的日志查询页面踩过一个大坑几千条数据一次性渲染页面白屏了四五秒我当时第一反应也是虚拟列表后来仔细分析才发现这个场景根本不适合虚拟列表因为用户需要全量搜索、全量导出、甚至还要用浏览器本身的查找功能。真正动手优化之前得先回答一个问题你的列表卡是因为DOM 节点太多还是因为渲染耗时太长这两者的区别非常大。DOM 节点太多指的是页面里真实存在的节点数量已经到了一定规模比如几千上万个 DOM 元素同时挂在页面上这种情况下哪怕你什么都不做光是滚动、选中、展开这些交互浏览器都要花大量时间去遍历、重排、重绘。而渲染耗时太长往往是因为一次性把大批量数据塞进了渲染流程JavaScript 执行时间过长主线程被长时间占用用户看到的是界面卡死点哪儿都没反应。我个人的判断标准很直接打开 DevTools 的 Performance 面板录制一段操作看主线程的 Long Tasks。如果出现连续的 50ms 以上的长任务而且这些任务集中在数据处理和节点构建阶段那大概率是渲染耗时问题可以用分片渲染来解决。如果长任务并不长但页面上的 DOM 节点数量已经大得离谱比如超过 3000 个而且每次滚动都会触发明显的卡顿那更像是节点规模问题虚拟列表才是对的方向。还有个更简单的方法在页面里执行document.querySelectorAll(*).length看看节点总数。一般来说超过 5000 个节点页面的交互就不可能顺畅超过 10000 个几乎必然会卡。这时候你要么缩减节点要么就让不在可视区域的节点根本不渲染。选型的时候还要考虑一个很实际的因素你的用户需不需要查找、定位、复制里面的内容如果答案是肯定的虚拟列表会让你非常痛苦因为虚拟列表里本质上只有可视区域那一小段 DOM浏览器自带的查找功能几乎失效CtrlF 找到的内容根本不在页面上。而这个场景下分片渲染反而能把几万条数据全部渲染出来虽然慢一点但渲染完成之后功能上是完整的。所以我的建议是先别急着定方案。你先搞清楚你的业务场景属于下面哪一类数据一次性返回用户需要全量可见、可搜索、可导出DOM 规模大但可接受选分片渲染。数据量极大几万到几十万条用户只关心当前可视区域的内容滚动需要丝滑选虚拟列表。单个列表项内部结构很复杂比如表格行里嵌套树、图表、操作按钮即使可视区域的数据量不多单次渲染的耗时也很高这时候两者结合才是最优解。打个比方分片渲染更像“分批吃完一顿大餐”饭量再大一口一口来都能吃完只是吃得慢虚拟列表则像“自助餐只拿眼前这一盘”不管你后面还有多少菜你永远只看得到手边这几样。这两个思路的目标一致——保住流畅度但手段完全不同适用场景也完全不同。搞清楚卡顿的原因之后下面这两套方案你就知道该怎么选了。先看分片渲染它解决的是“一次性渲染太多、耗时太长”的问题。2. 分片渲染的核心实现与实测经验2.1 分片渲染的基本原理把大任务拆成小任务分片渲染的思路说穿了就是把一次耗时的渲染任务切成多个小片段让主线程在每个小片段之间有机会处理用户交互、响应事件、执行动画。这里有个关键点要理解浏览器的主线程是单线程的JavaScript 执行和页面渲染共用这一条线程。如果你在一个同步循环里一次性往页面上追加 5000 个节点这 5000 个节点的创建、插入、样式计算、布局计算会全部挤在这一个同步执行栈里完成。这期间主线程被牢牢占住用户的任何点击、滚动、输入都得不到响应。而分片渲染的策略是每插入一小批节点就主动“让位”给浏览器让它在间隙里去处理其他任务。最简单的实现方式是用setTimeout把任务拆分但实际项目里我更推荐用requestIdleCallback。这个 API 会在浏览器空闲的时候执行你传入的回调它比setTimeout更聪明的地方在于它知道主线程什么时候是真正空闲的不会因为你设定的延迟时间到了就去抢占主线程。不过requestIdleCallback的兼容性需要处理一下在不支持的浏览器里要回退到setTimeout。这里给出一个最基础的分片渲染实现思路function chunkRender({ data, container, chunkSize 20, onRender }) { let index 0; function renderNextChunk(deadline) { // 用时间判断而不是固定次数 while (index data.length deadline.timeRemaining() 0) { // 当前这一片的起点和终点 const chunk data.slice(index, index chunkSize); onRender(chunk, index); index chunkSize; } if (index data.length) { requestIdleCallback(renderNextChunk); } else { // 渲染完成 console.log(全部渲染完成); } } requestIdleCallback(renderNextChunk); }注意这段代码里我用的是deadline.timeRemaining() 0作为循环条件而不是简单地每片固定多少个就停。理由是requestIdleCallback传入的deadline对象会告诉你当前空闲窗口还剩多少时间如果你在单个空闲窗口内塞入了过多工作一样会导致后续帧掉帧。通过时间判断每一帧只做“刚好来得及”的工作量这样渲染耗时就被平均分布到了多个帧中用户基本感知不到卡顿。2.2 分片大小怎么定别用固定数量用时间预算很多教程会让你设定一个固定的分片大小比如每片 100 条。但这里有一个坑不同的 DOM 结构复杂度单条数据的渲染成本完全不同。假设你渲染的是一个简单的文本列表每条数据就是一个div包一段文字那每片 100 条可能只需要不到 2ms非常轻松。但如果每条数据是一个复杂的卡片里面包含图片、评分组件、按钮事件绑定那每片 100 条可能就要消耗 30ms 以上两个片之间就会出现肉眼可见的卡顿。我实测后的经验是用时间预算来控制每一片的工作量而不是条数。具体做法是在每个分片回调里记录开始时间当当前分片消耗的时间超过一个阈值比如 8ms 或 16ms时立刻终止当前分片把控制权交还给浏览器。function renderWithTimeBudget({ data, container, timeBudget 8 }) { let index 0; function renderNextChunk() { const startTime performance.now(); // 这一片最多工作 timeBudget 毫秒 while (index data.length performance.now() - startTime timeBudget) { const item data[index]; const node createItemNode(item); container.appendChild(node); index; } if (index data.length) { requestAnimationFrame(renderNextChunk); } } requestIdleCallback(renderNextChunk); }这里我用了performance.now()来计算耗时而不是依赖Date.now()原因是performance.now()的精度更高而且不受系统时间调整的影响。8ms 这个数字不是拍脑袋定的一帧是 16.7ms渲染工作至少占半帧留出另一半给浏览器处理事件和绘制。如果发现实际页面还有富余可以适当上调到 12ms但如果调到 16ms 以上用户可能会在交互上感觉到轻微延迟。还有一个容易忽略的点appendChild每调用一次都会触发一次节点插入。即使你使用了DocumentFragment做批量插入也需要在合适的时机一次性提交。const fragment document.createDocumentFragment(); // 往 fragment 里添加节点 container.appendChild(fragment);这个操作的原理是DocumentFragment本身是一个不在 DOM 树上的“准容器”先把所有节点挂到它上面最后再一次性挂到真实 DOM 上这样可以显著减少强制同步布局的次数。我实测过同样是渲染 2000 个简单列表项不用 Fragment 的耗时是用 Fragment 的 2 到 3 倍。这个优化在分片渲染里同样有效每一片都用 Fragment 累积、最后统一挂载性能提升非常明显。2.3 分片渲染里的交互细节加载状态与取消机制分片渲染不是把任务扔给浏览器就完事了还有两个细节必须处理加载状态和取消机制。先说加载状态。因为分片渲染会把一个原本 1 秒能完成的渲染拉长到 3 到 4 秒用户面对一个空白的页面等待这么久体验非常差。我通常在数据量超过 1000 条时会先渲染一个轻量的“加载中”占位然后利用requestIdleCallback的第一帧空闲时间开始渲染同时在页面顶部或者侧边显示一个进度条表示当前已经渲染了多少条。再说取消机制。这个很多人会忽略但在实际业务里特别重要。用户可能在列表还没渲染完的时候就切换了 Tab、修改了查询条件这时候如果你还在继续渲染旧的列表不仅浪费性能还会导致新旧数据混在一起出现页面错乱。我见过一个线上事故就是因为分片渲染没有取消机制用户切换筛选条件后旧的数据还在继续往页面里追加最后页面里同时存在新旧两种数据排查了很久才发现是这个原因。取消的写法很简单用一个标志位在每个分片开始时检查let cancelled false; function renderNextChunk() { if (cancelled) return; // 渲染下一片... if (index data.length) { requestIdleCallback(renderNextChunk); } } // 需要取消时 cancelled true;更健壮的做法是把取消逻辑封装到一个类或者函数里返回一个取消函数方便调用方随时终止渲染。如果用了 Vue 或者 React还要额外注意组件卸载后不能再操作 DOM否则会报错或者导致内存泄漏。3. 虚拟列表不止是“只渲染可视区域”这么简单3.1 固定高度虚拟列表的实现思路虚拟列表的核心思想是只渲染用户能看到的那一部分。假设数据有 5 万条每条高度固定为 40px可视区域高度为 800px那屏幕上最多只能看到 20 条。剩下的 49980 条根本不需要存在于 DOM 中你只需要通过计算知道当前滚动到哪个位置把对应的那 20 条渲染出来就够了。固定高度虚拟列表的实现逻辑大概是这样的外层容器设置固定高度并设置overflow-y: auto。内部有一个高度为totalHeight的占位元素这个高度等于数据总条数 × 每条高度用来撑出滚动条。真正的可视内容通过transform: translateY(offsetY)来定位。滚动时根据scrollTop计算出起始索引startIndex Math.floor(scrollTop / itemHeight)。渲染startIndex到endIndex之间的数据endIndex startIndex visibleCount bufferCount。这里最关键的是第 4 步和第 5 步。我直接给一个精简版的实现function createVirtualList({ container, data, itemHeight, visibleCount }) { const totalHeight data.length * itemHeight; // 占位元素撑起滚动条 const spacer document.createElement(div); spacer.style.height totalHeight px; container.appendChild(spacer); // 内容渲染区域 const content document.createElement(div); container.appendChild(content); function update() { const scrollTop container.scrollTop; const startIndex Math.floor(scrollTop / itemHeight); const endIndex Math.min(startIndex visibleCount 5, data.length); // 多渲染5条作为缓冲区 content.style.transform translateY(${startIndex * itemHeight}px); let html ; for (let i startIndex; i endIndex; i) { html div classlist-item styleheight:${itemHeight}px${data[i]}/div; } content.innerHTML html; } container.addEventListener(scroll, () { requestAnimationFrame(update); }); update(); }注意这里我没有用scroll事件直接更新 DOM而是用requestAnimationFrame包装了一层。为什么因为scroll事件在滚动过程中会触发非常多次如果每一次都立刻去重新渲染 DOM性能消耗会很大而且浏览器往往来不及在每一帧内完成重新计算。用requestAnimationFrame把更新合并到下一帧可以保证滚动过程中只做一次计算和渲染。5这个缓冲区的意义很多人不理解。它解决的是“快速滚动让页面出现空白”的问题。用户快速下拉时scrollTop跳变很快如果你只渲染精确可视区域的那几条很可能这一帧渲染完下一帧滚动位置已经跳走了用户会看到明显的白屏闪烁。多渲染几行作为缓冲即使快速滚动也有一部分余量来过渡。3.2 动态高度虚拟列表的进阶处理固定高度是虚拟列表最简单的形态现实里几乎不存在。真实业务中的列表项高度往往是不固定的——有的条目有一行字有的条目有三行字有的条目里面还有图片。这时候如果你用固定高度计算会出现两个问题滚动条长度不正确以及滚动时内容错位。解决动态高度的主流方案是“预估高度 实测校正”。做法是先给每条数据预估一个高度比如 80px。用预估高度计算总高度和起始索引。渲染完成后实测每个条目的真实高度把实测值缓存起来。用缓存值更新总高度校正滚动条长度。这里有一个关键点实测高度必须在 DOM 渲染完成后才能获取而且获取offsetHeight会触发强制同步布局。在虚拟列表这种频繁更新的场景里这个操作本身就有成本所以不能对每条数据都测量而是只在初次渲染时测量后续滚动时直接使用缓存值。我在实际项目里写过一个简化的动态高度虚拟列表核心数据结构是这样的const positions data.map((item, index) ({ index: index, height: 80, // 预估高度 top: index * 80, bottom: (index 1) * 80, }));滚动时用scrollTop在positions里做二分查找找到起始索引而不是用除法直接算。因为高度不固定scrollTop / itemHeight这种除法已经不适用了。找到起始索引后渲染的内容需要做绝对定位或translateY位移位移量为positions[startIndex].top。渲染完成后批量测量function measureItems(domNodes, startIndex) { domNodes.forEach((node, offset) { const realHeight node.offsetHeight; const pos positions[startIndex offset]; if (pos.height ! realHeight) { pos.height realHeight; // 重新计算后续所有条目的 top 和 bottom for (let i pos.index 1; i positions.length; i) { positions[i].top positions[i - 1].bottom; positions[i].bottom positions[i].top positions[i].height; } } }); }这段代码在每次测量后重新计算后面所有条目的位置会导致性能问题。优化方式是记录一个“最大已测量索引”每次只重算从这个索引往后的部分而不是全部重算。因为实际渲染出来的条目只是可视区域附近的一小段后面的位置根本不需要重新计算等滚动到那边时再纠正也不迟。3.3 虚拟列表的容量上限不是无敌的虚拟列表能大幅减少 DOM 数量但它的能力也有边界。我开始用虚拟列表的时候以为从此就“一劳永逸”了直到后来遇到一个把虚拟列表用到崩溃的项目才意识到问题。什么场景会让虚拟列表失效答案是每个列表项本身的渲染成本太高。假设一个列表项内部包含一个复杂的图表组件那么即使屏幕只能容纳 20 条你把 20 个图表组件同时挂在页面上渲染帧率依然会掉到 30fps 以下。这时候虚拟列表只解决了“节点数量”的问题没有解决“单个节点渲染成本”的问题。另一个绕不开的问题是滚动时频繁重新渲染。虚拟列表在滚动过程中可视区域的数据不断变化每变化一次都要重新创建一批 DOM 节点、销毁一批旧节点这本身是有开销的。如果滚动速度很快而每个列表项的创建成本又不低就会导致滚动卡顿。我对虚拟列表的使用建议是数据量在几万条以内且列表项结构简单比如纯文本、图片文字的简单卡片虚拟列表的体验会很好。但如果列表项本身很复杂或者单个列表项的渲染需要占用大量 CPU这时候单纯靠虚拟列表已经不够了你需要的是“可视区内再做懒加载”或者“整体的组合同步优化”这就是下一节要讲的组合策略。4. 组合策略分片渲染 虚拟列表一起用4.1 什么时候需要组合复杂列表项 大数据量前面讨论的两个方案各自有适用的场景但真实业务里经常出现一种混合情况数据量很大几万条甚至几十万条同时单个列表项结构又很复杂。如果你只用虚拟列表虽然 DOM 数量被限制住了但可视区域内的那些复杂条目仍然会让滚动卡顿如果你只用分片渲染虽然每一项的渲染压力被分散了但最终还是会累积出几万个 DOM 节点依然治标不治本。这时候就需要组合使用外层用虚拟列表控制节点规模内层用分片渲染控制单项的渲染成本。我做一个实际的例子说明这个组合思路。假设有一个任务管理看板需要渲染 2 万条任务记录每条任务记录包含多行文字、一个头像、几个标签、一个进度环组件。如果直接虚拟列表渲染 20 个条目到屏幕上为什么会让滚动卡顿因为这 20 个条目每个包含大量子节点和组件光是创建这 20 个复杂节点就可能耗费 30~40ms已经远超一帧 16.7ms 的预算了。组合方案的做法是虚拟列表负责决定当前应该显示哪些条目分片渲染负责把这些条目“分批”挂到 DOM 上。虚拟列表滚动时先清空内容区再通过requestIdleCallback分片创建新条目。这样做的好处是即使用户快速滚动也不会一次性创建大量复杂 DOM浏览器能在分片间隙里完成滚动的绘制。另一种组合方式是“懒渲染”。比如一个网格布局的看板一个卡片包含 10 个子模块你可以先渲染卡片的骨架和标题图片、图表这些重量级内容等浏览器空闲时再逐个补上。这样用户看到界面响应迅速内容慢慢填充整体体验反而比“一次性渲染完整卡片”更好。4.2 组合方案的代码骨架组合方案的核心是把“虚拟列表的内容更新”从“同步执行”改造成“异步分片执行”。我在这里给出一个思路示例。虚拟列表的内容区有一个update方法原本是同步渲染可视区域的所有条目。现在把渲染逻辑改成class VirtualListWithChunk { update() { const { startIndex, endIndex } this.getVisibleRange(); // 先清空内容区 this.content.innerHTML ; this.renderTasks []; // 把每个条目的渲染任务放到任务队列里 for (let i startIndex; i endIndex; i) { this.renderTasks.push(() this.createItem(i)); } this.processNextChunk(); } processNextChunk() { if (this.renderTasks.length 0) return; const startTime performance.now(); // 在时间预算内尽可能多地渲染 while (this.renderTasks.length 0 performance.now() - startTime 8) { const task this.renderTasks.shift(); this.content.appendChild(task()); } if (this.renderTasks.length 0) { requestIdleCallback(() this.processNextChunk()); } } }注意这个方法有一个细节每次滚动都要清空内容区、重建所有任务。如果滚动频率很高任务还没执行完就被清掉了会造成内容闪烁。我给这个方案加了缓冲区虚拟列表的渲染范围本来就多渲染 5 条分片时分两批执行第一批是同步的优先渲染核心可视区域的条目第二批是异步的用来填充缓冲区。这样用户看到的可视区域是立即出现的缓冲区慢慢补齐不会出现空白。另外还要注意组件销毁时分片任务可能还没有执行完。如果任务执行到一半组件已经卸载appendChild就会往一个不存在的节点里添加内容浏览器会报错或者更隐蔽地造成内存泄漏。所以组合方案里的取消机制比单独用任一种都更重要必须在组件卸载时把任务队列清空并标记取消状态。4.3 不要忽略首屏预渲染与骨架屏的配合组合方案里还有一个容易被忽略的体验问题虚拟列表首屏加载要等数据请求完成后才开始渲染而分片渲染又要等首屏开始点击才能进行所以整体时间是叠加的。用户看到的是页面空白等数据→ 页面空白等渲染→ 内容出现。我的经验是给组合方案加一个“预渲染”步骤。数据返回后先同步渲染首屏可视区域内的条目只调用createItem但不分片这个数量通常只有 10~20 个即使复杂一点同步渲染也不会太卡。剩下的缓冲区条目再走分片渲染。这个做法的核心思路是骨架与主体分离先让用户看到核心内容其余的部分放在浏览器空闲时慢慢补。如果你用的是 Vue 或者 React 这类框架组合方案还要考虑一个细节避免在滚动过程中频繁触发框架的 diff 更新。虚拟列表内容区的每次更新都会让框架重新对比新旧虚拟 DOM。如果分片任务每次只更新一个条目又会造成框架连续触发多次 diff。更好的方式是分片任务累积到一定数量后在每帧结束时一次性提交给框架减少 diff 次数。5. 高频故障自查滚动抖动、白屏闪烁等问题的排查清单我在多个项目里用虚拟列表和分片渲染踩过不少坑。有些问题排查到最后发现是自己少写了一个判断条件有些问题则要翻源码才能定位。这里我把高频故障和排查思路整理成一份清单工具性强一点方便你直接对着查。5.1 滚动抖动的经典原因与排查滚动抖动是所有虚拟列表最容易出现的问题具体表现是滚动过程中列表项抖动、跳动、错位严重的时候数据会一闪而过然后跳到另一个位置。先排查起始索引的计算逻辑。如果是固定高度确认Math.floor(scrollTop / itemHeight)里的scrollTop是否拿的是正确的滚动容器。很多页面的滚动容器不是 window而是某个overflow: auto的 div如果你错用了window.scrollY拿到的值永远是 0列表就会一直停留在第一屏。再排查高度估算。动态高度虚拟列表用预估高度计算总高度时如果预估值严重偏离真实值滚动条的长度就是错的。举个例子预估每条 80px实际每条 120px那么总高度会被低估 33%滚动条会偏短滚动位置就会错乱。排查方法是检查离屏缓冲区的渲染结果如果预估值和真实值差距超过 20%需要调整预估值或者在测量到真实高度后主动重算总数。最后排查缓存的有效性。如果你在条目内容发生变化后没有更新高度缓存会导致滚动条长度错误。比如用户点击“展开”按钮某一条的高度从 80px 变成 180px如果你没有更新这个条目的实际高度并重算后续条目的位置就会出现滚动到一半数据跳位的问题。5.2 白屏闪烁和内容区空白的排查白屏闪烁在虚拟列表里也经常出现尤其是在快速滚动的时候。原因多数是缓冲区不足。我之前是用visibleCount 5做缓冲在普通场景下没问题但有一次数据量达到 5 万条用户滚动速度非常快缓冲区里还没渲染完的内容跟不上滚动速度白屏就会出现。排查方法是把缓冲区数量从 5 增加到 10 或者 15看白屏是否消失。缓冲区大小是内存开销与视觉体验的折中——缓冲区越大越不容易闪白屏但可视区外多渲染的节点也越多滚动性能会下降。我建议先按每屏条数的一半来做缓冲区比如可视区域能显示 20 条缓冲区设 10 条再根据实际效果微调。另一个原因是滚动事件监听和帧更新不同步。如果你直接把 DOM 更新逻辑写在scroll回调里没有用requestAnimationFrame合并那么高频率的滚动触发会让浏览器来不及在每一帧内完成更新也会出现白屏。加一层requestAnimationFrame通常能缓解大部分闪烁问题。还有可能超出一开始讨论的范畴你在内容区里用了absolute定位的组件或者图片加载高度变化导致内容高度变化。图片是虚拟列表的一个特例因为图片加载完成后会改变自身的高度进而影响条目的真实高度。处理方式是对图片设置固定高度或者预估宽高比或者在图片加载完成后再触发一次高度校正。5.3 分片渲染的性能验证方法分片渲染是否真的有效的判断标准不是“我用了 requestIdleCallback”这件事本身而是“主线程是否还有空闲时间处理用户交互”。我给你的验证方法是用 DevTools 的 Performance 面板。打开 Performance点击录制然后正常操作页面比如滚动、点击按钮。录制结束后查看主线程的时间线。我期望看到的是没有任何一个长任务大于 50ms阻塞主线程任务之间有明显的小间歇浏览器有时间处理事件和绘制。如果时间线上依然出现连续的长任务说明你的分片逻辑没有生效。常见的错误是分片回调里做了一次大的同步操作比如构建了一个内部包含 1000 条子节点的DocumentFragment表面上分片了实际上一片的工作量依然巨大。这时候需要减小时间预算或者把单条渲染的成本降下来。另一个验证方式是检测 UI 响应性。在分片渲染进行中快速点击界面上的一个按钮如果按钮能立即有反馈比如变色、弹出提示说明主线程没有被长期占用如果点击后要等一两秒才响应说明分片粒度还是太大。分片渲染还有一个我时常强调的经验不要等数据量很大时才用分片渲染。我有一次处理一个只有 200 条数据但每一条都包含复杂图表的页面直接同步渲染导致页面卡了 1 秒多。这种情况分片渲染同样有效分片的本质是应对“单次渲染耗时过长”而不是“数据量过大”。判断要不要用目标永远是看主线程完成了多少工作而不是单纯看数据量。6. 个人经验补充两个容易被忽视的细节写了这些内容之后我再分享两个我在实际项目中不断踩坑后总结出来的细节。第一个是关于requestIdleCallback的一个容易被人忽略的行为它只在浏览器空闲的时候执行如果页面持续处于繁忙状态比如一直在播放动画或者主线程一直有任务在执行那么requestIdleCallback的回调可能长时间得不到执行分片渲染会停滞。我的经验是给requestIdleCallback加一个超时兜底用setTimeout在最大延迟比如 1 秒后强制执行一次分片避免渲染任务被无限期推迟。第二个细节是关于记忆化。虚拟列表的条目渲染函数里可以用一个 Map 缓存已经渲染过的条目数据避免重复创建相同结构。这在有大量重复数据的场景里特别有效。比如一个日志列表里有大量重复的错误信息如果每次滚动到同一位置都重新创建 DOM就会浪费很多性能。缓存后滚动到之前的位置时可以复用旧的 DOM 节点滚动不再像第一次那样卡顿。最后无论你选了分片渲染还是虚拟列表都要在真实设备上验证效果而不是只在开发机的 Chrome 里测试。移动端的 CPU 性能和桌面端差距很大我在桌面端完全流畅的虚拟列表在手机上实测还是一卡一卡的。做性能优化这事儿最终的标准永远是“用户手里的设备跑起来怎么样”而不是你在模拟器里看到的数据多好看。