大屏项目的图片和 WebM 预加载:如何避免首帧黑屏和动画卡顿

普通 H5 页面资源没加载出来,最多是图片慢一点。互动大屏不一样,它通常投在现场大屏上,用户第一眼看到的就是画面。如果开场动画黑一下、背景视频闪一下、霸屏素材卡一下,体验会非常明显。

我以前做的大屏项目里有大量 webppngwebm 素材。刚开始只是做一个 loading 进度条,后来发现真正要解决的不是 loading,而是资源分级、首帧稳定和失败降级。

问题背景

大屏页面里常见资源包括:

  • 背景视频。
  • 开场动画。
  • 排行榜装饰图。
  • 霸屏主题素材。
  • 点歌模块素材。
  • 游戏开始、成功、失败、结束动画。
  • 二维码和按钮图标。

这些资源体积不小,而且经常来自 CDN。现场网络一旦不稳定,就容易出现:

  • 首屏进入后背景视频第一帧黑屏。
  • 开始游戏时动画临时加载,卡顿明显。
  • 霸屏动画素材没加载完,组件先显示空白。
  • 预加载资源太多,loading 时间过长。
  • 某个资源失败后,整个 loading 一直不结束。

所以预加载不是“把所有 url 丢进去”这么简单。

初始方案

最自然的做法是维护一个资源数组:

const loader = preloader({
  xhrImages: false
})

loader.on('progress', progress => {
  this.progress = Math.round(progress * 100)
})

loader.on('complete', () => {
  this.$emit('loaded')
})

urls.forEach(url => loader.add(url))
loader.load()

这个方案能解决一部分问题,但有几个隐患:

  • 所有资源优先级一样。
  • 某些非首屏资源拖慢首屏。
  • 失败资源没有明确降级策略。
  • 加载后的资源映射分散,后续组件还要自己拼 url。
  • 无法区分“必须加载”和“可以后加载”。

大屏项目更适合分阶段加载。

分级思路

我会把资源分成三类:

critical:首屏必须资源,比如背景、logo、基础按钮。
interactive:用户操作后高概率马上用到,比如开场动画、游戏动画。
lazy:低频主题素材,比如部分霸屏主题、节日素材。

加载策略:

  • critical 成功后才进入主页面。
  • interactive 可以在主页面出现后继续预热。
  • lazy 等空闲时间或首次使用前加载。
  • 单个资源失败不能卡死整个页面。
  • 加载成功的资源写入统一资源表。

资源表

不要让组件到处写死 CDN 地址。可以把加载后的资源按 key 存起来:

const assets = {
  background: 'https://cdn.example.com/background.webm',
  start: 'https://cdn.example.com/start.webm',
  success: 'https://cdn.example.com/success.webm'
}

组件只拿 key:

<video :src="assets.background || fallback.background" autoplay muted loop />

这样以后资源迁移、CDN 改域名、某个素材换版本,影响面会小很多。

核心实现

下面是一个不依赖具体库的加载器示例:

function loadAsset(asset, timeout = 12000) {
  return new Promise(resolve => {
    const done = result => resolve(result)
    const timer = setTimeout(() => {
      done({
        key: asset.key,
        url: asset.url,
        ok: false,
        reason: 'timeout'
      })
    }, timeout)

    if (asset.type === 'video') {
      const video = document.createElement('video')
      video.preload = 'auto'
      video.muted = true
      video.src = asset.url

      video.oncanplaythrough = () => {
        clearTimeout(timer)
        done({
          key: asset.key,
          url: asset.url,
          ok: true,
          element: video
        })
      }

      video.onerror = () => {
        clearTimeout(timer)
        done({
          key: asset.key,
          url: asset.url,
          ok: false,
          reason: 'error'
        })
      }

      video.load()
      return
    }

    const image = new Image()
    image.onload = () => {
      clearTimeout(timer)
      done({
        key: asset.key,
        url: asset.url,
        ok: true,
        element: image
      })
    }
    image.onerror = () => {
      clearTimeout(timer)
      done({
        key: asset.key,
        url: asset.url,
        ok: false,
        reason: 'error'
      })
    }
    image.src = asset.url
  })
}

再加一个并发控制:

async function loadWithConcurrency(list, limit, onProgress) {
  const result = {}
  let index = 0
  let finished = 0

  async function worker() {
    while (index < list.length) {
      const asset = list[index++]
      const item = await loadAsset(asset)
      finished += 1

      result[item.key] = item
      onProgress && onProgress(finished / list.length, item)
    }
  }

  await Promise.all(
    Array.from({ length: Math.min(limit, list.length) }, worker)
  )

  return result
}

页面加载时:

async mounted() {
  const criticalResult = await loadWithConcurrency(criticalAssets, 4, progress => {
    this.progress = Math.round(progress * 100)
  })

  this.commitAssets(criticalResult)
  this.showMainScreen = true

  requestIdleCallback(() => {
    loadWithConcurrency(interactiveAssets, 3, null).then(this.commitAssets)
  })
}

如果要兼容没有 requestIdleCallback 的浏览器,可以退化成 setTimeout

首帧问题

视频资源加载完成不代表首帧一定稳定。大屏项目里,我会做一个小处理:

async function prepareVideoFirstFrame(video) {
  video.currentTime = 0

  try {
    await video.play()
    video.pause()
    video.currentTime = 0
  } catch (error) {
    // 静音视频大多数浏览器允许自动播放,失败时交给页面兜底图
  }
}

另外要准备一张兜底图:

<div class="screen-bg">
  <img v-if="!assets.background.ok" :src="fallbackImage" />
  <video v-else :src="assets.background.url" autoplay muted loop />
</div>

不要让失败资源把屏幕变成黑色。

方案权衡

全量预加载最省心,但 loading 会变长,用户可能等很久。

完全懒加载进入快,但第一次触发动画容易卡。

分级预加载是折中方案:首屏必须稳定,互动高频素材提前预热,低频素材延后。这个方案更适合活动现场,因为现场最怕的是关键节点卡顿,而不是所有素材都提前加载完。

调试方法

可以这样测:

1. DevTools Network 切 Slow 3G,观察首屏是否有黑屏。
2. 禁用缓存刷新,记录 critical 加载耗时。
3. 故意让一个 lazy 资源 404,确认不影响主页面进入。
4. 开始游戏前观察对应 WebM 是否已经在内存或缓存里。
5. 连续切换背景和动画,观察是否出现重新请求。
6. Performance 面板录制,观察首次动画触发时是否有明显长任务。

小结

大屏资源预加载的重点,不是做一个好看的进度条。

真正要设计的是资源优先级、失败降级、统一资源表和首帧稳定。这样才能保证页面不是“加载完了”,而是“现场关键时刻不会掉链子”。

posted @ 2026-06-25 13:39  万物皆object  阅读(5)  评论(0)    收藏  举报