ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

vue-video-player首次黑屏?根源是初始化时机与异步数据错位

vue-video-player首次黑屏?根源是初始化时机与异步数据错位 最近有个朋友在群里问了一个问题Vue项目里用vue-video-player做视频播放父组件从接口拿到视频地址通过props传给子组件结果第一次进入页面视频黑屏不播放退出再进或者刷新一下又好了。更奇怪的是他在子组件里写了watch去监听父组件传过来的值第一次竟然监听不到打印出来是undefined第二次才正常。这个问题在vue-video-player的使用中非常典型很多人踩过坑。我当年也在这个问题上折腾了一整个下午。最后发现这还真不一定是“监听不到”而是播放器初始化的时机和父组件数据到达的时机错位了。这个问题的本质是异步数据流和video.js初始化机制之间的冲突。今天我就把这个问题彻底拆开从根因、排查到解决方案一次性讲清楚。无论你是用vue-video-player还是直接用video.js这套思路都适用。1. 问题本质不是“监听不到”而是初始化时机踩了坑1.1 先搞懂video-player初始化时发生了什么vue-video-player本质上是对video.js的Vue封装。它内部会在组件的mounted阶段创建一个video.js播放器实例然后从组件传入的options、sources、src等属性里读取视频地址完成初始化。这个初始化过程是同步的、一次性的。也就是说播放器在创建的那一刻你给它什么source它就用什么source。之后你再怎么改props只要没有主动触发播放器的源更新方法它都不会自动去拉新的地址。问题就在这里。父组件的数据往往是异步获取的比如在created或mounted里发一个axios请求拿到数据后再传给子组件。子组件被创建的时候父组件的请求可能还没返回这时props里的视频地址还是初始值——通常是空字符串、null或者undefined。video-player拿到这个空地址照样会初始化一个播放器只是source是空的自然什么都播不了。等父组件请求返回了address变成了真实的视频链接props也变了但播放器实例已经创建完了它不会自动去更新source。结果就是第一次黑屏、无法播放。1.2 为什么第二次就正常了watch也“失效”了很多人遇到这个问题后第一反应是写watch去监听父组件传过来的地址例如watch: { videoUrl(newVal) { if (this.player) { this.player.src(newVal) } } }然后发现第一次还是不行第二次才行。这就更让人困惑了。其实这跟Vue的响应式机制有关。如果你在父组件里用了v-if来控制子组件的渲染等数据到了才渲染子组件那么子组件mounted时props就已经有值了watch根本不会触发因为它只监听“变化”不监听“初始值”。所以你以为watch没生效其实是没有触发条件。另一种情况是你在子组件的mounted里创建播放器但watch里在this.player还没赋值的时候就触发了一次比如props在mounted之前就变了。这时watch里访问this.player可能是undefined你加个判断if (this.player)就又跳过去了等于白写。那为什么第二次进入正常呢因为第二次组件被复用或者重新创建时父组件的数据可能已经从缓存里拿出来了或者接口返回更快组件初始化时props已经有值了播放器直接带着真实地址初始化自然就正常了。2. 根因定位3分钟排查问题出在哪一环2.1 第一步确认父组件数据是否真的传到了子组件不要凭直觉猜测直接打点验证。在子组件里加一个created和mounted的日志打印this.videoUrlcreated() { console.log(created videoUrl:, this.videoUrl) }, mounted() { console.log(mounted videoUrl:, this.videoUrl) }同时在父组件里也打印一下接口返回后赋值的时间点。这样你就能看到顺序如果子组件created和mounted时videoUrl都是undefined父组件的数据显然是后到的。如果mounted时videoUrl已经有值了但播放器依然黑屏那可能是播放器的options配置有问题不是时机问题。如果父组件在请求返回前根本没有渲染子组件那这个日志不会出现在第一次创建时而是请求返回后才会打印这时候videoUrl是有值的。这一步能帮我们快速缩小排查范围。大多数情况下你都会看到子组件创建时videoUrl还是空的。2.2 第二步确认video-player实例上到底有没有源如果你已经能在console里拿到this.player那直接检查播放器实例的源mounted() { this.$nextTick(() { console.log(player currentSrc:, this.player.currentSrc()) console.log(player src:, this.player.src()) }) }如果打印出来是空字符串或者undefined就说明播放器初始化的源是空的。这时候你再看看props的值就能确认是初始化时机问题。这里有个小坑video-player组件在渲染时内部会创建video标签。this.$nextTick之后player一般就存在了但有些低版本或特殊场景下player可能还要等ready事件。如果拿不到this.player可以配合ready回调使用。2.3 第三步确认watch触发顺序和值这一步是为了搞清楚watch是真的没触发还是触发时机不对。你可以这样写watch: { videoUrl: { handler(newVal, oldVal) { console.log(watch videoUrl:, newVal, oldVal) // 这里不要直接操作 player先观察 }, immediate: true } }加上immediate: true组件创建时watch会立即执行一次方便你观察初始值。然后再等父组件数据返回又会执行一次。通过两次日志对比你就能知道第一次执行时newVal是undefinedoldVal也是undefined说明props初始值是空的。第二次执行时newVal是真实地址oldVal是undefined说明数据确实变了。但要注意即使watch触发了也不代表播放器一定准备好。特别是第一次执行时this.player大概率是undefined因为mounted都还没跑。这就是很多人写了watch却无效的根本原因。3. 解决方案4种可靠写法实测有效3.1 方案一v-if判断src存在后再渲染播放器最省心这个方法最简单、最粗暴但也是我最推荐新手使用的。父组件或者子组件内部用v-if控制video-player的渲染只有视频地址有值时才创建播放器。在子组件里template div video-player v-ifvideoUrl refvideoPlayer :optionsplayerOptions / /div /template这样做的核心逻辑是不让播放器在空源状态下初始化。父组件的数据到了videoUrl从空变成真实地址Vue才会去创建video-player组件此时初始化时source就是有效地址。这个方案的好处非常明显不用写watch不用关心player实例是否ready。第一次就能正常播放不会黑屏。逻辑清晰后面人维护也能看懂。缺点是如果视频地址是异步获取的播放器容器会晚一点出现视觉上会有一小段空白。但这个问题可以接受你甚至可以在容器位置上放一个loading占位体验反而更好。实际项目中我经常这样配合父组件使用template child-player v-ifvideoSrc :video-urlvideoSrc / /template如果你在父组件里用v-if控制子组件那么子组件内部就不用重复v-if了因为父组件保证了传入的props一定不为空。双保险也行但没必要。3.2 方案二监听videoUrl变化后手动设置source最灵活如果因为一些业务原因不能让播放器晚渲染比如你需要常驻播放器来做播放列表切换那就得用watch方案。核心思路是等videoUrl变化后手动把新的地址塞给播放器。先给video-player加上ref然后在watch里操作playertemplate video-player refvideoPlayer :optionsplayerOptions / /templatewatch: { videoUrl: { immediate: true, handler(newVal) { if (!newVal) return this.$nextTick(() { const player this.$refs.videoPlayer.player if (player) { player.src({ src: newVal, type: this.getVideoType(newVal) }) player.play() } }) } } }这里有几个关键点必须先判断newVal为空就return避免播放器set空源。要用this.$nextTick包一层确保此时player实例已经创建完成。player.src()接受一个对象里面可以指定type。如果播放m3u8type就是application/x-mpegURL播放mp4type就是video/mp4。写成方法getVideoType来根据后缀判断更通用。src设置完以后再调用play()可以自动播放不过浏览器会自动播放策略限制需要用户交互或muted这个后面再细说。这个方案我第一次跑通时说实话有点成就感因为它解决的不只是“第一次监听不到”的问题还顺手解决了“切换视频地址需要手动刷新播放器”的问题。3.3 方案三使用ready回调把初始化状态变成可控状态除了v-if和watch还有一种思路是我后来才get到的利用video.js的ready事件。video.js在播放器初始化完成后会触发ready回调在这个回调里再根据当前props的值来设置源。vue-video-player组件支持在options里配置events或者通过ready监听template video-player refvideoPlayer :optionsplayerOptions readyonPlayerReady / /templatemethods: { onPlayerReady(player) { this.player player if (this.videoUrl) { player.src({ src: this.videoUrl, type: this.getVideoType(this.videoUrl) }) } } }这个方案的重点在于不管父组件的数据是创建前到达还是创建后到达只要播放器ready了我们就检查一遍props。如果props已经有值就直接设置如果还没有就靠watch等后续变化。这样做的好处是把“什么时候设置source”这个逻辑统一收口了不会出现videoUrl已经更新但player还没ready的情况。配合watch使用基本能做到万无一失。我个人的体会是方案三最适合播放器组件需要长期复用、并且中间会多次切换视频源的业务场景比如课程列表、监控视频切换、短视频列表等。3.4 方案四使用compute计算属性让视频源始终跟着响应式数据走这个方案不是用来初始化播放器的而是用来配合video-player的options让options本身变成响应式。很多人的误区是在data里定义死playerOptions比如data() { return { playerOptions: { sources: [] } } }然后把videoUrl传进来后想在mounted里修改playerOptions.sources。结果发现改了也没用播放器不刷新。更合理的做法是把sources用一个computed来实时生成computed: { playerOptions() { return { src: this.videoUrl, sources: this.videoUrl ? [{ src: this.videoUrl, type: this.getVideoType(this.videoUrl) }] : [], controls: true, autoplay: false, preload: auto } } }模板里直接绑定这个computedvideo-player :optionsplayerOptions /注意video-player对options的响应式更新并不彻底它主要会把options传给video.js。如果你直接修改sources有些版本能生效有些版本不能。所以我更推荐把computed和watch方案结合computed保证模板每次都能拿到新的source结构watch负责在关键时机手动调用player的方法。单靠computed时不时还是会有首次不刷新的情况。4. 完整可跑通示例父组件异步传值 子组件播放m3u84.1 环境准备与基本安装这里以Vue2 vue-video-player为例。先安装依赖npm install vue-video-player --save如果你要播放m3u8格式还需要引入hls支持。vue-video-player内置的video.js版本较老时需要额外安装npm install videojs-contrib-hls --save然后在main.js里全局注册import Vue from vue import VideoPlayer from vue-video-player import vue-video-player/src/custom-theme.css import video.js/dist/video-js.css Vue.use(VideoPlayer)如果你的video.js版本高于7m3u8播放可能需要换用http-streaming。具体以你npm install下来的版本为准。我在项目里遇到过版本差异导致m3u8播不了的情况后面在常见问题里再展开。4.2 父组件异步获取视频地址并传给子组件父组件里一般是这样写的template div video-player-child :video-urlvideoUrl / /div /template script import VideoPlayerChild from ./VideoPlayerChild.vue export default { components: { VideoPlayerChild }, data() { return { videoUrl: } }, created() { this.fetchVideo() }, methods: { fetchVideo() { // 模拟接口请求实际替换成 axios / fetch setTimeout(() { this.videoUrl https://example.com/path/to/index.m3u8 }, 1000) } } } /script这里的关键是videoUrl在data里先初始化为空字符串。这样子组件第一次创建时props.videoUrl是空字符串不会误播一个无效地址。等接口返回后再赋值为真实地址。如果你在父组件里用了v-ifvideoUrl来控制子组件渲染那子组件第一次就是在videoUrl有值后才创建的天然规避了初始化时机问题。但也正因如此子组件内部就不需要再用v-if了以免多一次不必要的销毁重建。4.3 子组件封装一个健壮的video-player组件我通常把播放器单独封装成一个子组件方便复用。下面这个版本同时融合了v-if、watch和ready三种手段实测非常稳template div classvideo-player-wrap video-player v-ifvideoUrl refvideoPlayer :optionsplayerOptions readyonPlayerReady / div v-else classplayer-loading视频加载中.../div /div /template script export default { name: VideoPlayerChild, props: { videoUrl: { type: String, default: } }, data() { return { player: null } }, computed: { playerOptions() { const type this.getVideoType(this.videoUrl) return { controls: true, preload: auto, autoplay: false, sources: this.videoUrl ? [{ src: this.videoUrl, type }] : [] } } }, watch: { videoUrl(newVal, oldVal) { if (newVal newVal ! oldVal) { this.setVideoSource(newVal) } } }, methods: { getVideoType(url) { if (!url) return video/mp4 if (url.indexOf(.m3u8) -1) return application/x-mpegURL if (url.indexOf(.mp4) -1) return video/mp4 if (url.indexOf(.webm) -1) return video/webm if (url.indexOf(.ogg) -1) return video/ogg return video/mp4 }, onPlayerReady(player) { this.player player this.setVideoSource(this.videoUrl) }, setVideoSource(url) { if (!url) return this.$nextTick(() { const player this.player || (this.$refs.videoPlayer this.$refs.videoPlayer.player) if (!player) return player.src({ src: url, type: this.getVideoType(url) }) player.play().catch(() { // 浏览器自动播放限制导致的 reject 可以忽略 console.log(play rejected, wait for user action) }) }) } }, beforeDestroy() { if (this.player) { this.player.dispose() this.player null } } } /script这段代码我已经在很多项目里验证过了。父组件首次传入的地址即使晚到也会触发watch并设置源如果地址在ready之后才到watch同样兜底如果地址在ready之前就到了ready回调里就会设置源。三重保障基本不可能出现黑屏。有两点要注意v-ifvideoUrl会让播放器在videoUrl为空时不渲染所以页面会先显示“视频加载中”。如果你不喜欢这个加载占位可以直接去掉v-if但那样的话watch和ready逻辑要更仔细确保不会以空源初始化。如果你想切换多个视频地址直接把父组件里的videoUrl改成新地址即可子组件会通过watch自动切换。实测切换过程中播放器状态会重置不需要销毁重建业务上比较友好。5. 常见问题与排查技巧实录5.1 第一次黑屏第二次正常是缓存问题吗不是缓存问题本质上是初始化时机问题。第二次正常的原因我已经在前面解释过了组件复用或数据先到位播放器初始化时就有有效source。遇到这种情况不要盲目清缓存先按第2节的排查步骤确认是否props先空后非空。如果你发现props第一次就有值播放器还是黑屏那可能是播放器初始化失败或者是m3u8跨域、视频地址无法访问等问题。这时候打开Network面板看有没有视频请求发出、有没有报401或404。video.js在拉取视频失败时播放器UI会停留在一个黑屏状态很容易让人误以为是时机问题。5.2 用v-if后autoplay为什么失效了这是个很常见的配套问题。v-if让播放器晚渲染但浏览器自动播放策略和播放器渲染时间无关。Chrome、Safari等浏览器禁止带声音的视频自动播放只有用户点击过页面或者视频设置为muted时才允许自动播放。如果你确认需要自动播放可以在playerOptions里设置autoplay: true同时加上muted: trueplayerOptions() { return { controls: true, autoplay: true, muted: true, sources: ... } }muted后多数浏览器会允许自动播放。但这样做会没有声音需要用户手动取消静音。很多直播平台就是用这个方式实现无交互自动播放的。如果你的视频一定要有声音那就不能依赖自动播放需要引导用户点击一次播放按钮。5.3 切换视频源时频繁报错“VIDEOJS: ERROR: (CODE:4)”这通常不是监听问题而是切换源时上一次的播放器状态没有清理干净。在设置新源之前最好先调用一次reset或者loadsetVideoSource(url) { if (!url) return this.$nextTick(() { const player this.player if (!player) return player.reset() player.src({ src: url, type: this.getVideoType(url) }) player.play() }) }不过reset()会把播放器所有状态都清掉包括控件状态在某些版本里会导致UI异常。如果只是切换mp4或m3u8直接调用src()通常也能正常但遇到老视频地址切换到新地址时偶尔会报错。我一般只在明确需要彻底重置时才用reset()平时直接用src()。另外切换源时也不需要把整个video-player组件销毁重建。除非你遇到了很难排查的播放器状态残留问题才考虑用 或key来强制重建。不过重建成本高不推荐当作常规手段。5.4 m3u8格式播不了提示格式不支持这个问题和“父组件传值”看似无关但很多朋友在联调时正好撞上。vue-video-player打包进来的video.js版本如果比较老默认是不支持m3u8的需要引入hls库。在main.js里安装后还要手动注册。如果你用的是videojs-contrib-hls可以这样注册import videojs from video.js import videojs-contrib-hls然后在使用时确认video.js能识别application/x-mpegURL。如果你不想手动指定type也可以直接传src地址让video.js根据URL后缀去猜。但我在实测中发现具有.m3u8后缀的URL大概率能猜对带签名参数的URL比如/index.m3u8?tokenxxx就经常识别不了。所以最好还是用我们前面封装好的getVideoType方法明确指定type。如果hls库都装了还是播不了检查一下你的video.js版本。vue-video-player依赖的video.js如果是6.x用videojs-contrib-hls没问题如果是7.x建议改用videojs/http-streaming它已经内置在video.js 7.x里了不需要额外安装。这点在踩坑时很容易被忽略。5.5 父组件传的是对象watch需要加deep吗如果你传的不是字符串地址而是一个对象比如videoInfo: { url: ..., poster: ..., title: ... }那你监听这个对象时如果父组件只是改了里面的url字段普通watch是触发不了的因为对象的引用没变。你需要在watch里加deep: true或者改用computed来监听具体的url字段。但deep监听有性能开销特别是对象层级很深、频繁变化时会造成不必要的重渲染。我建议是尽量传字符串如果必须传对象就把url单独提取成一个prop。比如父组件同时传video-player-child :video-urlvideoInfo.url :video-infovideoInfo /这样播放器主逻辑只依赖videoUrl这个字符串videoInfo只用于展示封面、标题等附属信息监听逻辑更简单性能也更好。这种“主数据用字符串、附属数据用对象”的做法是我在封装组件时比较喜欢用的一种方式。5.6 Vue3项目里遇到同样的问题怎么办vue-video-player这个包本身对Vue3的支持并不好Vue3里建议直接使用video.js自己封装组件。但初始化时机问题的原理完全一样不要在mounted里无条件创建播放器而是等视频源有效后再创建或者创建好之后再通过监听源字段动态设置player.src。Vue3的组合式API写起来其实更顺手script setup import { ref, watch, onMounted, onBeforeUnmount } from vue import videojs from video.js const videoUrl ref() const videoEl ref(null) let player null watch(videoUrl, (newVal) { if (newVal player) { player.src(newVal) } }) onMounted(() { player videojs(videoEl.value, { controls: true, sources: videoUrl.value ? [{ src: videoUrl.value }] : [] }) }) onBeforeUnmount(() { if (player) { player.dispose() } }) /scriptVue3里同样推荐v-ifvideoUrl或者watch player.src。核心思想没有变只是API变了。如果你是从Vue2切到Vue3把心态放平这个问题其实好解决。6. 安装环境与Node版本相关的坑很多人把问题定位到“监听父组件传值”上结果排查一圈发现是环境问题。这里我分享一个真实经历有次项目里vue-video-player装好了运行也不报错但播放器就是白屏控制台里没有任何source信息。后来发现是我本地的Node版本太新导致编译后的video.js资源加载异常换回项目要求的Node版本后立刻正常。所以如果你在排查父组件传值之前先确认一下这些环境项npm install时有没有报错特别是node-sass或node-gyp相关的错误。有没有正确引入video.js的css样式文件。忘记引样式的话播放器控制条会错乱黑屏也很正常。有没有在构建时压缩转码导致video.js的wasm或worker文件路径错误。这个在webpack配置里需要额外处理。如果你是用Vite构建vue-video-player的兼容性不算好我建议直接换video.js避免在配置上浪费时间。用Webpack的话vue-video-player相对稳定但也别忽略css的引入。7. 我在实际项目里的收尾习惯最后再分享一点个人经验。封装video-player组件时我会习惯性把加载状态、错误状态、重试按钮都做进去。原因很简单视频播放出问题的时候用户看到的不能只有一个黑屏至少要有一个“加载中”或“播放失败”的提示不然你连问题是什么时候出现的都不知道。我会在onPlayerReady里监听播放器的error事件player.on(error, () { this.$emit(play-error, player.error()) })然后父组件根据error信息决定是展示报错还是重新拉取地址。视频行业的接口经常有签名过期的情况签名的有效期可能只有几分钟第一次拿到地址能播用户看了一会儿再切换下一条时地址就失效了。这种时候监听父组件传值反而不是最关键的了你得在error事件里重新请求地址。这些细节项目上线后比“第一次能不能播放”更影响体验。第一次监听不到父组件传值这个问题你只要抓住一点就不会再犯video-player的初始化是一次性的异步数据得在初始化之前或之后主动去同步不能指望播放器自己刷新source。不管是v-if还是watch本质都是在“正确的时机”把“正确的值”给到播放器。搞懂这个后面再遇到其他播放器插件的问题你也能举一反三。
返回列表