
数组扩容缩容这四个字每次出现基本就意味着前端页面又开始卡了。你以为只是push几下、改个length的事但真正等到内存飙升、滚动掉帧、图表卡成一坨的时候才发现这事远没有想象中简单。尤其在做大屏、日志面板、实时消息流这类场景时数据会像自来水一样不断涌进来数组只能不停变大然后你被迫去截断、裁剪、清空循环往复烦不胜烦。这篇文章不打算绕弯子。我要讲的就是一套我自己在项目里反复验证过的方案核心代码只有 3 行用ResizeObserver监听容器尺寸再根据可视宽度动态调整数组保留的数据量自动完成扩容与缩容。你不用引入任何第三方库不用重写业务逻辑复制一个工具类就能用。适合正在写图表、日志、长列表、消息缓存的前端同学也适合准备前端面试、想弄明白数组性能细节的人。1. 为什么数组扩容缩容会让人头疼1.1 push 越用越慢的真相动态扩容的“搬家成本”多数前端对数组的理解停留在“就是一个列表随便 push”这个层面。实际上 JS 数组底层是一段连续内存当push之后长度超过当前容量V8 会重新分配一块更大的内存再把旧元素一个个拷贝过去。这个过程跟搬家一样东西越多搬一次越费劲。虽然 V8 的扩容策略通常是倍增式扩容比如容量从 1 翻到 2、4、8……均摊下来单次 push 的时间复杂度是 O(1)但那是针对“数组一直增长、不频繁缩容”的理想情况。麻烦的地方在于前端场景往往不是单向增长。页面加载完先有一批历史数据之后 WebSocket 源源不断推新数据用户可能还要切换图表时间范围、折叠面板、拖动窗口大小。每一次“先截断再继续 push”的操作如果不得要领都会引发底层连续多次的重新分配和元素搬移。数据量小的时候无所谓数据量一旦上了万级就能明显感觉到页面卡顿甚至直接触发 long task。我在一个数据大屏项目里实测过一段 20 万条记录的处理流程如果业务代码里用的是“数组 push 到一定长度后shift()丢掉头部”页面主线程的耗时能从 30ms 飙到 400ms 以上。原因是shift()删除头部元素后数组其余所有元素都要向前移动一位复杂度是 O(n)。每次超限就 shift相当于每一次写入都伴随一次全量搬移这谁顶得住。1.2 缩容不是“把 length 变小”那么简单很多人的第一反应是既然数组太大了直接arr.length 500不就把后面的数据砍掉了从表面看确实可以length变小后超出的元素会被置为空洞随后被垃圾回收标记。但这里有几个隐藏问题。第一只设length会让数组容量不降。虽然逻辑上长度是 500但底层那块连续内存可能仍然维持在几千条元素的大小占用的内存空间未必立刻还给系统。你不断循环“push 到超限 →length砍回 → 再 push”底层容量会频繁地在两个水位之间跳动内存峰值反而不好控制。第二频繁缩容再扩容刚好踩中数组动态扩容最痛的模式每次扩容都要重新分配内存、拷贝数据。如果你的缩容只是把逻辑长度砍掉但底层容量没有跟着降那下一次扩容可能不需要重新分配可一旦数据量越过某个临界点触发容量重新分配代价就是一次大范围的元素拷贝。前端主线程上的 GC 和内存搬移多了掉帧就是必然。第三GC 的表现很难预测。你把数组长度缩小理论上旧数据可以被回收但如果这些数据对象还被其他地方引用着——比如某个闭包、某个事件监听器、某条日志对象里还挂着一个大对象——缩容后内存也不会真正降下来。这就是很多同学“明明限制了数组长度内存还是一路涨”的最常见原因。1.3 前端场景里是谁在疯狂增长数组哪些场景最容易踩数组扩容缩容的坑我随手就能列出一串实时日志面板后端日志按秒推送前端要展示最近几百行但内存里可能已经攒了几万行。大屏折线图一条曲线每秒新增一个点跑一晚上就是几十万个点全部塞进图表必然卡死。即时通信消息流聊天记录不断追加还要支持向上翻页加载更多。数据中台里的滚动列表、报表统计、操作记录只要是长连接页面时间一长数据都会堆积。行为埋点、用户操作回放、Canvas 像素动画的轨迹数组本质都是“无限写入 有限展示”。一开始大家都会选择偷懒方案设定一个固定上限到了就 shift 或者清空。固定上限的问题在于不同设备、不同容器宽度下过大的上限浪费内存过小的上限又丢失有用信息。而真正的需求是数组的容量应该跟着视图走——容器大就多存点容器小就少存点。这就引出了后面要讲的方案。2. “3行代码”的核心思路让容器尺寸决定数组容量2.1 思路拆解容量不该拍脑袋要跟视图走为什么数组容量要跟视图走拿日志面板举例一个 1920px 宽的屏幕上能看到更多日志行手机横屏时区域变宽但高度有限你真正需要展示的数据条数是动态变化的。如果固定只保留 500 条在超大屏幕上浪费在极小容器上又显得冗余。如果把容量和容器尺寸挂钩比如每 100px 宽度保留 50 条日志那容器变大自动扩容容器变小自动缩容逻辑就自然了。生活里也有类似做法请客吃饭10 个人的桌子备 12 个人的菜来的人少就少做两个菜来的人多临时加菜。你不会每次都按 100 人的规模备菜也不会只按 3 个人备菜。数组容量管理本质上就是“按需备菜”而这个“需”在组件化开发里最直接的体现就是容器宽度。我见过不少团队在这个问题上硬编码 1000、5000 这种数字拍脑袋定个上限然后再也不管。一旦产品经理改了布局、换了屏幕尺寸或者加了侧边栏折叠功能数组要么多出大量用不上的数据要么把该留的历史数据提前丢了。用容器尺寸动态推算容量至少让保留策略跟着 UI 走产品怎么改数组就怎么自适应。2.2 ResizeObserver为什么不用 window.resize要监听容器尺寸很多人第一反应是window.addEventListener(resize)。这个方案有两个痛点一是只监听窗口变化容器尺寸因为布局变化而改变时未必触发二是 resize 事件触发频率极高每次还要手动getBoundingClientRect()去量容器尺寸性能开销不小。ResizeObserver就是专门干这个事的。它可以在任意元素尺寸变化时触发回调而且回调里直接带contentRect包含准确的宽高信息不用你再去手动测量。它天然支持弹性布局和嵌套组件容器宽度变了就立刻通知你。拿一个常见场景来说页面里有个 800px 宽的图表卡片用户把浏览器窗口拉大了图表卡片变成 1200pxResizeObserver能在下一帧之前触发回调但如果用的是window.resize你还要自己判断图表区域的宽高是否真的变了多了不少样板代码。更关键的是ResizeObserver的兼容性已经覆盖所有现代浏览器在降级方案里再补一个window.resize兜底就够用了。2.3 真正负责“扩容缩容”的三行代码工具类的完整代码放到下一章这里先把最核心的三行逻辑拎出来讲因为真正做扩容缩容决策的就是这三行resize(targetWidth) { const target Math.max(this.min, Math.min(this.max, Math.round(targetWidth / this.step))); if (this.data.length target) this.data.splice(0, this.data.length - target); return this.data; }第一行根据容器宽度和步长算出目标容量再用min和max夹住避免容量越界。这里的Math.round不是随手写的它能产生一个天然的滞回效果宽度在步长范围内抖动时目标容量不会变避免 ResizeObserver 高频触发时数组来回扩容缩容。第二行当前数组长度超过目标容量就从头部批量裁剪多出来的数据。注意这里用的是splice(0, n)不是length target。splice会真正移除并释放被删元素而且一次裁剪一个批次比逐个shift()高效得多。第三行把数组返回出去方便链式调用。这不单纯是为了耍帅在数据流处理里buffer.resize(width).length这种写法能让你少写一行临时变量。那扩容呢可能有人会觉得奇怪三行代码里没有做任何“扩容”操作。这就是 JS 数组的特性底层容量是自动管理的不需要你手动扩容。我们要控制的只是“保留多少条数据”而不是“底层能存多少”。容器变大时只要把target调大后续 push 的数据自然会被保留更多扩缩容的“扩”就体现在目标容量变大这件事上。理解了这一点三行代码搞定扩容缩容就不玄乎了。3. 完整实操从工具类到业务接入3.1 一个可直接复制的工具类下面这个工具类我实际用在日志面板、折线图、消息流三个场景里稳定跑了好几个月。你可以直接复制使用。class AdaptiveBuffer { constructor({ min 60, max 1000, step 50, initial [] } {}) { this.min min; // 最小保留条数防止容量过小 this.max max; // 最大保留条数防止内存失控 this.step step; // 宽度换算步长同时也作为裁剪批次 this.data initial.slice(-max); } resize(targetWidth) { const target Math.max(this.min, Math.min(this.max, Math.round(targetWidth / this.step))); if (this.data.length target) this.data.splice(0, this.data.length - target); return this.data; } push(item) { this.data.push(item); if (this.data.length this.max) { this.data.splice(0, this.data.length - this.max this.step); } } clear() { this.data.length 0; } get length() { return this.data.length; } }参数含义再拆一下min是最小保留量容器再小也不能低于这个值否则图表上连一个可见区域的数据点都凑不够max是最大保留量容量再大也不能超过这个值防止长连接页面内存无上限增长step是容器宽度到条数的换算系数值越小容量对宽度越敏感值越大容量越稳定。push方法里有个细节值得注意当长度超过max时不是只删除新超出的 1 条而是一次性裁剪到max - step。这样做的目的是减少splice的执行次数。如果每次都只删一条splice(0, 1)触发频繁底层数组反复搬移性能反而差。先用空间换时间允许数组短暂超过上限攒够一批再统一裁剪整体效率高得多。3.2 接入真实图表场景的完整步骤拿一个实时折线图来走一遍完整流程。假设页面结构是div idchart classchart-panel/div第一步初始化缓冲区和容器监听const buffer new AdaptiveBuffer({ min: 30, max: 600, step: 25, initial: loadHistoryData() // 从后端拉取的最近数据 }); const chartEl document.getElementById(chart); const ro new ResizeObserver((entries) { const width entries[0].contentRect.width; buffer.resize(width); render(); }); ro.observe(chartEl);第二步数据进来时只负责 push 和重新渲染ws.onmessage (e) { buffer.push(JSON.parse(e.data)); render(); };第三步渲染函数里只读取buffer.data不需要关心它到底有多少条function render() { const points buffer.data; // 这里根据 points 绘制折线图 // 坐标映射、path 路径、label 展示都用 points.length 作为数据量 }这个流程的关键点在于ResizeObserver只做“容量调整 触发渲染”这件事业务数据流完全不用感知容量策略。你 push 数据的时候数组自己会决定哪些数据保留、哪些丢弃。图表画多少点、坐标轴怎么映射都自动适配。销毁组件的时候记得释放监听特别在单页应用里这一步忘掉就是内存泄漏的隐患// 在组件卸载时 ro.disconnect(); buffer.clear();3.3 三个场景的最小参数配置不同场景对容量和步长的需求不一样这里给一组我调过的参数作为参考。场景minmaxstep说明实时日志面板100200030每 30px 宽度保留 30 条日志翻页历史留得久一点大屏折线图3060025每 25px 宽度保留一个数据点曲线平滑且不卡顿即时消息流50150040消息气泡较宽步长放大避免容量过密这些参数不用一次调到位。我的习惯是先用默认值跑起来再用后面提到的性能测试方法观察内存和帧率然后微调step。step调大数组容量变小、渲染更轻step调小数据更完整、渲染更重。核心思路是让容量匹配实际可展示的像素量而不是拍脑袋定一个数字。4. 性能实测与关键参数调优4.1 用 console.time 做一个简易压力测试判断扩容缩容方案好不好不能靠感觉。我在项目里保存了一个简单的性能测试脚本专门用来对比不同策略下的耗时。核心思路是模拟“持续 push 定时 resize”的真实负载用performance.now()记录总耗时。function stressTest(buffer, total 200000) { const start performance.now(); for (let i 0; i total; i) { buffer.push({ index: i, payload: new Array(100).fill(i) }); if (i % 1000 0) buffer.resize(800); // 模拟容器变化 } return performance.now() - start; } const buffer new AdaptiveBuffer({ min: 30, max: 600, step: 25 }); console.time(adaptive-buffer); const cost stressTest(buffer); console.timeEnd(adaptive-buffer); console.log(resize cost:, cost);同样的负载我用三种方式跑过对比策略20 万次操作耗时最终内存占用表现不限制长度约 180ms持续上涨时间长必崩每次超限 shift超过 1200ms稳定但卡顿明显频繁全量搬移批量裁剪 resize约 260ms稳定且低位无明显卡顿这个表里的数字会随浏览器版本和数据大小浮动但趋势很稳定不限制长度是最省事的写法但内存不可控每次 shift 是最烂的写法性能直接崩盘批量裁剪是可控且高效的中间方案。4.2 参数调优min、max、step 怎么定min的取值逻辑很简单就是“容器最小可见内容所需的数据量”。如果图表在小容器里至少需要显示 30 个点那min至少设为 30。日志面板至少需要看到 100 行那min就设为 100。它更多由业务语义决定而不是性能决定。max主要防止内存失控所以取值应该参考最极端情况下的可接受内存。比如一条数据平均 200 字节max 2000时最大占用约 400KB对现代设备来说完全无压力。如果单条数据是大对象比如包含完整上报 JSON 的日志那max就要调低或者对数据做裁剪后再 push。step是容量对容器宽度的敏感度。假设图表每 25px 宽度放一个数据点那step 25会让容量和可视宽度几乎一一对应。如果容器宽度小于 750px容量自动被夹在min 30如果宽度达到 15000px容量夹在max 600。这样无论屏幕多大数组都不会把数据量拉到离谱的程度。还有一个小技巧如果你在写 Canvas 大屏建议把devicePixelRatio也考虑进去。高 DPI 屏幕上画线的实际像素密度更高同样的物理宽度需要更多的数据点才能铺满此时step可以除以window.devicePixelRatio让容量更贴合实际渲染需求。4.3 为什么不建议直接用环形缓冲区实现讲到数组性能一定有人提环形缓冲区。环形缓冲区确实能解决头部删除 O(n) 的问题通过维护 head/tail 指针让 push 和 pop 都变成 O(1)。我在日志系统里也写过类似实现但最后在这个场景里没有用它。原因很简单可读性和维护成本不划算。环形缓冲区取数时要自己算索引映射比如data[(head i) % capacity]对后续维护的人很不友好。而且它天然适合“容量固定”的场景你要是让它动态缩容还得处理 head 回绕和容量变化时的数据迁移复杂度直接上升。而我们这个场景里max通常在 1000 以内splice(0, k)批量删除的成本完全可控没必要为了理论上的 O(1) 牺牲代码简洁度。如果哪天数据量确实到了上万级再换环形缓冲区不迟但大多数前端页面到不了那个量级。5. 常见问题与排查技巧实录5.1 高频问题速查表问题现象常见原因解决方案数组长度设置了上限内存却不降删除的数据被闭包或其他引用持有用内存快照检查 retained size找到持有方置 null缩容后数据来回抖动ResizeObserver 触发频繁容量阈值反复切换调大step利用Math.round的滞回效应页面卡顿明显频繁shift()删除头部数组元素反复搬移改为批量的splice(0, n)减小裁剪频率push 大量数据时帧率暴跌一次 push 大批数据主线程渲染压力过大拆分批次渲染用requestAnimationFrame控制节奏resize 时画面闪烁resize 回调里做了重渲染但数据还没准备好先算容量再 push 缓存数据最后统一 render这套速查表是我在项目里踩坑后的总结基本覆盖了数组扩容缩容最常见的几类问题。5.2 实战为什么数组长度设置了上限内存还是只涨不降有一个真实案例。同事负责的日志面板代码里明明做了if (logs.length 500) logs.shift()但监控面板显示内存一路从 200MB 涨到 800MB。第一反应是 shift 太频繁导致性能慢但性能其实还好。打开 DevTools 的 Memory 面板做了一次堆快照对比前后两次快照发现大量的LogItem对象还挂在事件监听器和闭包里没有被回收。问题出在数据流设计上日志对象被 push 进数组之后又被一个注册在全局的事件总线引用了一份。数组缩容虽然删掉了数组里的引用但事件总线上那份引用还在GC 自然标记不掉。解决方法是把事件总线的订阅及时取消或者让日志对象进入数组前先脱敏、复制一份轻量结构。这类问题有个排查技巧不要只看数组本身的长度要看“谁还在引用那些应该被删除的数据”。用 Chrome 的 Allocation 记录抓一下分配位置重点排查闭包、事件监听器、Map 和 Set 里是否有隐藏引用往往比盲目调参数有效得多。5.3 数据抖动与回调风暴ResizeObserver 的两类坑ResizeObserver 虽然好用但它也有两个典型的坑。第一个坑是在回调里修改被观察元素的尺寸。比如你在 resize 回调里根据新宽度设置chartEl.style.height xx而这个高度变化又引起了布局重算、宽度再次变化就会触发回调风暴甚至直接报ResizeObserver loop limit exceeded。解决方案是回调里只读取尺寸、只修改数组和渲染数据不要在同一次布局循环里再次改动被观察元素的尺寸。第二个坑是回调触发频率可能比你预期的要高。窗口拖拽时容器宽度可能一帧内连续变化多次每次都执行splice和重渲染会造成不必要的开销。我的习惯是在回调里配合requestAnimationFrame做合并保证一帧最多处理一次let rafId 0; const ro new ResizeObserver((entries) { const width entries[0].contentRect.width; cancelAnimationFrame(rafId); rafId requestAnimationFrame(() { buffer.resize(width); render(); }); });这样即使 resize 事件在短时间内高频触发真正执行 resize 逻辑的也只是每一帧一次渲染压力大幅降低。说到这里分享一个我实际踩过的小教训如果页面上有多个图表组件每个都自己创建ResizeObserver组件数量一多回调数量也会跟着膨胀。可以考虑在父组件里统一监听外层容器把宽度通过 context 或者 props 传给子组件避免每个图表都重复创建观察器。这个优化在仪表盘类页面里收益很明显。我个人在实际项目里的做法是把AdaptiveBuffer封装成一个很小的自定义 hook 或者工具模块容器宽度和容量逻辑集中管理业务侧只需要拿到一个稳定的data数组。这样即便产品后续改动布局、调整数据源也不需要重新折腾性能问题。数组扩容缩容这个事说起来是性能优化本质上其实是数据流的治理意识数据总量要有界保留策略要跟视图一致删除数据要真正释放引用。把握住这三条你也能用三行代码把这个问题按得死死的。