我: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)
乍一看好像也挺像瀑布流,页面上是两列或三列图片整齐排列。
但是,这种方法问题很大:
-
它完全不考虑图片实际高度。
- 高一点的图片就会撑高列
- 低一点的图片就会让另一列短得像“人生低谷”
-
列高度可能严重不平衡
- 一列“高耸入云”,另一列矮小
- 用户滚动时,会觉得页面布局奇怪甚至抖动
-
严重依赖渲染顺序
- 如果图片异步加载(比如懒加载),高度变化还会导致 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
})
执行流程解析:
-
维护
heights数组heights[i]表示第 i 列的当前总高度- 每次添加图片前都能快速知道哪一列最短
-
动态选择最短列
Math.min(...heights)找到最小高度indexOf返回列索引
-
更新列数据
- 把图片 push 进去
- 更新
heights,保证下一张图片也能找到最短列
-
效果
- 每列高度尽量接近
- 页面布局自然
- 瀑布流不会抖
📌 为什么这比 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)
发生了什么?
-
用户滚动页面
- 浏览器实时监控
loader元素的位置
- 浏览器实时监控
-
loader 元素进入视口
- IO 回调触发
entry.isIntersecting为true
-
自动加载下一页数据
- 执行
fetchMore() - 新数据追加到列表
- 页面滚动感知和数据加载完全分离
- 执行
为什么说“没有 scroll、没有计算、没有节流”?
-
❌ 没有 scroll
- 不用监听
window.onscroll或div.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
}
原理拆解:
-
先在内存中创建图片对象 (
oImg)- 浏览器不会把它直接渲染到 DOM
- 只是偷偷下载图片
-
等图片完全加载完成
oImg.onload回调触发- 说明图片完整可用
-
一次性替换真实 DOM 的
img.src- 页面看到的是完整图片
- 避免白屏、闪烁、半张图
- 瀑布流布局稳定,不会抖动
📌 这叫 工程级懒加载
📌 不是 Demo 级的“直接写 src 就懒加载”,也不是“看不见就不加载”的伪懒加载
它的好处在于:
- 用户体验佳:页面不会闪烁
- 布局稳定:瀑布流高度不会错乱
- 可控性强:可以加加载动画、骨架屏、错误处理等扩展