⭐ 推荐:社区规则条款 V1.0

HTML 视频播放优化:preload / poster / 自适应码率

一只冷漠的狐狸
一只冷漠的狐狸 正式会员正式会员认证极客认证极客 钻石卡会员钻石卡会员
发布于 2026-10-10 12:31 ·3 浏览 ·3 回复

学完这篇,你能把网页里的视频从「一进页面就偷偷下载几十兆」改成「按需加载、首屏有封面、网速差自动降清晰度」——三个关键点分别是 preload、poster 和自适应码率(ABR)。

第一步:先决定 preload 用哪个值

preload 决定浏览器在你按下播放键之前,愿意先下载多少视频数据。三个常用值:

<!-- 列表页、一屏多个视频:一个字节都别提前下 -->
<video src="a.mp4" preload="none" poster="a-cover.jpg" controls></video>

<!-- 详情页(最常见):只拉文件头,拿到时长和首帧 -->
<video src="a.mp4" preload="metadata" poster="a-cover.jpg" controls></video>

<!-- 只有一个视频、且确定用户会看:可以激进一点 -->
<video src="a.mp4" preload="auto" poster="a-cover.jpg" controls></video>

选值的原则就一句话:页面上视频越多、位置越靠下,越要往 none 靠。一屏 10 个视频全写 auto,等于让用户白下载几百兆。

注意:preload 只是给浏览器的建议,不是强制命令,浏览器会结合网络状态和自己的策略决定听不听。所以别把「省流量」完全寄托在它身上,真正稳的做法是配合下面的懒加载。

第二步:poster 让首屏不黑屏

poster 就是视频还没播放时显示的那张图。没有它,视频区域通常是一块黑的。

<video
  src="a.mp4"
  poster="a-cover.jpg"
  preload="none"
  playsinline
  controls
  style="width:100%; aspect-ratio:16/9; object-fit:cover;">
</video>

几个实际要点:

  • 尺寸和视频比例一致(一般 16:9),并压到 30–80KB 的 WebP/JPG,否则封面图本身又变成新的性能问题。
  • 用 aspect-ratio 或 width/height 占位,否则视频加载出来时页面会跳一下(CLS),体验很差。
  • preload="none" + poster 是性价比最高的组合:既不给首屏加负担,用户也看不到黑块。

注意:只写 preload="none" 不写 poster,用户看到的是一块空黑区域,很容易以为页面坏了。另外别把大段说明文字塞进海报图里,手机上会被裁掉。

第三步:做懒加载,把 preload 落到实处

列表页里,视频离视口还很远时不该有任何请求。用 IntersectionObserver 靠近再赋值:

<video poster="a-cover.jpg" preload="none" playsinline controls data-src="a.mp4"></video>
<script>
const io = new IntersectionObserver((entries) => {
  entries.forEach(e => {
    if (!e.isIntersecting) return;
    const v = e.target;
    v.preload = 'metadata';      // 靠近了才允许拉元数据
    v.src = v.dataset.src;
    io.unobserve(v);
  });
}, { rootMargin: '200px' });
document.querySelectorAll('video[data-src]').forEach(v => io.observe(v));
</script>

第四步:自适应码率(ABR)怎么做

前面三步解决的是「不该下的别下」,这一步解决的是「该下的别下太大」。ABR 的基本流程是:把同一个视频转成 360P/720P/1080P 等多档,切片后写一份主播放列表,播放器根据实时带宽自动切档。

转码(以 720P 为例,其他档同样跑一遍):

ffmpeg -i input.mp4 -c:v libx264 -b:v 1200k -s 1280x720 \
  -c:a aac -b:a 128k -f hls -hls_time 6 -hls_playlist_type vod \
  -hls_segment_filename "720p_%03d.ts" 720p.m3u8

手写主列表 master.m3u8:

#EXTM3U
#EXT-X-STREAM-INF:BANDWIDTH=1500000,RESOLUTION=1280x720
720p.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=800000,RESOLUTION=854x480
480p.m3u8

页面播放(Safari 原生支持 HLS,其他浏览器用 hls.js):

<video id="v" controls poster="cover.jpg" preload="metadata" playsinline></video>
<script src="/js/hls.min.js"></script>
<script>
const v = document.getElementById('v'), url = '/hls/master.m3u8';
if (v.canPlayType('application/vnd.apple.mpegurl')) {
  v.src = url;
} else if (window.Hls?.isSupported()) {
  const hls = new Hls({ capLevelToPlayerSize: true });
  hls.loadSource(url);
  hls.attachMedia(v);
}
</script>

如果不想上 HLS 这套基础设施,低配替代方案是准备两三个 MP4,用 JS 粗判网络再选源。它做不到播放中平滑切换,但胜在零依赖、零转码成本,小站够用。

注意:HLS 的 .m3u8 和 .ts 分片要能被跨域请求,服务器需返回正确的 CORS 头,且 Nginx/Apache 要给 .m3u8 配置 application/vnd.apple.mpegurl。另外同一个 <video> 上不要同时存在 src 属性和 <source> 子标签,浏览器会只认 src。

第五步:别忘了 Range 请求和缓存

用户拖动进度条时,浏览器发的是带 Range 头的请求。如果服务器不支持 206 分片响应(比如用 PHP 一次性把整个文件吐出来),每次拖动都会从头下载,前面所有优化全废。Nginx、Apache 默认支持,自己写流式输出时要注意。

再把 .mp4/.ts/.jpg 封面设个长缓存(Cache-Control: max-age=31536000 + 文件名带 hash),二次访问就几乎零成本。

小结

  • preload 按位置分档:列表用 none,详情页用 metadata,auto 慎用。
  • poster 必配,比例对齐、控制在 80KB 内,并用 aspect-ratio 占位防抖动。
  • 列表用 IntersectionObserver 懒加载,把「不该下」的请求彻底掐掉。
  • 码率自适应优先 HLS + hls.js,多档转码 + master.m3u8,播放器自动切档。
  • 服务器必须支持 Range 请求,静态资源配长缓存。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-775.html
转载请注明出处,版权归原作者所有。
他们都看过 1 人浏览过
XiaoC

全部回复 3

做个坏人啦
做个坏人啦 正式会员正式会员认证极客认证极客 1楼 2026-10-10 12:38

方向都对,但漏了最容易翻车的一环:服务端 Range 和 MP4 的 faststart——这两点不做,preload="metadata" 等于白设。

先说原因。浏览器拉 metadata 靠的是 HTTP Range 请求,服务器必须返回 Accept-Ranges: bytes 和 206;Nginx 默认没问题,但走 CDN、对象存储或某些反代时容易丢这个头,结果就是「只要元数据」变成「整包下载」。另外 MP4 的 moov 原子如果在文件尾部(很多剪辑软件默认如此),浏览器为了拿到时长和首帧必须先把整个文件拖下来,这时候 preload 填什么都没用。转码时加一句 ffmpeg -movflags +faststart 把 moov 前置,成本几乎为零。

再补第三步的两个坑。一是改完 data-src 后 Safari 有时不主动发起加载,稳妥做法是赋值后手动调一次 v.load();二是列表很长时只做「进入即加载」还不够,视频滚出视口后建议 removeAttribute('src') + load() 释放解码器,手机上几十个 video 元素同时挂着内存会很难看。rootMargin 给到 200px 左右就够,给太大等于变相 preload。

至于 ABR,提醒一句:原生 <video src="x.mp4"> 不具备自适应码率能力,单一 MP4 只能整段下载。真要做 ABR 只有两条路——HLS/DASH 分片,或者准备多档 MP4 自己按 navigator.connection.effectiveType 切换。前者用 canPlayType('application/vnd.apple.mpegurl') 判断:Safari/iOS 走原生 src,其余浏览器加载 hls.js(依赖 MSE)。

另外 object-fit: cover 用在内容型视频上会裁掉画面,等比例占位的话其实不需要它。帖子好像截断在 IntersectionObserver 那段了,后面懒加载的完整写法要不要补上?

一个达不溜
一个达不溜 正式会员正式会员认证极客认证极客 #625 2楼 2026-10-10 12:46
做个坏人啦:方向都对,但漏了最容易翻车的一环:**服务端 Range 和 MP4 的 faststart**——这两点不做,`preload="metadata"` 等于白…

方向对,而且你点到的这两条恰恰是最容易翻车的:preload="metadata" 白设,九成都死在 moov 后置和 Range 头丢失上,其他都是次要的。

排查顺序我一般这么走:先 curl -I -r 0-1023 视频地址,确认返回 206 + Content-Range + Accept-Ranges: bytes;没 206 基本就是 CDN/反代把 Range 吃掉了,Nginx 直连默认没事,但开了 gzip、或面板里带「压缩/合并」的选项、或对象存储没开分片回源,都可能变成整包 200。然后看 moov 位置,grep -abo 'moov' a.mp4 和 grep -abo 'mdat' a.mp4 比一下 offset 就行,moov 在 mdat 后面就得重转,-movflags +faststart 成本确实接近零,建议转码流水线里写死。

Safari 那个我同意,赋值 data-src 后补一次 v.load() 是最省事的兜底。释放解码器这块再补一句:removeAttribute('src') 之前最好先 v.pause(),不然后台可能还在解码;手机上 limit 一下同时挂载的 video 数量(比如只保留视口内 ±2 个)效果更明显。rootMargin: 200px 这个量级也认同,给到 1000px 就退化成变相 preload 了。

ABR 的判断没问题,多档 MP4 用 effectiveType 只能粗切、切换点会黑屏重缓,真要顺滑还是 hls.js 那条路。object-fit: cover 用在内容视频上会裁字幕和边缘信息,等比例容器下确实该去掉,cover 留给封面图和背景视频就行。

懒加载完整写法要,麻烦贴一下,重点是「滚出视口释放」那段的收尾,很多人写到 isIntersecting 就没了。

zero
zero 见习用户见习用户 #626 3楼 2026-10-10 12:52
一个达不溜:方向对,而且你点到的这两条恰恰是最容易翻车的:**`preload="metadata"` 白设,九成都死在 moov 后置和 Range 头丢失上**,其他都…

收尾的关键就三行,顺序不能换:先 pause(),再 removeAttribute('src'),最后必须补一次 load()——少了最后一步,Safari/iOS 上资源不会真正释放,在途请求也可能继续挂着。完整写法如下:

```js
const MAX_LIVE = 3 // 同时挂载上限,手机端建议降到 2
const live = [] // 已挂载队列,FIFO 淘汰

function mount(v) {
if (v.src) return
v.preload = 'metadata' // 挂载后切成 metadata,控件能显示时长
v.src = v.dataset.src
v.load() // Safari 赋值后不主动拉流的兜底
live.push(v)
while (live.length > MAX_LIVE) unmount(live.shift())
}

function unmount(v) {
if (!v.src) return
if (!v.paused) return // 正在播的别动
if (v.dataset.keep === '1') return //