面试官:你瀑布流怎么做的?

81 阅读7分钟

我:IntersectionObserver,从青铜到王者

前端有三大幻觉:

1️⃣ 我会写瀑布流
2️⃣ 我懂懒加载
3️⃣ 我这个列表性能还行

—— 直到某天产品说了一句:

“数据量可能有点大,先给你一万条试试。”

然后你打开 Chrome:

  • 页面开始卡
  • 滚动开始掉帧
  • 风扇开始起飞 🛫
  • 你开始怀疑人生

这篇文章,我们就一次性打碎这三大幻觉。

从最基础的瀑布流开始,一步步升级到:

  • ✅ 真 · 瀑布流(不是 i % 2
  • ✅ IntersectionObserver 无限滚动
  • ✅ 工程级图片懒加载
  • ✅ 瀑布流 + 虚拟列表(性能王炸)

全程只围绕一个核心主角:

IntersectionObserver(IO)


一、IntersectionObserver 是什么?(一句人话版)

官方定义很复杂,我们直接说人话

IntersectionObserver = 浏览器帮你盯 DOM

你只需要告诉浏览器三件事:

  • 👀 你要盯谁(目标元素)
  • 🪟 相对于谁(视口 / 容器)
  • 📢 什么时候通知你(进入 or 离开)

然后:

❌ 不用监听 scroll
❌ 不用算 scrollTop
❌ 不用节流 / 防抖

浏览器帮你全干了,而且是 原生 C++ 级别优化

一句话总结:

IO 就是为懒加载、无限滚动、性能优化而生的 API。


二、从“假瀑布流”说起(你 80% 写过)

最经典、最容易上手的写法

// 左列
images.filter((_, i) => i % 2 === 0)

// 右列
images.filter((_, i) => i % 2 === 1)

然后 JSX 里一渲染:

<div className="column">
  {leftList.map(img => <ImageCard />)}
</div>
<div className="column">
  {rightList.map(img => <ImageCard />)}
</div>

效果看起来像瀑布流:

  • 两列
  • 高度不等
  • 图片参差不齐

但问题来了:

你是怎么分列的?

靠索引瞎分的。

致命问题

图片高度不一样,但你分列时完全不考虑高度。

结果就是:

  • 左边:一路通天
  • 右边:矮到怀疑人生

📌 这在面试官眼里是什么?

“会写 UI,但不懂瀑布流原理。”


三、什么才是「真实瀑布流」?

一句话定义(建议背)

真实瀑布流:每来一条数据,就放到当前“高度最小”的那一列。

听起来很复杂?
实际上,核心算法只有 3 行。


核心思路拆解

假设你有 2 列(多列同理):

const columns = [[], []]
const heights = [0, 0]

每来一张图片:

const minIndex = heights.indexOf(Math.min(...heights))
columns[minIndex].push(image)
heights[minIndex] += image.height

就完事了。

为什么这才是真瀑布流?

很多初学者写瀑布流,只是把图片平均分到每一列,比如:

// 左列
images.filter((_, i) => i % 2 === 0)

// 右列
images.filter((_, i) => i % 2 === 1)

乍一看好像也挺像瀑布流,页面上是两列或三列图片整齐排列。
但是,这种方法问题很大

  1. 完全不考虑图片实际高度

    • 高一点的图片就会撑高列
    • 低一点的图片就会让另一列短得像“人生低谷”
  2. 列高度可能严重不平衡

    • 一列“高耸入云”,另一列矮小
    • 用户滚动时,会觉得页面布局奇怪甚至抖动
  3. 严重依赖渲染顺序

    • 如果图片异步加载(比如懒加载),高度变化还会导致 DOM 重排
    • 页面体验不佳,用户可能看到“瀑布流跳动”

真实瀑布流的核心策略是:

  • 永远把新图片放到当前最短的一列

    • 每来一张图片,先计算每列累计高度
    • 找出最矮的列,把图片放进去
    • 更新该列高度
  • 列与列高度趋于平衡

    • 每一列高度尽量接近
    • 整个布局看起来整齐又自然
  • 完全不依赖 DOM

    • 所有高度计算都在 数据层完成
    • 即使图片还没加载,也不会抖动
    • 布局稳定,滚动流畅

📌 面试官最爱问的点就在这里:

“你瀑布流是怎么分列的?”

这时候,你绝不能说:

“我用 i % 2 平分列。”

你要说:

维护每一列的累计高度(height 数组),动态选择当前最短的列,把新图片放进去,然后更新列高度。


核心实现示例(3列)

const columns = [[], [], []]   // 三列数据
const heights = [0, 0, 0]     // 每列累计高度

images.forEach(image => {
  // 找到当前最短的列索引
  const minIndex = heights.indexOf(Math.min(...heights))

  // 把图片放到最短列
  columns[minIndex].push(image)

  // 更新该列的累计高度
  heights[minIndex] += image.height
})

执行流程解析:

  1. 维护 heights 数组

    • heights[i] 表示第 i 列的当前总高度
    • 每次添加图片前都能快速知道哪一列最短
  2. 动态选择最短列

    • Math.min(...heights) 找到最小高度
    • indexOf 返回列索引
  3. 更新列数据

    • 把图片 push 进去
    • 更新 heights,保证下一张图片也能找到最短列
  4. 效果

    • 每列高度尽量接近
    • 页面布局自然
    • 瀑布流不会抖

📌 为什么这比 i % 2 高级?

区别i % 2 分列真实瀑布流
分列依据索引当前列高度
高度平衡不平衡自动平衡
图片加载顺序顺序高度优先
DOM 重排容易抖不抖
面试回答“平均分列”“动态选择最短列”

面试官一听,就知道你理解布局原理,不是只会写表面效果。

四、为什么瀑布流一定要「提前知道高度」?

很多人第一次写真实瀑布流,都会卡在一个问题:

图片高度不是加载完才知道吗?

是的。
但你不能等它加载完。

如果你等图片加载完再算高度,会发生什么?

  • 图片一张张加载
  • DOM 一次次 reflow
  • 布局疯狂抖动
  • 用户以为你的网站在抽搐

这在工程里是灾难级体验


正确姿势:高度在「数据层」就确定

比如 mock 数据:

{
  id: '1-0',
  height: 420,
  url: 'xxx'
}

React 里直接:

<div style={{ height }}>
  <img />
</div>

📌 图片只是“内容”,
📌 布局一定要稳定。


五、IntersectionObserver 实现无限滚动(优雅版)

老派写法是什么?

  • scroll 监听
  • scrollTop
  • clientHeight
  • scrollHeight
  • 再加节流

维护成本高,bug 还多。


IO 版无限滚动(标准答案)

我们只做一件事

在列表底部放一个“哨兵”。

<div ref={loader}>加载中...</div>

然后观察它:

const observer = new IntersectionObserver(([entry]) => {
  if (entry.isIntersecting) {
    fetchMoreImage()
  }
})

observer.observe(loader.current)

效果是什么?

当你把 IntersectionObserver 应用于无限滚动时,整个过程非常优雅,几乎不需要你操心滚动逻辑。

假设页面底部有一个哨兵元素 loader

<div ref={loader}>加载中...</div>

然后用 IO 观察它:

const observer = new IntersectionObserver(([entry]) => {
  if (entry.isIntersecting) {
    fetchMore() // 请求下一页数据
  }
})

observer.observe(loader.current)

发生了什么?

  1. 用户滚动页面

    • 浏览器实时监控 loader 元素的位置
  2. loader 元素进入视口

    • IO 回调触发
    • entry.isIntersectingtrue
  3. 自动加载下一页数据

    • 执行 fetchMore()
    • 新数据追加到列表
    • 页面滚动感知和数据加载完全分离

为什么说“没有 scroll、没有计算、没有节流”?

  • 没有 scroll

    • 不用监听 window.onscrolldiv.scroll
    • 避免频繁回调和复杂计算
  • 没有计算

    • 不用自己算 scrollTop + clientHeight >= scrollHeight
    • 不用手动判断用户是否到底
  • 没有节流

    • IO 回调是浏览器调度的
    • 自动防抖,性能天然好

📌 整个体验是:

  • 用户滚动流畅
  • 页面性能高
  • 数据加载和 DOM 渲染完全解耦

IO 就像一个“智能保安”:
只在元素真正进入视口时才通知你,而不是你自己跑去盯着它。

优雅得像个大厂代码。


六、图片懒加载(别再直接 src 了)

很多人对懒加载有误解:

<img src={url} />

然后说:

“我这是懒加载。”

不,你这是:

浏览器一口气全加载。


正确姿势:data-src + IO

<img data-src={url} ref={imgRef} />

当图片进入视口时:

const oImg = new Image()
oImg.src = img.dataset.src

oImg.onload = () => {
  img.src = img.dataset.src
}

为什么要 new Image()

这是关键!

在很多初学者或简单 Demo 里,你可能直接写:

img.src = img.dataset.src

浏览器就开始加载图片,同时尝试渲染。
问题来了:

  • 图片还没下载完 → 浏览器显示空白
  • 图片下载到一半 → 页面可能闪烁或显示半张图
  • 瀑布流布局依赖高度 → 半加载状态可能导致 页面抖动

所以我们引入 new Image()

const oImg = new Image()
oImg.src = img.dataset.src

oImg.onload = () => {
  img.src = img.dataset.src
}

原理拆解:

  1. 先在内存中创建图片对象 (oImg)

    • 浏览器不会把它直接渲染到 DOM
    • 只是偷偷下载图片
  2. 等图片完全加载完成

    • oImg.onload 回调触发
    • 说明图片完整可用
  3. 一次性替换真实 DOM 的 img.src

    • 页面看到的是完整图片
    • 避免白屏、闪烁、半张图
    • 瀑布流布局稳定,不会抖动

📌 这叫 工程级懒加载

📌 不是 Demo 级的“直接写 src 就懒加载”,也不是“看不见就不加载”的伪懒加载

它的好处在于:

  • 用户体验佳:页面不会闪烁
  • 布局稳定:瀑布流高度不会错乱
  • 可控性强:可以加加载动画、骨架屏、错误处理等扩展