做性能优化那会儿最怕什么?东西确实懒加载了,但图片或组件卡在视口外老半天,用户滑过去只能对着空白干瞪眼。好不容易等到它们蹦出来,又突然抖一下,像从后台被人推上台似的。传统 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,新建一个组件负责包裹目标内容并监听交叉状态。一个叫 的组合式函数返回 isVisibleentry,前者用来控制 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 里定义不同尺寸下的动画曲线、间距、甚至是否显示某些子模块

这样一来,一个卡片组件在列表里、在轮播图里、在弹窗里,都能自动适配形态,且只在用户即将看到时才渲染。不浪费一个字节,也不让用户多等一帧。

Vue 3 component lazy loading Intersection Observer

真正决定性能的是“什么时候让它出生”

光有容器查询还不够。组件本身还在 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 对象。

Vue 3 dynamic component rendering with v-if and isVisible

入场动效:容器宽度决定你怎么出来

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 算宽度再动态绑定类名。后来发现完全多此一举。容器查询直接在样式层帮你做了分支判断,动画逻辑跟宽高绑在一起,干净。

动画卡不卡,关键在触发没触发重排。你用 topleftmargin 做位移,浏览器每帧都要重新计算布局树,CPU 直接拉满。换成 transformopacity,浏览器走合成线程,GPU 帮忙渲染,掉帧概率小得多。

所以上面那段关键帧里全是 transformopacity,没有 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 回调了。省下来的代码量算是个意外收获吧。

CSS container queries animation keyframes

实战验证:图片画廊和无限滚动列表

理论说再多,不如直接上手干一个。拿图片画廊和无限滚动列表这两个场景来验证一下,看看容器查询和 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 控制“生”,容器查询控制“长”,分工明确,谁也不抢谁的活。