做性能优化那会儿最怕什么?东西确实懒加载了,但图片或组件卡在视口外老半天,用户滑过去只能对着空白干瞪眼。好不容易等到它们蹦出来,又突然抖一下,像从后台被人推上台似的。传统 lazy 说白了就管一件事:什么时候开始下载资源。至于“什么时候真的该渲染”,它根本不关心。更麻烦的是,不同容器宽度下你也许想换一套布局或动效——这活儿 lazy 也接不了。
页面首屏往往不会把所有卡片都一口气渲染。可如果继续用 scroll 事件去猜每个元素的可见比例,脚本会频繁执行,帧率跟着掉。IntersectionObserver 的出现把这件事从“轮询”变成“订阅”。浏览器会在元素跨越阈值时批量通知,主线程压力立刻降下来。配合 Vue 3 的 defineAsyncComponent,只有真正快进视口的组件才会被导入和挂载,首包体积肉眼可见地缩小。
const LazyCard = defineAsyncComponent({
loader: () => import('./components/ExpensiveCard.vue'),
loadingDelay: 200,
suspensible: false
})
然而,仅靠可见性还不够。很多时候我们希望组件在进入视口的那一刻就顺滑地“到位”,而不是生硬地出现。这就需要它知道自己所在的容器到底有多宽,从而决定采用哪种动画曲线、是否折叠某些区块、甚至切换完全不同的模板结构。过去我们只能依赖媒体查询对着全局视口做判断。如今 container queries 允许组件在自己的父级容器内建立 size 上下文,于是同一套组件可以在侧边栏、模态框、抽屉面板里各自呈现合适的形态。
想象这样一个列表:每张卡片外面套一层带有 的 div,内部样式通过 @container (max-width: 480px) 决定间距和字体大小。与此同时,卡片自身注册 IntersectionObserver,观察自己何时跨过 20% 视口高度的门槛。一旦满足条件,立即触发异步组件的加载,同时给根节点加上 visible 类,启动 CSS 过渡。这样一来,加载时机和入场动画都被精确约束在“用户即将看到”的那几十毫秒内,既不会过早占用带宽,也不会让用户等待空白。
项目里装好 vue@3.4 和 vite,新建一个组件负责包裹目标内容并监听交叉状态。一个叫 的组合式函数返回 isVisible 与 entry,前者用来控制 v-if,后者可以上报埋点。父容器设置 并在样式里声明 @container card (max-width: 600px) 对应的规则。最终效果是:窄屏时卡片收起阴影并放大头像,宽屏时恢复完整动效。无论哪种情况,只要用户还没滚动到,这个组件就不会被真正渲染,自然也不存在占位抖动。
两个 API 搭台唱戏,别让它们各玩各的
IntersectionObserver 这东西,2016 年就进 Chrome 了。但大多数开发者对它的理解还停留在“检测元素是否在视口里”这个层面。事实上它的回调里带的那张 IntersectionObserverEntry 表,能读出 、、甚至元素相对于根节点的滚动偏移量。你完全可以用它判断“元素在屏幕里暴露了 30% 还是 80%”,从而决定是预加载资源还是直接渲染。
在 Vue 3 里封装一个 组合式函数,核心代码少得让人意外。
import { ref, onMounted, onUnmounted } from 'vue'
export function useIntersectionObserver(
targetRef,
options = { threshold: [0.1, 0.5, 1.0] }
) {
const isVisible = ref(false)
const entry = ref(null)
let observer = null
onMounted(() => {
if (!targetRef.value) return
observer = new IntersectionObserver(([e]) => {
isVisible.value = e.isIntersecting
entry.value = e
}, {
root: options.root || null,
rootMargin: options.rootMargin || '0px',
threshold: options.threshold
})
observer.observe(targetRef.value)
})
onUnmounted(() => {
if (observer) observer.disconnect()
})
return { isVisible, entry }
}
拿到 isVisible 后,你可以把它绑到 v-if 上,也可以塞进 defineAsyncComponent 的加载逻辑里。注意那个 threshold 数组。如果只给一个 [0],元素刚碰到视口边缘就触发。给 [0.5] 则必须露出 50% 才激活。懒加载场景下,建议用 [0, 0.25],让组件在即将进入视口前就开始加载,避免用户滚动到位置时还在转圈。
但光是监听可见性还不够。卡片组件往往不知道自己被塞进了多宽的容器——侧边栏 280px,中间内容区 960px,抽屉面板可能只有 320px。过去我们只能靠媒体查询猜,或者用 ResizeObserver 把容器尺寸存到 Vuex 里,再手动传给每个子组件。又啰嗦,又容易产生竞态。
CSS 容器查询(@container)把这个逻辑直接搬到了样式层。你在父元素上声明 ,子元素的样式就能基于容器的内联尺寸做响应。
<div class="card-wrapper" style="container-type: inline-size; container-name: card">
<div class="card">
<!-- 卡片内容 -->
</div>
</div>
@container card (max-width: 480px) {
.card {
padding: 8px;
font-size: 14px;
}
}
@container card (min-width: 800px) {
.card {
padding: 24px;
font-size: 18px;
}
}
2026 年的浏览器支持已经没什么好担心的。Chrome 105+、Safari 16+、Firefox 110+ 全都原生支持。inline-size 只检测容器的宽度,block-size 检测高度。懒加载场景几乎只用 inline-size,因为高度通常由内容撑开,你没法提前知道。
Polyfill 方案的话, 包还能用。但现在新项目完全没必要上 polyfill。除非你要兼容 iOS 15 以下的设备——那你也别用容器查询了,直接上 ResizeObserver + getBoundingClientRect 硬写吧。
把这两样东西拼在一起,思路就清晰了。
- 用 IntersectionObserver 控制组件的“出生”时刻
- 用
给组件一个自我感知的宽度上下文 - 在
@container里定义不同尺寸下的动画曲线、间距、甚至是否显示某些子模块
这样一来,一个卡片组件在列表里、在轮播图里、在弹窗里,都能自动适配形态,且只在用户即将看到时才渲染。不浪费一个字节,也不让用户多等一帧。
真正决定性能的是“什么时候让它出生”
光有容器查询还不够。组件本身还在 DOM 里躺着,尺寸计算得再准也白搭。
我最早踩过的坑是把懒加载做成了“先渲染再隐藏”。Vue 的 v-show 只是切换 display,组件照样 initialised,请求照样发。用户滚动到附近时页面已经卡过一轮了——因为你把所有不可见组件的 API 都调了。
正确的做法是 v-if 配合 isVisible 这个响应式变量,IntersectionObserver 一触发才把组件塞进 DOM。
<template>
<div ref="triggerRef" class="lazy-wrapper">
<component
:is="componentType"
v-if="isVisible"
:container-width="containerWidth"
/>
<div v-else class="skeleton-placeholder"></div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
const triggerRef = ref(null)
const isVisible = ref(false)
const componentType = ref(null)
let observer = null
onMounted(() => {
observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
isVisible.value = true
fetchData()
observer.unobserve(entry.target)
}
},
{ rootMargin: '200px' }
)
observer.observe(triggerRef.value)
})
function fetchData() {
componentType.value = 'ActualComponent'
}
onUnmounted(() => {
if (observer) observer.disconnect()
})
</script>
rootMargin: '200px' 是经验值。视口上下各提前 200px 触发加载,用户滚动到那里时组件已经 ready 了。别设太大——提前一个屏幕以上不如直接首屏渲染。也别设 0,用户滚到时才看到骨架屏闪一下,体验不好。
组件渲染了,数据还在路上怎么办?Vue 3 的 <Suspense> 正好解决这个。
<template>
<Suspense>
<template #default>
<ActualComponent :data="asyncData" />
</template>
<template #fallback>
<div class="skeleton" :style="skeletonStyle">
<div class="skeleton-line"></div>
<div class="skeleton-line short"></div>
</div>
</template>
</Suspense>
</template>
注意 ActualComponent 必须是个异步组件(defineAsyncComponent)或者在 setup 里返回 Promise。Suspense 才会等它 resolve 再切换掉 fallback。
这里有个坑:Suspense 只对异步依赖做一次等待。如果你组件内部又 watch 了某个异步数据源二次触发 loading,Suspense 不会管第二次。所以最好把数据预取和组件异步化绑在一起——组件加载的同时发请求,请求回来组件刚好渲染完。
骨架屏尺寸猜错了会导致布局偏移(CLS)。以前的做法是写死一个宽高比,结果容器变成 300px 时骨架屏还撑 600px 的样式,闪一下再调整,用户眼都花了。
.lazy-wrapper {
container-type: inline-size;
container-name: skeleton-container;
}
@container skeleton-container (max-width: 360px) {
.skeleton {
height: 120px;
padding: 8px;
}
.skeleton-line {
height: 12px;
margin-bottom: 8px;
}
}
@container skeleton-container (min-width: 720px) {
.skeleton {
height: 240px;
padding: 24px;
}
.skeleton-line {
height: 20px;
margin-bottom: 16px;
}
}
骨架屏的占位尺寸跟最终组件渲染后的尺寸保持一致。容器查询直接在样式层帮你算好了,不用 JS 算,也不用 ResizeObserver 回调里写一堆 updateLayout 的逻辑。
数据预取和渲染分离得好,用户看到的永远只有两帧:骨架屏 → 完整组件。中间没有“组件先出来,数据后到,布局跳一下”的尴尬时刻。
最后提醒一句:别在同一个页面上堆几百个 IntersectionObserver 实例。用单个 observer 监听所有 trigger,或者用 vue-use 的 统一管理。V8 再快也架不住你 new 两三百个 observer 对象。
入场动效:容器宽度决定你怎么出来
IntersectionObserver 告诉我“你该出来了”。但出来的时候怎么出来?直接暴力的 v-if + opacity: 1 太生硬。用户滚动到那个位置,组件“啪”一下怼在脸上,跟贴小广告似的。这体验说不上差,但肯定不算好。
Vue 的 <Transition> 可以接住入场动画。但这还不够——因为我想要的不只是一个统一的淡入,而是根据容器实际宽度做不同的入场效果。比如左侧栏里的卡片,宽度只有 320px,滑入式动画反而显得挤。主内容区宽度 800px 的卡片,从底部滑入带透明度的渐变,视觉上就很舒服。
这就是容器查询(@container)和动画结合的地方了。
先给包裹组件的外层容器加上 。然后直接在 @container 条件里定义关键帧。
@container (min-width: 400px) {
@keyframes slideInFromBottom {
from {
transform: translateY(40px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
}
@container (max-width: 399px) {
@keyframes fadeInOnly {
from { opacity: 0; }
to { opacity: 1; }
}
}
然后在 <Transition> 的类名里引用对应的动画。Vue 的 enter-active 和 enter-from 分别对应动画播放阶段和初始状态。
.lazy-fade-enter-active {
animation: slideInFromBottom 0.4s ease-out;
}
@container (max-width: 399px) {
.lazy-fade-enter-active {
animation: fadeInOnly 0.3s ease-in;
}
}
注意优先级。容器查询的样式会被嵌套在 @container 块里,同名的 animation 属性不会因为权重问题被覆盖掉。CSS 引擎会按容器查询匹配的结果来决定用哪一套关键帧。
我一开始以为得用 JS 算宽度再动态绑定类名。后来发现完全多此一举。容器查询直接在样式层帮你做了分支判断,动画逻辑跟宽高绑在一起,干净。
动画卡不卡,关键在触发没触发重排。你用 top、left、margin 做位移,浏览器每帧都要重新计算布局树,CPU 直接拉满。换成 transform 和 opacity,浏览器走合成线程,GPU 帮忙渲染,掉帧概率小得多。
所以上面那段关键帧里全是 transform 和 opacity,没有 top 也没有 height。
再加一句 will-change 告诉浏览器提前准备合成层。不过别滥用——整个页面几百个元素全加上 will-change,内存先炸。只在即将进入视口的懒加载元素上用,或者跟 IntersectionObserver 配合,在触发加载的同时加上 will-change。
const triggerRef = ref(null)
const observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) {
triggerRef.value.style.willChange = 'transform, opacity'
showComponent.value = true
observer.unobserve(entry.target)
}
})
onMounted(() => {
observer.observe(triggerRef.value)
})
等组件完全渲染完动画,再把 will-change 去掉,避免一直占着合成层浪费资源。
这步优化在大多数设备上提升不明显。但你要是碰上低端机或者页面卡片特别多,差距一下就能感知到——一个 60fps 一个 15fps,用户手一滑就能感觉出来。
有个小细节容易翻车:<Transition> 的 enter-from 样式只在元素插入 DOM 的第一帧生效。如果你把动画关键帧写在 @container 里,但 enter-active 类名对应的动画没有在容器查询块里重新声明,Vue 会直接报错说找不到动画名。
解决方案是保持类名一致,只在 @container 块里覆盖 animation 属性值。CSS 的层叠机制会帮你选对的那一套。
最后给个完整组件的写法示意。
<template>
<div class="lazy-wrapper" ref="containerRef">
<Transition name="lazy-fade">
<ActualComponent v-if="show" />
</Transition>
</div>
</template>
配合上面的 CSS,组件在可见且容器宽度大于 400px 时会从底部滑入并淡出。小于 400px 只淡入。不用 JS 判断宽高,不用监听 resize,全在样式层搞定。
动画这件事,能用 CSS 解决的别碰 JS,能用 GPU 的别碰 CPU。容器查询让“根据容器大小做不同动效”这件事终于不用写一堆 ResizeObserver 回调了。省下来的代码量算是个意外收获吧。
实战验证:图片画廊和无限滚动列表
理论说再多,不如直接上手干一个。拿图片画廊和无限滚动列表这两个场景来验证一下,看看容器查询和 IntersectionObserver 到底能省多少事。
先说图片画廊。常见的做法是缩略图网格,点开弹大图。但我想做的是——卡片本身自适应:容器宽的时候展示高清大图加描述,容器窄的时候自动切成缩略图+标题,动画还不掉帧。
每个图片卡片用 包裹。卡片内部的结构不变,全靠 @container (min-width: 400px) 切换布局。
/* 图片卡片容器 */
.photo-card {
container-type: inline-size;
container-name: photo;
width: 100%;
}
/* 窄容器:缩略图模式 */
@container photo (max-width: 399px) {
.photo-card__image {
height: 120px;
object-fit: cover;
}
.photo-card__caption {
font-size: 0.875rem;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
}
/* 宽容器:全尺寸展示 */
@container photo (min-width: 400px) {
.photo-card__image {
height: 320px;
object-fit: contain;
}
.photo-card__caption {
font-size: 1rem;
line-height: 1.6;
}
.photo-card__meta {
display: block;
}
}
懒加载部分用 IntersectionObserver 控制。卡片进入视口 200px 之前就开始加载高清图,而不是等用户滚到眼前才触发。
// 图片懒加载 composable
export function useLazyImage(src) {
const imageRef = ref(null)
const loaded = ref(false)
const highResSrc = ref('')
const loadHighRes = () => {
if (loaded.value) return
const img = new Image()
img.onload = () => {
highResSrc.value = src
loaded.value = true
}
img.src = src
}
onMounted(() => {
if (!imageRef.value) return
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
loadHighRes()
observer.unobserve(entry.target)
}
},
{ rootMargin: '200px' }
)
observer.observe(imageRef.value)
})
return { imageRef, highResSrc, loaded }
}
对于无限滚动列表,提前加载的边界得自己掐——不能真等到用户滚到底部再触发,那一下空白闪烁谁都受不了。常见解法是在列表末尾塞一个占位元素,拿 IntersectionObserver 盯着它,只要它进入视口外大约 300px 的范围,就提前把下一页数据拉回来。
用容器查询处理加载时的骨架屏尺寸。不同容器宽度下骨架屏的高度和行数不一样。以前得写好几套模板或者用 JS 算。现在 @container 一套搞定,样式层自动适配。
这两个场景跑下来,最直观的感受是:以前用 JS 硬算容器宽度再分发到各个子组件的那套逻辑,现在全扔给 CSS 了。代码量少了一半不止,而且不会再出现因为 JS 执行时机问题导致的“闪一下才调整”的尴尬。IntersectionObserver 控制“生”,容器查询控制“长”,分工明确,谁也不抢谁的活。
评论