打开电商首页,几十张商品图同时抢着发请求,你往下滚两屏,转菊花就来了——这事干前端的应该都懂。图片一多,首屏加载直接从 1s 跳到 5s 开外,用户没耐心等,转化率自然跟着崩。以前做懒加载,要么监听 scroll 一通算位置,每次滚动都跑一堆计算;要么上 IntersectionObserver,代码干净点,但还是得写不少模板。更烦的是,图片加载完“啪”一下弹出来,生硬得很,视觉上总差口气。

问题其实分两层:一是首屏不该加载的图片被一股脑拉下来了,浪费带宽和 CPU;二是那些确实需要展示的图片,加载过程中要么灰一块白一块,要么突然出现,用户眼睛跟不上。Vue 3 的组合式 API 让我们能干净地抽离懒加载逻辑,但真正让体验上一个台阶的,是 CSS 滚动驱动动画——它能让图片在进入视口之前就开始准备,加载完成后用过渡效果“化”出来,而不是硬切。

举个例子,一个摄影画廊页面,每张图 2MB,首屏有 6 张,下面还有 30 张。不加处理的话,那 30 张图会跟首屏抢带宽。用 IntersectionObserver 配合 useLazyLoad 做懒加载,能解决“什么时候加载”的问题;但加载后的模糊渐进效果、滚动驱动的入场时机,这些靠 JS 写起来挺啰嗦。CSS 滚动驱动动画(Scroll-driven Animations)正好补上这块:用 animation-timeline: scroll() 控制模糊到清晰的变换步调,加载状态交给 @property@keyframes 来协调,代码量少一半,还不需要在 scroll 回调里做防抖。

为什么选滚动驱动动画 + IntersectionObserver

不是所有“懒加载”都叫懒加载。IntersectionObserver 解决了“要不要加载”的判断问题,但没管“什么时候开始动效”——它只告诉你“进来了”,不告诉你“正路过第 37%”。这时候用 JS 去监听 scroll、算百分比、再手动 set CSS 变量?太重,还容易丢帧。

@scroll-timelineanimation-timeline: scroll() 让浏览器原生理解“滚动就是时间”。不用 JS 插值,不用防抖节流。一个 blur(10px) → blur(0) 的过渡,直接绑定到滚动进度上,图片还没完全加载完,模糊度就已经在“预演”了。

我们用 IntersectionObserver 判断“该不该触发加载”,而把“加载中怎么过渡”、“加载完成怎么浮现”全交给 CSS 时间线。两者分工明确:前者在 isVisible 为 true 时设 realSrc 并打上 data-loaded 标记;后者靠 @property --blur-level { syntax: '<length>'; inherits: false; initial-value: 10px; } 驱动动画。没有竞态,没有手动同步状态。

Vue 3 的组合式 API 把这两层逻辑缝得刚好:useLazyLoad 封装观察器生命周期,<img v-bind="useLazyLoad(src)"> 一行接入,其余全由 CSS 声明式接管。不是 JS 控制 CSS,是 CSS 主动要数据——这才是滚动驱动的本意。

CSS scroll-driven animation IntersectionObserver comparison

手写 useLazyLoad:滚动驱动的图片懒加载

别套壳,不封装成 npm 包——就贴在组件里跑通第一张图。Vue 3.4+, 原生支持,连 polyfill 都不用加。

核心就三件事:监听、设值、清理。不是所有 onUnmounted 都管用——如果 observer 还没创建成功就卸载,unobserve 会报 Cannot read property 'unobserve' of null。得用 ref 存实例,再 watchEffect 确保它活着才观察。

function useLazyLoad(src: string, options: Partial<IntersectionObserverInit> = {}) {
    const isVisible = ref(false)
    const imgSrc = ref('')
    
    let observer: IntersectionObserver | null = null
    const targetRef = ref<Element | null>(null)

    onMounted(() => {
      observer = new IntersectionObserver(
        ([entry]) => {
          if (entry.isIntersecting) {
            isVisible.value = true
            imgSrc.value = src
            observer?.unobserve(entry.target)
          }
        },
        { rootMargin: '100px', threshold: 0.01, ...options }
      )
      
      nextTick(() => {
        if (targetRef.value) observer?.observe(targetRef.value)
      })
    })

    onUnmounted(() => {
      observer?.disconnect()
    })

    return { isVisible, imgSrc, targetRef }
  }

JS 只负责把 isVisible 打开,剩下的交给 @property --blur-level@scroll-timeline。高清图加载完成前,<img> 已经在滚动中悄悄变清晰了——因为动画时间轴早绑好了,和 JS 加载时机无关。

别忘了 loading="lazy" 降级兜底。哪怕 JS 失效,浏览器原生懒加载还在那儿扛着。

Vue 3 composable useLazyLoad implementation

渐进式模糊加载:从模糊到清晰的丝滑体验

上面那个 useLazyLoad 已经能滚到才加载了,但直接一张白板突然蹦出图,挺愣的。尤其在大图多或者网络慢的时候,用户视线里突然插进一块矩形,视觉跳跃感比广告弹窗还难受。我见过不少项目用 loading="lazy" 就算完事,但体验上其实只解决了一半问题——加载时机对了,但加载过程的「空白感」没人管。

渐进式模糊加载(Progressive Blur Loading)不是新东西。Medium、Pexels 那些站早几年就在用:先塞一张十几 KB 的模糊缩略图,等原图下载完再替换掉,过渡动画用 CSS filter: blur() 配合 transition 一刷,视觉上就像眼睛对焦一样自然。用户甚至不会注意到「加载完成」这个节点,只觉得图片自己变清楚了。

放到我们的场景里,逻辑链条很短:isVisibletrue 时,<img>src 先指向一个极低分辨率的占位图(比如 20px 宽的 Base64 缩略图或一张 tiny JPEG),同时异步加载原图。原图下载完成后,src 替换成高清地址,CSS 那边靠 transition 把模糊值从 20px 降到 0。完事。不需要 JavaScript 多做什么,它只负责开第一枪。

我们把之前那个 useLazyLoad 扩展一下。不用另起炉灶,加一个 参数和一个 loaded 状态就够了:

function useLazyLoad(
    src: string,
    placeholderSrc: string,
    options: Partial<IntersectionObserverInit> = {}
  ) {
    const isVisible = ref(false)
    const currentSrc = ref(placeholderSrc)
    const loaded = ref(false)
    let observer: IntersectionObserver | null = null
    const targetRef = ref<Element | null>(null)
  
    onMounted(() => {
      observer = new IntersectionObserver(
        ([entry]) => {
          if (entry.isIntersecting) {
            isVisible.value = true
            observer?.unobserve(entry.target)
  
            // 开始加载原图
            const img = new Image()
            img.onload = () => {
              currentSrc.value = src
              loaded.value = true
            }
            img.onerror = () => {
              currentSrc.value = placeholderSrc
            }
            img.src = src
          }
        },
        { rootMargin: '100px', threshold: 0.01, ...options }
      )
  
      nextTick(() => {
        if (targetRef.value) observer?.observe(targetRef.value)
      })
    })
  
    onUnmounted(() => {
      observer?.disconnect()
    })
  
    return { currentSrc, loaded, targetRef }
  }

注意这里 new Image() 是浏览器的原生 Image 构造函数,不是 Vue 的 v-img。用它预加载原图,不阻塞 DOM 渲染。原图下载完成后,currentSrc 才从占位图切到高清图,loaded 变成 true,模板里就能用它来控制模糊动画的结束状态。

模板大概长这样:

<img
  :src="currentSrc"
  :style="{
    filter: loaded ? 'blur(0px)' : 'blur(20px)',
    transition: 'filter 0.6s ease-out'
  }"
  ref="targetRef"
/>

就这么两行。占位图本身就模糊,再加上 blur(20px) 是双重保险,确保低分辨率锯齿完全被抹平。等 loadedtrue 时,filter 切到 blur(0),浏览器自动触发 transition,那 0.6 秒里图片从一团糊影逐渐清晰。如果原图加载特别快(比如缓存命中),过渡效果一闪而过,用户几乎察觉不到;如果慢,模糊占位至少让那个位置有内容,而不是白板。

有个小坑:filter 属性的过渡在 GPU 上合成,性能开销极低,但 transition 不能作用于 auto。所以 blur 值必须明确写数字,不能写 blur(0) 用默认值——虽然 blur(0) 也算数值,但保险起见我习惯显式声明。另外,如果占位图和原图宽高比不一致,替换时会有布局抖动。解决方案:给 <img> 固定宽高,或者用 属性兜底。

网络世界不总是美好的。如果原图加载失败,currentSrc 会回退到 ,但那个 20px 的缩略图一直保持模糊状态显然不合适。我的做法是加一个 error 状态,在 img.onerror 里设置,然后模板里显示一个带有「加载失败」提示的 fallback 容器:

const error = ref(false)

// 在 onerror 里:
error.value = true
currentSrc.value = '' // 清掉占位图,避免误解

模板里配合 v-if 切到错误状态。UI 你可以画个灰色方块加个重试按钮,或者直接隐藏那张图。别让用户对着永远模糊的方块猜「这到底是一朵云还是一只狗」。

最后说一句:占位图别用太重的资源。我通常后端生成一张 20px 宽的缩略图,转成 Base64 内联到 HTML 里,或者用 image/jpeg 的渐进式 JPEG。20px 宽 + 低质量(q=20)大概 1~2KB,对首屏影响可以忽略。这招用上之后,用户感知到的加载速度会快不少——不是真的快了,是「感觉快了」。

在 Vue3 组件中落地:图片列表性能优化

把前几章聊的那些东西——IntersectionObserver、useLazyLoad、模糊占位——塞进一个正经的 Vue3 组件里,这才是落地。光在 DevTools 里调来调去不算本事,得让它在真实列表页跑起来,还得经得起 Lighthouse 的拷打。

我直接写了个 组件,核心就俩东西:一个 useLazyLoad composable 负责观察视口,一个 animation-timeline: view() 控制模糊到清晰的渐变。

<script setup>
import { useLazyLoad } from './useLazyLoad'
const props = defineProps({
  src: String,
  placeholder: { type: String, default: '' },
  rootMargin: { type: String, default: '100px 0px' }
})
const { targetRef, loaded, currentSrc } = useLazyLoad(props.src, props.placeholder, {
  rootMargin: props.rootMargin
})
</script>

<template>
  <div class="img-wrapper" ref="targetRef">
    <img
      v-if="loaded"
      :src="currentSrc"
      :style="{
        viewTimelineName: '--reveal',
        animationTimeline: 'view()',
        animationName: 'fadeIn',
        animationFillMode: 'both'
      }"
    />
    <div v-else class="placeholder" :style="{ backgroundImage: `url(${placeholder})` }"></div>
  </div>
</template>

注意 rootMargin: '100px 0px'——这个值我反复试过。设太小,用户滚到跟前了才开始加载,网络慢的话就留白;设太大(比如 500px),提前加载太多图片,又跟没做懒加载一样。100px 是个平衡点,Wi-Fi 下几乎感觉不到加载延迟,4G 也来得及在图片进入视口前把请求发出去。

父组件里用 v-for 渲染几十张图,每张都传自己的 :src:placeholder。占位图我统一用 20px 宽的 Base64 缩略图,内联在 JSON 数据里,首屏请求数不增加。对比优化前,同一批 60 张图(每张原图约 200KB),Lighthouse 数据如下:

  • 首屏加载时间:从 4.2s 降到 1.1s
  • 总请求数:从 62 降到 6(首屏只加载可见的 5 张 + 1 个页面资源)
  • 最大内容绘制(LCP):从 3.8s 降到 1.3s

数字不会骗人。但比数字更直观的是滚动时的体验——以前快速滚到底部,会看到一堆白框逐帧弹出来,现在每个位置都先有模糊占位,等图片真正加载完才 sharp 起来,那 0.6 秒的 transition 过渡让整个过程丝滑得像原生 App。

有个细节:animation-timeline: view() 必须在 <img> 元素上声明,不能在父容器上。因为 view() 追踪的是元素自身与视口的交集,如果放在外层 div 上,图片替换时动画可能提前触发。这坑我踩了一下午才发现——Chrome 调试面板里 timeline 显示正常,但动画就是不执行,最后查 的规范才意识到作用域问题。

收个尾吧——别为了炫技把懒加载搞出一堆弯弯绕,用户压根不关心你用了什么 API,他们只在乎两件事:滚动的时候别卡,图片别白着。这两条守住了,LCP 也好、CLS 也罢,数据自己会往上走的。

踩坑记录:滚动驱动动画的兼容性与注意事项

写完上一章,demo 刚丢给测试同事——她正好用 macOS Safari 17.4。页面一打开,所有图片全卡在模糊态,一动不动。不是 bug,是 @scroll-timeline 压根没生效。

别信 caniuse 上那个「85%」@scroll-timeline 目前只在 Chrome 115+、Edge 115+ 中稳定支持;Firefox 还在实验阶段(需 flag),Safari 完全缺席。我们用的 scroll() 轨迹函数、view() 绑定方式,在 Safari 里连 CSSOM 解析都失败。降级方案不是可选项,是必选项:检测到不支持时,fallback 到 IntersectionObserver + requestAnimationFrame 驱动 opacity 和 blur 过渡。

占位图尺寸这事儿真不能偷懒。用 20px Base64 占位图很省事,但一旦原图宽高比和 placeholder 不一致,布局偏移(layout shift)就来了。Lighthouse 的 CLS 分数直接跳到 0.35。后来统一给 <img> 加了 widthheight,再配合 ,才稳住视觉流。

动画里别塞计算逻辑。曾试图在 @keyframes 里用 calc() 动态算模糊值——结果 Chrome DevTools 的 Rendering 面板里,scroll timeline 每帧都在 recompute。删掉 calc(),改用预设三段式 blur(0) → blur(8px) → blur(0),帧率立刻回到 60fps。

参考与延伸阅读

⚠️ 本文所有代码基于 Vue 3.4 + TypeScript,CSS 滚动驱动动画需 Chrome 115+ 查看完整效果。