资讯动态

Vue2瀑布流组件从零实现:布局算法、图片加载与无限滚动优化

发布时间:2026/10/13 4:04:37 来源:尧图企业网站定制
前段时间接了一个电商改版需求要在 Vue 2 项目里做一套瀑布流卡片流图片高度参差不齐还要支持加载更多和动态筛选。翻了一圈社区里现成的瀑布流方案要么年久失修要么跟具体业务样式耦合得太深改起来比重新写还累。最后决定自己动手写一个通用组件前后折腾了两周把布局算法、图片加载、无限滚动、性能调优这些坑都踩了一遍。这篇就把整个思路和实现细节完整写出来给还在 Vue 2 上做瀑布流的朋友一个能直接落地的参考。关于这块内容适合的人群很明确项目还锁在 Vue 2 上的前端同学正在纠结“用现成库还是自己写”的人以及已经决定自己写但不确定核心算法怎么下手的人。我会从最基础的两种实现路线讲起一直讲到大数据量下的虚拟渲染优化基本上你遇到的需求场景都能覆盖到。1. 瀑布流组件的两种实现路线CSS列假瀑布流与JS定位真瀑布流1.1 为什么CSS多列布局在图片瀑布流里撑不住很多人的第一反应是直接用 CSS 的多列属性也就是column-count配合column-gap再给子元素加上break-inside: avoid。这种方案在静态页面里看起来很省事几行样式就能出效果但一旦放到业务场景里问题接二连三地冒出来。先说最核心的排序问题。CSS 多列布局的填充顺序是竖向的比如 3 列浏览器会先把第一列从上到下填满再填第二列最后填第三列。而用户通常期望的瀑布流顺序是“横向从左往右、整体按列表顺序排下去”的新闻资讯流和商品展示流尤其依赖这个顺序。用 CSS 列实现的话前几个元素会全部堆在左边第一列用户横向扫视时看到的根本不是列表的先后顺序产品和技术都很难接受。第二个问题是动态内容变更时整个布局会“跳”。CSS 多列布局是浏览器内部维护的当你筛掉一个元素、排序或者向列表中间插入一条数据时浏览器会重新平衡所有列所有卡片的垂直位置都会跟着变。这个过程中你没法控制哪个元素移动、移动到哪动画效果就别想了用户看到的就是一片乱窜。第三个问题是卡片内部有异步图片时列高计算不稳定。多列布局依据的是内容真实渲染高度图片没加载完的时候列高是错的图片加载完以后浏览器又得重新排一遍。虽然可以通过给图片外层包一个固定高宽比的容器来缓解但这种约束会让卡片的灵活性大打折扣。所以我的结论很直接CSS 多列布局适合做那种“内容静态、不排序、不筛滤、不要求顺序”的简单图文墙。一旦你的场景是数据驱动的、要跟列表联动、要支持加载更多的老老实实走 JS 计算定位这条路。1.2 JS绝对定位方案的核心逻辑最短列优先JS 方案的本质是容器设置position: relative每张卡片用position: absolute定位由我们自己计算每一张卡片的top和left。这听起来没什么难度真正的核心是“每一张卡片到底该放到哪一列”的决策算法。业界比较通用的策略是“最短列优先”也叫最小高度优先。维护一个长度为列数的数组数组里存的是每一列当前累积的高度初始都是 0。每来一个新卡片就找数组里值最小的那一列把卡片放到这一列的最底部然后更新这一列的高度。这个过程相当于“哪列最矮就往哪列塞”最终所有列的高度会趋向均匀整体视觉上不会有某列特别高、某列特别矮的失衡感。举个例子感受一下。假设容器宽度 1000px列数 3间距 10px那么每列宽度就是(1000 - 20) / 3 326.67px。前三个卡片的真实高度分别是 200、300、250那么第一个卡片三列高度都是 0选第 1 列放置后列高数组变成[200, 0, 0]。第二个卡片第 2 列最矮放第 2 列数组变成[200, 300, 0]。第三个卡片第 3 列最矮放第 3 列数组变成[200, 300, 250]。第四个卡片来了以后选第 1 列因为 200 最小。所有卡片放完后容器总高度就是列高数组里的最大值。这个算法简单、稳定而且计算量是 O(n)对几百上千条数据完全不成问题。1.3 组件整体设计与数据流确定了算法之后组件结构其实很清晰了。我把这个瀑布流组件设计成“只负责布局、不关心卡片内容”的容器型组件单个卡片到底长什么样由父组件通过插槽决定。组件的核心职责有三个接收列表数据、计算每个数据项对应的坐标和宽高、把这些计算出来的样式应用到内部渲染的定位容器上。父组件需要做的只是传入list、设置列数和间距然后在插槽里写自己的卡片模板。数据流上需要注意一点位置计算的结果不要让 Vue 的响应式系统在每次单项变更时都触发重渲染。比较稳的做法是计算完一批位置后用一个普通对象整体替换掉旧的positions再依赖这个替换触发一次组件更新。不要在一个位置对象上做this.$set之类的逐字段修改那种写法在数据量上来以后会明显变慢。这里要先解释清楚“真实高度”的问题。瀑布流布局里卡片高度决定了一切。如果一个卡片里只有图片高度就是图按列宽缩放后的显示高度也就是列宽 / (图片宽 / 图片高)如果卡片里除了图还有标题、价格、按钮等固定内容高度就得在图片高度的基础上加上这些固定区域的高度。最理想的情况是后端在下发列表数据时直接带上宽高比或者显示高度这样前端拿到数据就能立刻布局不需要等图片加载。但现实中后端往往不会给那就只能靠前端预取图片尺寸来算。我一般在接口层或者 store 里维护一个预加载方法用隐藏的Image对象把图片真实宽高拿到再换算成ratio字段挂到 item 上。拿到了ratio布局引擎就能在渲染前准确算出每张卡片的显示高度不需要渲染完再回头校正。如果实在拿不到 ratio就要用“先估算、后校正”的方案。默认给一个占位比比如0.75先按这个比布局等对应图片load后拿到真实比例再触发一次 refresh。注意图片error的时候也要兜底不然这一列的高度是 0后面的卡片位置全部错乱。另一种思路是把卡片先渲染到一个隐藏的测量容器里量出真实高度后再搬到正式位置。这个思路更通用但实现复杂度高测量容器的宽必须和正式容器一致而且因为高度是异步量出来的可能会出现闪烁。我做过的项目里后端的图片基本都有 CDN 处理接口拿不到宽高的情况其实不多所以更推荐优先推动后端给 ratio前端用占位策略做兜底。2.2 最短列优先的具体实现与计算过程布局引擎我封装成一个独立的方法layout()主要做的事情就是遍历列表、维护列高、生成位置数组。一个比较典型的核心逻辑像下面这样layout() { const containerWidth this.$refs.container.clientWidth; const col this.currentCol; const gap this.gap; const colWidth (containerWidth - (col - 1) * gap) / col; const colHeights new Array(col).fill(0); const positions {}; this.list.forEach((item, index) { const colIndex colHeights.indexOf(Math.min(...colHeights)); const height this.getItemHeight(item, colWidth); positions[item.id] { top: colHeights[colIndex], left: colIndex * (colWidth gap), width: colWidth, height }; colHeights[colIndex] height gap; }); this.containerHeight Math.max(...colHeights); this.positions positions; }getItemHeight负责返回卡片高度优先用 item 上的真实高度或比例都没有就给一个默认占位高度getItemHeight(item, colWidth) { if (typeof item.height number) return item.height; if (item.ratio) return colWidth / item.ratio; return colWidth * 0.75; }布局完成之后模板里的每个卡片通过 item 的 id 去positions里取样式。注意这里不要用index作为插槽循环的 key因为筛选和插数据会导致 index 错位位置对应关系会乱。用稳定 id 作 key理解和排错都轻松得多。模板部分大概是这样的template div refcontainer classwaterfall-container :style{ height: containerHeight px } div v-foritem in list :keyitem.id classwaterfall-item :stylegetItemStyle(item.id) slot :itemitem :widthpositions[item.id] positions[item.id].width/slot /div /div /template容器高度必须手动设置成containerHeight这是很多新手容易漏掉的地方。卡片全是绝对定位的已经不占文档流父容器如果高度为 0兄弟元素和整体滚动高度都会出问题。每次布局算完必须同步把容器高度 set 进去。2.3 响应式列数的自动调整瀑布流组件不能固定列数手机、平板、桌面应该显示不同的列数。我把col设计成两种用法传一个数字就是固定列数传一个断点对象组件内部根据容器宽度匹配对应的列数。断点对象的形式大概是这样const breakpoints { 1200: 4, 768: 3, 480: 2 };匹配逻辑很简单取容器宽度从大到小找第一个满足containerWidth key的断点取对应的列数如果没有匹配的就默认 1 列。这段逻辑放到getCurrentCol()方法里每次窗口大小变化时重新调用。关键的一点是列数变化时不能只在原来的基础上微调而是必须把整个布局全部重算。因为列宽变了卡片的显示高度也会变原来列高数组里的历史数据已经没有意义必须从头开始排一遍。这也是我坚持用positions整体替换而不是逐项修改的原因重排成本在可接受范围内代码却清晰很多。窗口监听采用节流函数我习惯用 200ms 左右的延迟避免 resize 事件在移动端频繁触发导致布局抖动。监听器在mounted里挂上beforeDestroy里移除这个生命周期管理不能省。3. 图片加载、占位与异步高度瀑布流最容易翻车的地方3.1 为什么图片不加载完布局就算不准这是我把瀑布流组件推翻重写次数最多的一个原因。初始思路是等页面渲染完再用$refs量每个卡片的实际高度但实际渲染时遇到了几个很棘手的情况第一次进入页面图片还没发请求渲染出的图片高度是 0或者只有占位符的高度。这时layout()算出来的每个卡片高度都是错的后面所有卡片的垂直位置全跟着错。图片有浏览器缓存的时候一部分图片会同步拿到尺寸另一部分还是异步加载。同一个列表里有的卡片高度对、有的不对整个布局错得毫无规律。更麻烦的是就算我第一次量对了后续图片加载完以后卡片高度变了之前的位置也全部作废。也就是说“先渲染再测量”这个思路天然就和异步图片的加载时序冲突。所以对于主要包含图片的瀑布流我的方案是布局依赖的数据必须提前准备好不能依赖“图片已经渲染出来”这件事。3.2 占位高度策略先估算、后校正的完整思路既然布局不能等图片那就把“知道高度”的时机提前。最推荐的做法是父组件在拿到列表数据后先把每张图片的尺寸预取出来。下面是一个常用的预取方法function getImageSize(url) { return new Promise((resolve, reject) { const img new Image(); img.onload () resolve({ width: img.naturalWidth, height: img.naturalHeight }); img.onerror reject; img.src url; }); }拿到宽高后换算成比例挂到 item 上const size await getImageSize(item.imageUrl); item.ratio size.width / size.height;这样在布局引擎跑之前每个 item 的ratio都已经存在layout()可以直接算出准确高度一次成型。如果预取请求没拿到数据就用占位比兜底。默认占位比我设为colWidth * 0.75这只是一个猜测值图片加载完成后高度往往会变因此还需要“校正”步骤。校正的时机可以是图片的load事件也可以在父组件拿到真实尺寸后显式调用this.$refs.waterfall.refresh()。refresh()做的事情很单纯重新跑一遍layout()。因为布局函数只用当前已知的 item 数据计算不依赖任何临时状态所以它是幂等的调用多少次结果都一样。这保证了校正不会越改越乱。这里有个实操经验图片加载失败时也要做兜底修正。如果一张图的请求直接报错load事件永远不触发占位高度就会一直占着。我的做法是在预取时catch住错误给 item 一个默认比比如 1.5保证布局不会因为单张图挂掉而塌陷。3.3 动态内容变更时的重排触发机制瀑布流组件在动态数据下最容易出现“数据变了但位置没更新”的延迟感。比如用户筛选了一个分类列表从 50 条变成 20 条如果只是把list改了layout()不一定会在正确的时机执行。原因在于 Vue 2 的依赖收集机制。组件模板里对list的引用是渲染层面的但layout()里用到的list是手动遍历的不会自动建立响应式关系。换句话说list变化不会天然触发layout()执行二者是分离的。所以我在组件里除了用watch监听list长度变化外还暴露了一个refresh()方法给父组件手动调用。业务里凡是涉及“数据发生结构变化”的地方比如筛选、排序、加载更多、删除某条都建议父组件在改完数据后显式调一次refresh()而不是指着 watch 保底。watch不是完全没用它可以作为第一道保险但判断条件要写得保守一点比如只在list.length变化时触发如果直接深度监听 list 里每个 item 的字段图片加载时哪怕只改了一个字号字段整个瀑布流都会重排这种卡顿是灾难性的。手动调用refresh()的体验会稳很多。4. Vue 2组件封装要点props、slot、nextTick与响应式陷阱4.1 组件API设计列数、间距、懒加载与插槽约定瀑布流组件要不要做懒加载我的建议是组件本身不内置图片懒加载逻辑。图片懒加载是另一个独立的关注点很多项目有自己的图片懒加载指令或者第三方插件组件里再内置一套反而容易冲突。组件只保留布局能力把懒加载交给调用方。核心 props 我控制在五个以内超过五个就要开始反思是不是越界了。具体如下prop类型默认值作用listArray必填数据列表每个 item 必须有唯一 idcolNumber / Object2固定列数或断点配置gapNumber10卡片间距单位 pxtransitionDurationNumber300位置变更时的过渡动画时长0 表示关闭scrollContainerHTMLElementwindow监听滚动的容器用于无限滚动场景插槽方面我只提供一个默认插槽作用域字段带上item、index和width。width是可用的列宽父组件可以用它来做图片或其他元素的尺寸控制。一个完整的用法长这样waterfall :listfeedList :colwaterfallBreakpoints :gap12 loadMoreloadMore template #default{ item, width } div classcard img :srcitem.imageUrl :style{ width: width px } / p{{ item.title }}/p /div /template /waterfall事件方面只需要一个loadMore在滚动接近底部时触发。父组件负责追加数据、更新list然后调一下refresh()新一轮布局自然就完成了。4.2 为什么nextTick和$refs在布局初始化时缺一不可布局函数第一步就是读容器宽度this.$refs.container.clientWidth但这句话有一个前提容器已经渲染到页面里了。如果你在created()里调用layout()容器还不存在$refs.container是 undefined直接报错。就算移到mounted()也不能保证一定能拿到正确的宽度。因为mounted()只是说组件的 DOM 已经挂载但子组件、插槽内容、图片占位的渲染可能还没完成。稳妥的写法是mounted() { this.$nextTick(() { this.layout(); }); }$nextTick把布局动作推到本轮 DOM 更新完成之后执行这时候容器宽度、插槽内容都已经就位读到的值才是准的。同样的问题也出现在父组件追加数据之后。父组件改了list但子组件的 DOM 是异步更新的立刻调layout()读到的新列表长度还是旧的。所以在refresh()内部我同样会把核心布局逻辑包在$nextTick里确保遍历的是最新数据。4.3 Vue 2响应式对瀑布流组件的隐性影响Vue 2 的响应式系统在普通页面上用着没什么感觉但一旦列表数据上到几百条响应式开销就会变得肉眼可见。瀑布流组件正好是这种数据密集型场景有几个地方必须小心。第一是位置数据不要跟着 item 走。我一开始想过把每个 item 的top和left直接挂到 item 对象上想着渲染时直接用。结果每次布局都要给新增 item 定义响应式属性一旦列表里有动态追加的字段defineReactive的消耗很快。后来改成独立的positions对象用 item.id 做 key整个替换不再逐字段修改。第二是避免深度监听。上面提过的watch: { list: { deep: true } }这种做法一旦启动列表里任意 item 的任意属性被修改watcher 都会触发。图片加载、点赞状态、某个 prop 的临时更新都会把瀑布流整个重排一遍。性能问题倒在其次逻辑上很容易出现“我只是改了标题整个布局跳了一下”的诡异现象。第三是v-for的 key。绝对定位下 key 尤其重要因为位置是从positions里取出来的如果 key 用了 index而列表中间插入了一条数据后面所有卡片会拿错位置数据布局瞬间乱套。用 item.id 当 key 是第一原则没有 id 的数据建议先map一下补上。5. 无限滚动与大数据量场景从“能跑”到“扛得住”5.1 无限滚动的接入点滚动位置、内容区高度与加载时机无限滚动是瀑布流最常用的搭档。低质方案是在scroll事件里判断scrollTop clientHeight scrollHeight - threshold然后加载下一页。这个方法能用但 scroll 事件触发频率极高每次都读 scrollHeight 和触发布局页面性能不会好。我更推荐在组件底部放一个哨兵元素用IntersectionObserver监听它是否进入视口。哨兵元素是瀑布流容器里的最后一块高度设成 1px 或者干脆就是一个空 div。初始化逻辑如下initInfiniteScroll() { const sentinel this.$refs.sentinel; if (!sentinel) return; if (!(IntersectionObserver in window)) { window.addEventListener(scroll, this.handleScrollFallback); return; } this.observer new IntersectionObserver( (entries) { if (entries[0].isIntersecting) { this.$emit(loadMore); } }, { rootMargin: 300px 0px } ); this.observer.observe(sentinel); }rootMargin: 300px 0px表示哨兵元素距离底部还有 300px 时就开始触发加载相当于提前加载用户体验上就是“快到底的时候自动加载不会明显等待”。这套写法不用监听 scroll 事件性能干净很多。5.2 数据量变大后的性能瓶颈在哪无限滚动跑到后期列表里可能堆了上千条数据。瀑布流的性能瓶颈这时候会集中在三个地方。第一是布局计算本身。虽然 O(n) 的算法不算复杂但每次重排都是全量计算几百条还好上千条逐条计算加 DOM 样式更新就有明显卡顿。应对办法是尽量把一次加载的数据量控制在 20~30 条左右避免一下子追加几百条。第二是绝对定位的渲染压力。每个卡片都是独立定位元素position 变化会触发布局一次性 append 大量新卡片时浏览器要重新计算的位置极多。方案是追加数据后不要给新卡片位置变化加过渡动画过渡动画在小批量时很流畅大批量时会变成肉眼可见的“蠕动”。我一般在组件里用状态位区分首屏初始化和追加加载追加时临时把transitionDuration设为 0下一帧再恢复。第三是插槽内容的迭代渲染成本。作用域插槽本身是有开销的特别是插槽里又嵌套了几个子组件每条数据渲染成本都不小。结合接下来要说的虚拟渲染可以有效控制同时存在于 DOM 里的卡片数量。5.3 轻量虚拟滚动的优化思路瀑布流的数据量一旦超过 2000 条全量渲染无论如何都会卡。完整方案是虚拟滚动但瀑布流的虚拟滚动比等高列表复杂太多因为每张卡片高度不一致位置坐标是分散的。好消息是瀑布流组件内部已经维护了每张卡片的top和height这两项正好是虚拟滚动计算可见范围必须的数据。所以可以做一个轻量的“视口裁剪”监听滚动位置只渲染那些“位置范围与视口范围有交集”的卡片可视区外上下各加一段缓冲区避免滚动时频繁出现白屏。实现的要点是维护一个visibleIds集合由scrollTop、clientHeight和一个缓冲量共同决定。核心计算可以简化成const top scrollTop - buffer; const bottom scrollTop clientHeight buffer; visibleIds list .filter(item { const pos positions[item.id]; return pos pos.top bottom pos.top pos.height top; }) .map(item item.id);模板里的v-for从list改成visibleIds然后用 id 去list里取原始数据和positions里的样式。缓冲量我一般取视口高度的一半太小会频繁出现空窗太大又起不到裁剪作用。这种方案虽然做不到等高列表那种“沉浸式虚拟滚动”但代码量小、稳定性好。对瀑布流这种高度随机、图片异步加载的场景来说是一个实际可行的折中方案。如果你追求的是那种 10 万条数据依然丝滑的极致体验那就需要后端配合分页、前端做窗口化缓存复杂度完全是另一个量级一般的项目没必要一上来就做那么重。6. 实测踩坑记录resize、路由切换、组件销毁的边界情况6.1 窗口resize重算时的节流与列数判定窗口大小变化是瀑布流重排的第一触发因素但怎么监听、怎么节流直接影响移动端的体验。我踩过的第一个坑是没有在 resize 里重新算列数。手机从竖屏切横屏容器宽度变了如果还是按旧列数布局每一列都会被拉得特别宽图片比例也跟着错。后来我在 resize 处理函数的开头强制调用getCurrentCol()刷新列数再决定要不要重新布局。第二个坑是节流时间太短。移动端 resize 事件在浏览器里几乎是连续触发的如果每次触发都跑一遍全量布局动画帧根本跑不完。我试过 100ms、150ms、200ms最终定在 200ms 比较合适既不会让用户感觉布局更新迟钝也不会在旋转屏幕时卡死。第三个坑比较隐蔽clientWidth是 rounded 后的整数而列宽计算里用到了除法最终每列宽度在小数点上会有偏差。多个卡片的left加width之后可能会比clientWidth多出 1~2px视觉上最后一列稍微溢出。解决办法是在布局函数末尾做一次剪切把最后一个卡片的宽度裁剪到边界刚好对齐即可这属于小细节但不处理总会有强迫症产品盯着看。6.2 路由切换和组件复用导致的布局错乱组件在keep-alive场景下的表现和普通页面完全不同。第一次进入页面没有任何问题从别的路由切回来时问题就来了。因为组件被缓存了created和mounted不会再执行只有activated会触发。如果activated里不重新执行布局而容器所在的页面在缓存期内被切换成了别的路由容器的宽度可能已经变了瀑布流却还停留在旧宽度算出的位置整个布局错位。我的做法是在activated里加一个标志判断组件是否已经初始化过初始化过就直接调refresh()。同时注意不要在activated里读取clientWidth后立刻算布局因为组件刚激活时可能还在过渡动画期间需要先等一帧activated() { this.$nextTick(() this.layout()); }另一个和路由相关的坑是滚动容器的归属。如果页面主体是一个内部滚动的 div而不是 window 滚动哨兵元素的观察根节点要对准这个 div否则哨兵永远不出现无限滚动直接失效。组件里的scrollContainerprop 就是为这个设计的传给IntersectionObserver的 root 时要小心确定滚动的真正容器。6.3 测试用例与自查清单瀑布流组件涉及的状态太多发布前我习惯跑一遍下面的自查清单能避免大部分线上问题空列表渲染后容器高度是否为 0页面有没有报错。单条数据位置是否为第一列顶部。图片缺失 ratio占位高度是否生效加载完成后 refresh 是否校正。图片加载失败兜底 ratio 是否生效布局是否塌陷。列数断点切换横竖屏切换后布局是否重新计算卡片是否溢出。插入中间数据v-for key 是否稳定位置是否错乱。筛选减少数据容器高度是否正确收缩。无限滚动触发哨兵是否在距离底部 300px 时触发 loadMore连续触发是否重复加载。keep-alive 切换回来布局是否重新计算滚动位置是否异常。组件销毁resize 监听和 IntersectionObserver 是否移除有没有内存泄漏。其实很多边界问题是可以从设计上规避的。比如把refresh()设计成幂等不管什么时候调用都只做“用当前数据重算布局”这一件事很多依赖顺序的 bug 就不会存在。比如把位置数据用独立对象管理、不被响应式深度监听就能过滤掉大量无关刷新。如果你也在项目里准备自己写瀑布流我的建议是从最短列优先这个算法开始先把布局和响应式列数跑通不要一上来就把懒加载、虚拟滚动全塞进去。一次只做一件事每一步都验证清楚再叠加下一层踩坑的概率会低很多。后面如果遇到更刁钻的需求比如卡片高度动态变化、混合内容、拖拽排序这类再在这个基础上一点一点加也不迟。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑