大屏项目的图片和 WebM 预加载:如何避免首帧黑屏和动画卡顿
普通 H5 页面资源没加载出来,最多是图片慢一点。互动大屏不一样,它通常投在现场大屏上,用户第一眼看到的就是画面。如果开场动画黑一下、背景视频闪一下、霸屏素材卡一下,体验会非常明显。
我以前做的大屏项目里有大量 webp、png、webm 素材。刚开始只是做一个 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 面板录制,观察首次动画触发时是否有明显长任务。
小结
大屏资源预加载的重点,不是做一个好看的进度条。
真正要设计的是资源优先级、失败降级、统一资源表和首帧稳定。这样才能保证页面不是“加载完了”,而是“现场关键时刻不会掉链子”。

浙公网安备 33010602011771号