
1. 先把需求摸清楚为什么还得回到原生 video现在做视频播放很多人第一反应是装个播放器库觉得原生video就是个能播能停的玩具。我在几个内容站和后台管理系统里反复折腾过之后反倒越来越倾向于先啃原生接口库的封装是别人的取舍而视频这块的需求往往又碎又偏——运营要自定义封面、要拖动进度、要静音自动播放、iOS 要内联不能全屏、后台要记录用户看到第几秒。这些需求一旦落到库的 API 之外你就得绕回原生层去写那还不如一开始就搞清楚原生层长什么样。HTML5 video 这套东西的价值在于它是一个浏览器内置的媒体引擎标签属性负责声明式配置JS API 负责命令式控制事件负责状态回调CSS 负责外观。四块拼起来恰好就是一个最小可用播放器的全部零件。你把这四个面吃透写出来的控件能直接塞进 Vue、React、原生页面也不受库版本升级的影响。这篇内容就是按属性 → API → 事件 → 样式 → 完整示例 → 排查这条线来铺的适合需要自己控制播放行为的前端也适合被自动播放策略、iOS 内联播放、进度条拖拽这些细节折腾过的人。文中给的所有代码都是可以粘贴到一个空 HTML 文件里直接跑起来的不需要任何构建工具。2. 标签属性怎么选声明式配置里的取舍2.1 播放控制类属性autoplay、loop、muted、controls这些属性是布尔型写了就生效不写就是关闭不能写controlsfalse这种——那在 HTML 里是字符串布尔属性只要存在即为真。这一点踩过坑的人不少尤其在模板引擎里拼接字符串的时候controlsfalse会让控件照样显示出来。autoplay页面加载后尝试自动播放。但浏览器普遍有拦截策略没有声音的自动播放基本放行带声音的自动播放需要用户已经和页面产生过交互。所以真正能用的自动播放几乎都是autoplay muted组合。muted初始静音。注意这个属性只决定初始状态后续用 JS 改video.muted不受它限制。loop播放结束后从头再来。配合ended事件用的时候要小心循环播放不会触发ended。controls显示浏览器原生控件。如果你打算自己做控件就不要加它否则原生控件和你的控件会同时出现页面上会有两套进度条。我个人的习惯是只要产品对播放器外观有任何一句要求就直接不要controls全部自绘。原生控件的样式几乎没办法跨浏览器统一各家的按钮布局、颜色、圆角都不一样靠私有伪元素去改是治标不治本。2.2 加载策略类属性preload、poster、srcpreload有三个常用取值理解它们的差异能省不少带宽取值行为适用场景none不预加载等用户点了才请求页面里有多个视频、首屏性能敏感metadata只加载元数据时长、尺寸、首帧信息最常见能拿到 duration 又不浪费流量auto浏览器自行决定通常预加载一部分单视频页面、确定用户会看这里有个很容易忽略的点不管preload设成什么浏览器都可能在网络空闲时多拉一些数据它只是提示而不是强制。想要严格控制流量得靠后面的load()和src的赋值时机来管比如首屏先不设src用户点击封面后再把地址赋上去。poster是封面图。它的显示时机是从标签渲染开始到第一帧画面准备好为止。如果视频本身有黑场开头poster 消失的那一刻会闪一下观感很差常见的处理办法是把 poster 做成一帧和视频首帧色调接近的图或者给视频容器加个背景色过渡。src直接写在标签上有时候不够灵活。更稳的写法是用source子元素或者干脆平时不给 src等到需要播放时再用 JS 赋值。2.3 移动端与跨域相关属性playsinline、crossoriginplaysinline是移动端最容易被忽略、又最容易出问题的属性。在 iOS Safari 上视频默认会走系统全屏播放器页面上你的自定义控件全部失效用户点一下就跳到全屏去了。加上playsinline之后才会内联在页面里播放。Android 上有些浏览器认webkit-playsinline这个旧写法两个一起加兼容性最好。crossorigin影响的是跨域资源的读取权限取值anonymous或use-credentials。它只在视频和页面不同源时才有意义作用是让请求带上 CORS 头从而允许你从视频里取像素、做 canvas 截图、接 Web Audio 分析波形。不加这个属性去调canvas.drawImage(video, ...)会直接抛安全错误。代价是服务端必须返回正确的Access-Control-Allow-Origin配置不对视频本身都播不出来所以这个属性不要随手加。2.4 画中画与远程播放控制属性disablepictureinpicture用来禁用画中画按钮controlslist可以隐藏原生控件里的部分按钮取值包括nodownload、nofullscreen、noremoteplayback。这里要提醒一句controlslist只是建议Chrome 支持得比较好其他浏览器未必买账而且用户右键还是能保存视频。真想防下载得从服务端做鉴权和分段前端这些手段只能挡住随手一点的人。disableRemotePlayback用来关掉投屏入口。做企业内部系统的时候我遇到过投屏按钮引起的困扰——用户不小心把内部培训视频投到会议室电视上后来加了这个属性才消停。3. 核心 API 方法命令式控制里的那些细节3.1 play() 返回 Promise 这件事video.play()在老版本里是同步返回 undefined 的现在返回的是一个 Promise。这个改动是为了配合自动播放策略如果播放被浏览器拒绝你可以捕获到错误而不是静默失败。const v document.querySelector(#v); v.play().catch(err { // NotAllowedError 表示没有用户手势需要引导用户点击 // NotSupportedError 表示视频格式不支持 console.warn(播放被拒绝, err.name); });不处理这个 Promise 的后果是控制台报一句 Uncaught (in promise) DOMException功能看着没坏但用户看到的是一个完全没反应的播放按钮。正确做法是捕获之后给出兜底比如把按钮恢复成点击播放的状态再显示一句请点击播放。我习惯在点击事件里调play()这样有明确的用户手势被拒绝的概率最低。pause()是同步的没有返回值调用后paused属性变为true但pause事件是异步派发的别在调用后立刻去读事件里的状态。3.2 load() 与切换视频源load()会重置整个媒体元素清空已缓冲数据、把currentTime归零、重新走一遍完整的加载流程同时派发emptied、loadstart等事件。切换视频源的推荐写法是function switchSource(video, url, poster) { video.pause(); if (poster) video.poster poster; video.src url; video.load(); // 触发重新加载 }只改src不调load()大部分浏览器也会自动重新加载但行为不完全一致尤其是在设置了多个source的情况下。显式调用load()是最不容易出岔子的写法。另外一个细节load()之后duration会变回NaN你得等loadedmetadata事件之后再去读否则拿到的是脏数据。3.3 时间、音量、倍速的读写规则currentTime可读可写但写入必须在元数据就绪之后。页面刚加载还没拿到时长就设currentTime 30多数浏览器会忽略这次赋值或者被后续的加载重置掉。稳妥的做法是缓存一个pendingSeek在loadedmetadata里再执行let pendingSeek 0; video.addEventListener(loadedmetadata, () { if (pendingSeek 0 Number.isFinite(video.duration)) { video.currentTime Math.min(pendingSeek, video.duration); pendingSeek 0; } });volume取值 0 到 1超出范围会抛IndexSizeError。这里有个必须记住的坑iOS 上音量是只读的你设video.volume 0.5不会报错但也不会生效系统音量由物理按键控制。所以做自定义音量条的时候在 iOS 上要隐藏这个控件只保留静音开关。playbackRate设成负数会抛错设成 0 在部分浏览器上会暂停播放。可以支持的倍速范围各浏览器不同Chrome 大致在 0.0625 到 16 之间超出范围在部分实现里会静默截断所以做倍速菜单的时候不要给出 0.0625 以下的值。defaultPlaybackRate只在load()之后才生效运行中改它不会立刻改变速度这个属性和playbackRate容易混淆。3.4 只读属性的正确读法duration、videoWidth、videoHeight这些必须等loadedmetadatabuffered、played、seekable返回的是TimeRanges对象不是数组取值要用.start(i)和.end(i)。取已缓冲到的最后一秒常见写法是function bufferedEnd(video) { const b video.buffered; if (!b.length) return 0; // 注意 currentTime 附近那一段才是即将播放的 for (let i 0; i b.length; i) { if (b.start(i) video.currentTime video.currentTime b.end(i)) { return b.end(i); } } return b.end(b.length - 1); }直接用b.end(b.length - 1)在分段请求的场景下会算出靠后的那一段画出来的缓冲条会跳来跳去用上面这个按currentTime定位的写法更符合直觉。4. 事件体系把播放器当成一个状态机来看4.1 加载阶段的事件顺序很多播放器的 bug 源于对事件顺序的误解。一个标准的加载流程大致是顺序事件含义1loadstart开始请求资源2durationchange时长信息可用duration开始有值3loadedmetadata元数据就绪可读尺寸、时长4loadeddata当前播放位置的首帧已加载5canplay可以开始播放但可能还需缓冲6canplaythrough估计能流畅播完不必再缓冲实际运行时这个顺序可能因为缓存、网络快慢而有变化progress会穿插在其中多次触发。所以千万不要写在loadeddata里去设进度这种硬编码顺序的逻辑应该把每件事绑到它真正依赖的那个事件上。4.2 播放与交互阶段的核心事件play和playing是两码事这是新手最容易混的地方。play在调用play()方法后立刻触发表示要开始播了playing在真正开始播放、而且缓冲已经够用的时候触发。pause之后又恢复播放会再触发一次playing。所以判断是不是真的在播应该看playing和paused属性而不是play。timeupdate是驱动进度条的主力但它有个明确的限制为了省电和不拖累主线程浏览器一般每 250 毫秒才触发一次频率不固定。不能拿它当帧计数器用。如果你需要精确到帧的回调比如做视频截图、逐帧标注应该用requestVideoFrameCallbackif (requestVideoFrameCallback in HTMLVideoElement.prototype) { const onFrame (now, metadata) { // metadata.mediaTime 是当前帧对应的媒体时间 console.log(metadata.mediaTime, metadata.presentedFrames); video.requestVideoFrameCallback(onFrame); }; video.requestVideoFrameCallback(onFrame); }这个接口目前在 Chromium 系和 Safari 上支持得不错用之前记得做能力检测。4.3 缓冲与卡顿相关事件waiting表示因为缓冲不足停下来了stalled表示浏览器在尝试取数据但超过三秒没有进展。这两个都是卡的信号但成因不同waiting通常是网络跟不上stalled有时候是服务端没响应或连接断了。seeking和seeked成对出现用户在拖动进度条时会连续触发多次。做加载动画的时候正确的做法是在seeking里转圈、在seeked里停但如果只是简单地show/hide快速连拖会有闪烁更稳的是加一个超时去抖let seekTimer null; video.addEventListener(seeking, () { clearTimeout(seekTimer); spinner.style.display block; }); video.addEventListener(seeked, () { seekTimer setTimeout(() spinner.style.display none, 120); });这个 120 毫秒的去抖是我实测下来比较舒服的值比它小会闪比它大会感觉迟钝。4.4 错误事件与错误码对照error事件触发时详细信息在video.error上MediaError.code的取值是固定的码值常量含义排查方向1MEDIA_ERR_ABORTED用户主动中断下载一般无需处理2MEDIA_ERR_NETWORK网络错误导致下载中断检查网络、CDN 回源3MEDIA_ERR_DECODE解码失败文件损坏或编码不支持重转码、换编码参数4MEDIA_ERR_SRC_NOT_SUPPORTED地址不可用或格式不支持检查 URL、MIME 类型、编码格式比较头疼的是error事件经常不触发。常见原因是地址返回了 200 但内容是个 HTML 错误页浏览器把它当媒体数据处理解码失败但错误信息被吞掉了。排查时我一般直接打开 Network 面板看响应的Content-Typevideo/mp4才对如果是text/html那就一定有问题。注意source元素上的error事件不冒泡到video如果用了多个source需要直接在source或父级的捕获阶段监听。5. 自定义样式跟原生控件说再见5.1 隐藏原生控件的三种写法与差异最直接的做法是不写controls属性。但如果因为某些原因已经写了可以靠 CSS 隐藏video::-webkit-media-controls { display: none !important; }这种私有伪元素只在 WebKit/Blink 系有效Firefox 上根本不认识写了也没用。而且不同版本的浏览器类名还不一样长期维护成本高。我的结论是自绘控件时永远不要加controls属性别指望用 CSS 去修原生控件。另外有一种情况是controls必须先加再移除。个别移动端浏览器在加载时如果没检测到controls会走另一套渲染路径导致后续加控件位置错乱。这种边缘情况在 iOS 15 以前的版本上出现过现在的版本已经很少见了如果遇到可以试着先加后移除。5.2 用遮罩层处理全屏下的样式视频元素本身能改的样式非常有限width、height、object-fit、background、border-radius基本就是全部。想做圆角、边框、渐变遮罩得包一层容器.vp { position: relative; width: 100%; max-width: 800px; aspect-ratio: 16 / 9; border-radius: 12px; overflow: hidden; background: #000; } .vp video { width: 100%; height: 100%; display: block; object-fit: contain; }object-fit: contain保证画面完整不裁切cover会填满但可能裁掉边缘。这里有个坑容器加了border-radius和overflow: hidden进入全屏后圆角依然生效但全屏容器的定位是浏览器管的有时候会出现黑边需要在:fullscreen状态下把圆角归零。5.3 自绘进度条的两条路线路线一是用input[typerange]好处是键盘可达性、拖拽行为都由浏览器实现样式用::-webkit-slider-thumb和::-moz-range-thumb分别适配坏处是各浏览器的默认样式差异大appearance: none之后又要自己补一套。路线二是纯div 指针事件自绘可控性最强。结构大致是三层底槽、缓冲进度、已播放进度再叠一个圆点手柄。指针事件我推荐用pointerdown/pointermove/pointerup一套代码同时兼容鼠标和触摸比分别写mousedown和touchstart省事。核心是按下时在document上挂pointermove抬起时解绑并且用setPointerCapture把后续事件锁定到元素上避免鼠标滑出元素后事件丢失。function bindSeek(bar, video) { let dragging false; const ratioFromEvent (e) { const rect bar.getBoundingClientRect(); return Math.min(Math.max((e.clientX - rect.left) / rect.width, 0), 1); }; bar.addEventListener(pointerdown, (e) { dragging true; bar.setPointerCapture(e.pointerId); video.currentTime ratioFromEvent(e) * video.duration; }); bar.addEventListener(pointermove, (e) { if (!dragging) return; // 拖动过程中只更新UI避免频繁seek const r ratioFromEvent(e); updateProgressUI(r); }); bar.addEventListener(pointerup, (e) { if (!dragging) return; dragging false; video.currentTime ratioFromEvent(e) * video.duration; }); }拖动中不实时seek这一点很重要。频繁设置currentTime会让浏览器不停发起分片请求网速一般的时候画面会一顿一顿。更好的做法是拖动时只动 UI松手才真正跳转。6. 从零搭一个能用的轻量播放器6.1 结构规划与属性配置先把 HTML 骨架定下来重点是 video 只做播放和事件源所有交互都由外层元素承担div classvp idvp video idvideo srchttps://example.com/demo.mp4 posterhttps://example.com/cover.jpg preloadmetadata playsinline webkit-playsinline /video button classvp-big idbigPlay aria-label播放 span classvp-big-icon/span /button div classvp-ctrl button idbtnPlay aria-label播放/暂停▶/button span classvp-time idcurTime00:00/span div classvp-bar idbar div classvp-bar-buf idbufBar/div div classvp-bar-played idplayedBar/div div classvp-bar-dot iddot/div /div span classvp-time iddurTime00:00/span button idbtnMute aria-label静音/button button idbtnFull aria-label全屏⛶/button /div /div属性上我给了preloadmetadata这样封面能显示、时长能拿到又不至于把整个文件拉下来。playsinline和webkit-playsinline都写上覆盖新旧 iOS。6.2 样式实现控件布局与进度条样式部分最关键的是控件的层叠关系和指针穿透.vp-ctrl { position: absolute; left: 0; right: 0; bottom: 0; display: flex; align-items: center; gap: 10px; padding: 10px 14px 12px; background: linear-gradient(to top, rgba(0,0,0,.75), transparent); opacity: 1; transition: opacity .25s; } .vp.hide-ctrl .vp-ctrl { opacity: 0; pointer-events: none; } .vp-bar { position: relative; flex: 1; height: 14px; /* 加大命中区域 */ display: flex; align-items: center; cursor: pointer; touch-action: none; } .vp-bar-buf, .vp-bar-played { position: absolute; height: 4px; border-radius: 2px; left: 0; } .vp-bar-buf { background: rgba(255,255,255,.35); width: 0; transition: width .2s linear; } .vp-bar-played { background: #4a9eff; width: 0; } .vp-bar-dot { position: absolute; width: 12px; height: 12px; border-radius: 50%; background: #fff; left: 0; transform: translateX(-50%); }touch-action: none这行不能少否则移动端在进度条上左右滑动会触发页面滚动拖动直接失效。进度条实际高度做 4px但外层容器给到 14px是为了扩大手指点击的命中区这个细节在移动端体感差别非常大。6.3 交互逻辑把事件和状态接起来播放器逻辑的核心就是事件改状态状态改 UI。先把状态同步函数写好const video document.getElementById(video); const played document.getElementById(playedBar); const buf document.getElementById(bufBar); const dot document.getElementById(dot); function fmt(sec) { if (!Number.isFinite(sec)) return 00:00; const m Math.floor(sec / 60); const s Math.floor(sec % 60); return String(m).padStart(2, 0) : String(s).padStart(2, 0); } function syncTimeUI() { const d video.duration; if (!Number.isFinite(d) || d 0) return; const p video.currentTime / d; played.style.width (p * 100) %; dot.style.left (p * 100) %; document.getElementById(curTime).textContent fmt(video.currentTime); } function syncBufferUI() { const d video.duration; const b video.buffered; if (!Number.isFinite(d) || d 0 || !b.length) return; let end 0; for (let i 0; i b.length; i) { if (b.start(i) video.currentTime video.currentTime b.end(i)) { end b.end(i); break; } } buf.style.width (end / d * 100) %; }然后绑定事件。加载类事件集中处理 UI 初始化video.addEventListener(loadedmetadata, () { document.getElementById(durTime).textContent fmt(video.duration); }); video.addEventListener(timeupdate, syncTimeUI); video.addEventListener(progress, syncBufferUI); video.addEventListener(seeked, () { syncTimeUI(); syncBufferUI(); });播放状态和按钮图标同步const bigPlay document.getElementById(bigPlay); const btnPlay document.getElementById(btnPlay); btnPlay.addEventListener(click, () { if (video.paused) video.play().catch(() {}); else video.pause(); }); video.addEventListener(play, () { btnPlay.textContent ❚❚; bigPlay.classList.add(is-hidden); }); video.addEventListener(pause, () { btnPlay.textContent ▶; if (!video.ended) bigPlay.classList.remove(is-hidden); }); video.addEventListener(ended, () { btnPlay.textContent ▶; bigPlay.classList.remove(is-hidden); });注意pause里判断了ended因为播放结束也会触发pause如果不判断视频播完后会同时出现大播放按钮和结束态两个状态叠在一起。音量和全屏这两块比较短但各有一个坑document.getElementById(btnMute).addEventListener(click, () { video.muted !video.muted; }); document.getElementById(btnFull).addEventListener(click, () { const box document.getElementById(vp); if (document.fullscreenElement) document.exitFullscreen(); else box.requestFullscreen box.requestFullscreen(); });全屏要请求外层容器而不是 video 本身否则你的自定义控件不会被带进全屏用户在全屏状态下就没有控件可用了。6.4 自动播放与移动端策略落地自动播放这块的实操结论是静音自动播放兼容性最好带声音的必须等用户交互。一个稳妥的初始化写法是function tryAutoplay() { video.muted true; const p video.play(); if (p p.catch) { p.catch(() { // 被拒绝展示封面和播放按钮等用户点击 bigPlay.classList.remove(is-hidden); }); } } video.addEventListener(canplay, function once() { video.removeEventListener(canplay, once); tryAutoplay(); });先静音再play()是关键。反过来先play()再设muted就来不及了浏览器在play()的瞬间就做了策略判断。另外一个移动端细节如果页面上有多个视频iOS 上同时只能有一个在播放play()新的视频会自动暂停旧的而且这个暂停是静默的——pause事件会触发但不会有任何提示。做列表页视频的时候要把当前播放的那个用变量记下来在play事件里手动暂停其他的否则状态会乱。提示iOS 上volume只读音量按钮应该退化成静音切换不要做滑块。7. 常见问题排查速查7.1 自动播放没反应怎么办先确认三件事是否设置了mutedtrue、play()是否在canplay之后调用、Promise 的 catch 里有没有被吞掉。如果都做了还是不播打开控制台看看有没有NotAllowedError这条错误的意思是当前上下文没有用户手势属于策略拦截而不是代码问题。还有一个隐蔽原因video 元素在 DOM 里的尺寸是 0。有些浏览器对不可见媒体元素会限制播放。检查一下容器有没有高度是不是被display: none藏着。7.2 事件不触发或者顺序不对loadedmetadata不触发八成是src设置得太晚或者设置之后被其他代码重新赋了空值。改成src赋值后紧跟load()基本能解决。timeupdate不触发检查是不是在暂停状态——暂停时确实不会高频触发只有 seek 时会补一次。ended不触发先看有没有加loop循环播放永远不会结束。再看有没有别的代码在ended之前把currentTime改了改了就不算播完。7.3 拖动进度条后回到开头这是最经典的问题。原因是拖动过程中触发了seeking而某个loadedmetadata或durationchange的处理器又执行了一次currentTime 0的初始化。排查思路是把所有对currentTime赋值的地方列出来只保留一条首次加载后恢复进度的逻辑并且加一个标志位只在第一次执行。7.4 视频能播但没有画面有声无画通常是编码问题。MP4 容器里的视频轨用 H.265HEVCChrome 在多数平台上不支持音频轨是 AAC 就没问题。这种情况在 Windows 转出来的视频里很常见。解决办法是重转码成 H.264 AAC这是兼容性最好的组合。检测方法是先看video.error的码值是不是 3解码失败再看 Network 里是不是返回 206 分片请求正常。7.5 排查速查表现象可能原因处理方式控制台报 NotAllowedError无用户手势被自动播放策略拦截设 muted 后重试或引导点击只有声音没有画面视频轨编码不支持如 H.265转码为 H.264iOS 一点就全屏缺少 playsinline补playsinline和webkit-playsinline进度条拖不动触摸时页面滚动抢走事件容器加touch-action: noneduration 是 NaN元数据未就绪放到loadedmetadata后再读缓冲条来回跳直接取buffered.end(last)按 currentTime 定位所在区间拖动后跳回开头有其他代码重置了 currentTime全文搜索赋值点加首次标志音量设置无效iOS 下 volume 只读降级为静音切换7.6 几个我踩过的坑先说结论最反直觉的一条currentTime的赋值不是精确的。你设 30.0实际落到 29.97 或者 30.05 都有可能尤其是分片加载的 MP4。所以做记住上次播放位置的功能时不要拿时间做相等判断用Math.abs(a - b) 0.5之类的容差。第二条是关于事件的清理。视频元素在 SPA 里切换路由时如果不手动解绑事件、不调pause()后台会继续播放用户在下一个页面还能听到声音。稳妥的做法是在组件卸载时执行video.pause(); video.removeAttribute(src); video.load();这三步能把网络请求和播放状态都清干净。第三条是poster的加载时机。poster 是普通图片如果尺寸很大视频元数据早就加载完了封面还没出来用户会先看到黑屏再看到封面再看到画面三段跳。把 poster 压到 50KB 以内、尺寸和视频等宽这个跳变基本感知不到。第四条跟倍速有关。切倍速之后timeupdate的频率不变但每次前进的媒体时间变了进度条更新会显得有点顿。解决办法是把进度条宽度的过渡时间从0.2s调到0.25s视觉上会平滑不少这是纯粹的经验参数很难从文档里找到。