简介:这份源码包面向使用UniApp开发跨平台短视频应用的开发者,聚焦抖音式交互组件的完整实现。内含videoList、videoPlayer、listRight、listLeft等6个vue组件,配合json配置文件、iconfont样式与说明文档,组成可直接运行的项目骨架。资源共15个文件,压缩后约47KB,结构精炼,便于快速阅读与二次开发。项目实现了滑动切换视频、双击点赞、首个视频自动播放等核心功能,并通过props与emit的配合演示父子组件间传值与方法调用,涵盖视频缓冲、播放、暂停、全屏切换等播放细节。适合具备Vue基础、希望掌握跨端组件化开发技巧的前端工程师,已有169人学习下载,对理解短视频场景下的组件拆分与通信机制具有实用参考价值。 说实话,第一次看到"仿抖音视频组件"这个需求的时候,我心里是有点打鼓的。市面上插件市场一搜一大把现成的,为什么还要自己写?但真正动手做下来才发现,这类组件最大的问题恰恰是"能用"和"好用"之间的差距。我这次用UniApp从零搭了一个视频信息流组件,核心要解决的就是那句话:限制一个视频播放,视频滑出可视区自动暂停。这篇就把整个实现过程拆开揉碎讲清楚,从页面骨架到单实例播放管理,从可视区判定到真机踩坑,适合正在做短视频、视频列表、H5信息流这类需求的开发者参考。
先交代一下背景。我做的这个组件基于Vue3版本的UniApp,目标平台覆盖微信小程序和App端,视频源是常规的MP4格式,整体交互参考抖音:全屏卡片上下滑动,一屏一视频,滑入自动播放,滑出立即暂停,列表数据分页加载。下面直接进入正题。
1. 先回答一个问题:为什么建议自己写,而不是用插件市场的现成方案
很多人的第一反应是去插件市场搜"抖音视频",确实能搜到不少封装好的组件,下载量看着也很可观。但我实际用过几个之后,发现它们在业务落地时普遍存在三个绕不过去的坎。
第一个坎是视频实例太多导致的内存问题。不少现成组件的实现方式很粗暴:列表有多少条数据,就渲染多少个video标签,虽然用v-if控制显示隐藏,但底层仍然会创建大量播放器实例。Android低端机上滑动不到二十个视频,App就开始卡顿甚至闪退,iOS上WebView的内存告警也随之而来。抖音自己是原生实现,天然没有这个问题,但跨端方案里这恰恰是最容易翻车的点。
第二个坎是定制不灵活。短视频业务很少只做"播放视频"这一个动作——通常还要叠加点赞动画、评论抽屉、关注按钮、话题标签、购物车入口,甚至左右滑切换Tab。插件市场里的组件往往把交互写死了,改起来比重新写还痛苦,尤其在多端同步上线的时候,每端表现还不一致,修bug都能修到怀疑人生。
第三个坎是版本维护没有保障。很多现成组件作者更新时间停留在两年前,UniApp升级到Vue3和新的编译体系之后,旧组件会出现各种兼容问题,到时候你连找谁修都不知道。
所以我的判断是:如果只是临时演示,可以用现成的;但凡要正式上线、要持续迭代,自己写一个可控制的组件是更稳妥的选择。而且核心逻辑并不复杂——一个竖向滚动的容器、一个当前索引、一个播放器实例,再加一段可视区判定,整个核心代码量其实很小,但你能完全掌控性能和交互。
我这次组件化之后,核心代码只有两个文件:VideoFeed.vue负责信息流容器和可视区计算,VideoCard.vue负责单个视频卡的渲染和播放,总代码量不超过五百行。后面所有截图和代码片段都来自这套实现。
2. 页面骨架:竖向滚动容器到底用scroll-view还是swiper
这是做仿抖音组件遇到的第一个方案选型问题。大多数人的直觉是用swiper组件,因为官方demo里就有垂直方向的轮播示例,item里面放video,看起来正好能实现一屏一视频的滑动效果。swiper最大的优点是自带惯性滚动和翻页动画,滑动结束自动对齐到整屏,不需要自己计算滚动位置。但它的问题也很明显:本质上它是轮播图逻辑,每一项是固定屏高的"页",想做数据预加载、想做滑动距离超过50%才翻页这种抖音式手感,都要额外靠change事件去模拟,灵活性受限。而且swiper一次性渲染所有子项,视频多的时候和"只留一个播放实例"这个目标直接冲突。
我最终选择的是scroll-view方案。原因有三点:第一,scroll-view是一个真正的滚动容器,滚动距离、方向、速度这些参数都能拿到,方便自己做可视区计算和播放控制;第二,它天然支持数据分页,列表末尾触底加载下一页很自然;第三,滚动过程中的内容都是真实渲染的DOM,在微信小程序端配合同层渲染,video标签能正常覆盖其他元素,不会出现层级错乱。
先上一个最基础的页面结构:
<template> <view class="video-feed"> <scroll-view class="feed-scroll" :scroll-y="true" :scroll-top="scrollTop" :scroll-with-animation="false" :style="{ height: pageHeight + 'px' }" :show-scrollbar="false" :enhanced="true" :bounces="false" @scroll="onScroll" > <view v-for="(item, index) in videoList" :key="item.id" class="feed-item" :style="{ height: pageHeight + 'px' }" > <VideoCard :video-src="item.videoUrl" :cover="item.coverUrl" :active="currentIndex === index" :need-play="index === currentIndex" /> </view> <view v-if="loading" class="loading-tip">加载中...</view> </scroll-view> </view> </template>几个关键点的解释:
pageHeight不能用100%,因为竖向列表中每一项的高度必须是固定像素值,百分比高度在部分小程序端会解析异常,所以我在onReady里用uni.getSystemInfoSync().windowHeight取到窗口高度,作为每张卡片的固定高度,也是后续可视区计算的基准单位。scroll-with-animation我设成了false。可能有人会问,抖音切换视频时不是有那种过渡吗?这个过渡是"滑动"本身带来的,不是scroll-view的动画。如果把这个参数设成true,调用scrollTop跳转会有一段平移动画,反而拖慢手感和检测逻辑。enhanced在微信小程序端是开启scroll-view的增强特性,让滚动事件返回更精确的滚动距离值;bounces={false}是关闭iOS橡皮筋效果,防止滑过边界后出现白屏,也避免拖拽时视频画面被拽走。
接下来是滚动监听的计算逻辑,这段是仿抖音手势的灵魂:
onScroll(e) { // 滚动中的实时逻辑 const scrollTop = e.detail.scrollTop; const rawIndex = scrollTop / this.pageHeight; // 还没翻过一整屏时不切换,保证手感和抖音接近 if (rawIndex < this.currentIndex - 0.5 || rawIndex > this.currentIndex + 0.5) { this.switchByIndex(Math.min( this.videoList.length - 1, Math.max(0, Math.round(rawIndex)) )); } }为什么要判断0.5这个阈值?因为scroll事件的触发频率很高,用户手指轻轻滑动、还没决定翻页的时候,如果立刻切视频,会出现画面还没稳定视频已经切走的跳动感。抖音的做法是滑动过程基本不做切换动作,抬手后滑动速度降到阈值以下才翻页。0.5这个阈值模拟的就是这个"确认翻页"的判断:当前视频偏移超过半屏,才把currentIndex切到新的整数位置。
不过只用scroll事件有一个缺陷:用户快速Fling的时候,scroll事件会连续触发,可能从位置A直接跳到位置C,中间视频还没来得及播就又被切走。所以我又加了一层防抖节流,切视频操作统一放到requestAnimationFrame里,保证同一帧内不会多次切换:
let frameId = null; onScroll(e) { const scrollTop = e.detail.scrollTop; const rawIndex = scrollTop / this.pageHeight; if (frameId) cancelAnimationFrame(frameId); frameId = requestAnimationFrame(() => { if (rawIndex < this.currentIndex - 0.5 || rawIndex > this.currentIndex + 0.5) { const target = Math.min(this.videoList.length - 1, Math.max(0, Math.round(rawIndex))); if (target !== this.currentIndex) { this.switchByIndex(target); } } }); }总结一下:scroll-view方案比swiper好在可控性,代价是要自己写一点手势判断逻辑,但这部分代码量很小、逻辑也很直观,做一次之后完全值得。
3. 核心逻辑:为什么必须只留一个播放实例,以及可视区判定的两种实现
这是整个组件最核心的部分。很多人仿抖音做失败,都是败在这里:每个视频卡里都放一个video标签,用户滑到哪条播哪条,其他video用v-show隐藏。表面看能出效果,但实际运行一段时间就会发现问题:每个隐藏的video仍然占着播放器内核资源,音频解码器、视频解码器都还挂着,内存和CPU占用持续走高。测试过二十个视频卡的页面,Android低端机上内存峰值能到600MB以上,滚几次系统就开始杀进程了。
所以我在设计上定了一条铁律:任何时刻最多只保留两个video实例——当前正在播放的那个,加上它的相邻上一条或下一条(用于预加载,让滑动切换更丝滑)。其他地方的理论依据是:人在滑动信息流时,眼睛只能看到当前屏和下一屏,再远的视频根本没有必要提前建播放器,等滑到再说。
实现上我在VideoCard里用v-if控制:
<template> <view class="video-card"> <video v-if="shouldRenderVideo" :src="videoSrc" :poster="cover" :autoplay="active" :controls="false" :muted="false" :loop="true" :show-center-play-btn="false" :show-play-btn="false" object-fit="cover" @play="onPlay" @pause="onPause" ></video> <view v-else class="video-cover" :style="{ backgroundImage: `url(${cover})` }"> <!-- 封面兜底,降低内存消耗 --> </view> </view> </template>shouldRenderVideo的值由父组件传入,只有index === currentIndex || Math.abs(index - currentIndex) === 1时才为真。其余卡片一律只渲染一个封面图,内存占用基本可以忽略。这个策略实测效果很明显:滑动二十个视频,内存峰值稳定在200MB上下,用户完全无感。
接下来就是题目里说的那个硬需求——滑出可视区自动暂停。这里我对比了两种实现方式。
第一种是上面那个scroll事件配合currentIndex判断。这种方式的原理是:容器每屏的高度固定,当前屏索引就是scrollTop / pageHeight,索引变化了就说明滑出了可视区。切换时调用播放器上下文执行pause()和play()。这种方式实现简单、兼容性好,但只能精确到"屏"级别,如果视频卡高度不等于屏幕高度,或者页面里有固定头部、底部导航条,计算就会失真。
第二种是用IntersectionObserver来判定每个卡片与视口的交叉比例。UniApp在微信小程序端和App端都提供了createIntersectionObserver接口。这种方式更灵活,能精确判断每个卡片有多少面积露出来:
createIntersectionObserver(that, { thresholds: [0.6] }) .relativeToViewport() .observe('.feed-item', (res) => { if (res.intersectionRatio >= 0.6) { const targetIndex = res.dataset.index; // 这里拿到的是出现在视口比例大于60%的那一项 that.switchByIndex(targetIndex); } });IntersectionObserver的原理是:浏览器定期检测目标元素与根元素(这里是视口)的交叉区域比例,超过设定阈值就回调。相比scroll事件,它的优势是不会因为滚动事件频繁触发而写一堆防抖逻辑,而且语义更清晰——"哪个卡片进入视野了就直接处理"。缺点是小程序端的thresholds数组在iOS上偶尔会有兼容问题,部分基础库版本对多阈值支持不完整,还需要降级判断。
我最终采用的是方案一的scroll判断为主、方案二作为App端增强验证。实际原因很简单:scroll计算在两端表现最一致,不用操心基础库版本差异;IntersectionObserver在小程序端的坑比想象中多,iOS上从底部往上滑时偶尔会出现回调抖动,一旦抖动就会导致视频反复暂停和播放,体验非常糟。
切到新视频时的播放控制也很关键。这里要小心一个坑:直接用uni.createVideoContext(videoId)去拿播放器上下文时,如果这个video刚被v-if创建出来,上下文还没初始化完成,调用play()会被静默丢弃。所以我在VideoCard内部做了一层换挡逻辑:watch到active变成true后,等下一个tick再调用播放:
watch: { active(newVal) { if (newVal) { this.$nextTick(() => { setTimeout(() => { this.videoContext = uni.createVideoContext(this.videoId, this); this.videoContext.play(); }, 50); }); } }, }这50ms的延时是我在真机上反复调出来的经验值:太短拿不到上下文,太长视频出现有黑屏等待。不同性能的手机这个值可能略有差异,但50ms在绝大多数机型上都能稳定生效。
4. 滚动手感之外的细节:分页加载与封面预渲染
只解决"一个视频播放"还不够,真正让组件像抖音的,还有滑动的节奏感和数据的加载效率。这部分比较容易被忽视,但直接决定用户体验。
分页加载这块,我在scroll-view的触底事件@scrolltolower里接入加载逻辑:
onScrollToLower() { if (this.loading || this.noMore) return; this.loading = true; this.pageNum++; this.fetchVideoList(this.pageNum).then((newList) => { this.videoList = this.videoList.concat(newList); this.loading = false; if (newList.length === 0) this.noMore = true; }); }这里有一个容易踩的坑:如果视频数据本身没有视频尺寸信息,新加载进来的视频还没拿到宽高,video标签默认会以300x150的尺寸渲染,卡片高度就会塌陷,导致滚动位置错乱。我拿到的数据结构里每个视频都有宽高比,我强制设置了卡片高度为pageHeight,视频用object-fit: cover填充,避免因为视频比例不同导致的高度跳动。如果你们接口没给尺寸,建议在封面上先给一个固定比例占位,视频加载后再替换,这样后续的scrollTop计算才稳定。
封面预渲染也值得说。视频首次播放前会有一段黑屏等待,抖音用模糊背景和"加载中"动画扛过去了,但我们既然已经拿到了视频封面图,就应该把封面显示出来。我的做法是video标签保留poster属性,同时封面图层采用background-image渲染。这里有个体验细节:封面图不要用视频首帧截图,而应该用接口单独返回的高清封面,首帧截图往往偏暗,而且在部分手机上截取需要消耗额外性能。
另外,列表里每张卡片的高度都是pageHeight,这个设定看似平常,实际上它承担了两个作用:一是让滚动计算和可视区判定变得非常直观,二是天然实现了抖音那种"一格一屏"的翻页节奏,不需要额外的scroll-snap配置。如果你后续要在卡片上叠加热门区、话题区等不占满全屏的内容,可以在卡片内部用position: absolute做自由布局,注意这些浮层元素需要设置pointer-events避免挡住video的手势事件。
5. 多端适配实战:微信小程序和App端最常见的五个坑
UniApp的优点是一套代码多端运行,但视频组件恰恰是"一套代码各有各的脾气"的重灾区。我这次同时测了微信小程序端和App端,把遇到的典型问题和解决方案整理出来,这些都是文档里不容易查到的实战经验。
第一个坑:微信小程序端video的层级穿透。在小程序里,video是原生组件,老基础库会把原生组件压在普通组件之上,你的点赞按钮、评论按钮全部可能被video挡住点不到。解决办法是开启同层渲染:<video>标签上什么都不用做,只要保证微信基础库版本在2.10.0以上,同层渲染默认开启。如果你还在用旧基础库,那就只能在video区域外放操作按钮,或者升级基础库。实测基础库2.32.0上同层渲染稳定,按钮能正常浮在video上一层。
第二个坑:video在scroll-view里快速滚动会出现白屏闪烁。这是微信小程序的老问题,video标签在滚动容器中表现不稳定,快速滑动时视频画面跟不上滚动速度,露出白色背景。我的处理方式有两个:一是把视频画面尺寸撑满卡片再用object-fit: cover裁切,让画面边缘超出卡片边界,滚动时视觉残留会比较少;二是在滚动期间暂时给video加一个半透明黑色遮罩,等滚动结束后再撤掉。这个遮罩的切换逻辑可以用scrollBegin和scrollEnd事件配合,实测能显著降低闪烁感。
第三个坑:Android端播放器上下文与视频初始化时序。微信小程序里createVideoContext必须在onReady之后调用,而Android端有时候video元素还没渲染完成,上下文创建之后play()不报错也不生效。我的解决方式是用setTimeout延时播放,但在特别老的Android机型上延时不够还需要重试。所以我写了一个重试机制:在play()之后监听错误事件,如果两秒内没有进入播放状态,重新创建上下文再次尝试,最多重试三次。这部分代码虽然丑,但确实救命。
第四个坑:App端视频全屏播放后会退出全屏。App端如果在video上加了requestFullScreen的逻辑,调起全屏之后再次退出,部分系统版本会导致video状态异常、无法自动恢复播放。这个其实和组件无关,是UniApp的video组件在全屏接口上的兼容性问题。稳妥的做法是不要主动调起全屏,用controls属性让用户自己控制全屏;如果一定要代码控制,记得在fullscreenchange事件里重建视频上下文。
第五个坑:视频编码格式的兼容性。这个看着像后端问题,其实前端也必须知道。Android上很多手机的WebView支持H.264和VP8/VP9,但iOS微信小程序端只支持H.264编码的MP4,如果源视频是MOV格式或者HEVC编码,播放会直接失败或者只有声音没有画面。我当时的排查经历是:用户反馈iOS上黑屏但Android没问题,抓包看了半天发现视频源编码是HEVC。最终方案是要求上传端统一转码为H.264 Baseline Profile的MP4,音频转成AAC,兼容性最好。这个在接口文档里就约定好,能少踩很多生产事故。
多端调试的时候不要只看微信开发者工具,工具正常不代表真机正常。我每次发版前都会用Android低端机(内存4G以下)和iPhone SE这类小内存设备各测一遍滑动播放链路。低性能设备才是检验资源释放是否到位的标尺。
6. 组件封装技巧:让调用方只关注数据,不关心播放逻辑
信息流组件做完,不能只在当前页面用,早晚要嵌入业务。我把它抽成了一个带完整props和events的独立组件,使用方只需要传一个数据源和几个回调函数,播放暂停的逻辑全部收在内部。
组件对外暴露的接口设计如下:
props: { videoList: { type: Array, required: true }, pageHeight: { type: Number, required: true }, preloadCount: { type: Number, default: 1 } }, emits: ['change', 'loadmore', 'videoClick'],change事件在currentIndex变化时触发,返回当前播放的视频对象,方便外部联动标题、评论数、点赞数等UI;loadmore在触底时触发,通知父组件加载下一页;videoClick处理视频卡片的点击行为,比如跳转详情页。
组件内部再拆分出VideoCard子组件,这样有几个好处:每个卡片的播放器上下文作用域独立,不会互相污染;后续要在卡片上叠加评论、打赏、分享等业务入口,只需要在VideoCard内部加slot即可,不会影响父组件的滚动逻辑。
有一点需要特别提醒:组件化之后,父子组件之间的通信要克制。不要每个子组件都直接操作全局事件或Vuex,播放器状态属于高频变化数据,频繁走全局总线会影响性能。我让VideoCard自身维护playing状态,父组件只下发active标志,子组件把自己观察到的播放状态通过update:status事件上报。这样数据流清晰,也没有多余的性能开销。
7. 收尾调试:一套可以抄作业的上线前检查清单
组件功能做完不代表能直接上线,我每次发布前都会按下面这个清单过一遍。这不是理论推演,全是真机踩坑后沉淀下来的项目经验,分享出来省得大家再走一遍弯路。
检查列表引擎:
- 确认
pageHeight获取时机:不要在onLoad里取,要在onReady后取,否则手机上偶尔拿到的是不带导航栏的纯窗口高度,导致底部露出黑边。 - 确认
currentIndex边界:初始化一定要设置成0,且等第一张卡片渲染完成后再调switchByIndex(0),否则首屏视频不自动播放。 - 确认视频失败回调:给video绑定
@error事件,加载失败时给用户一个"视频加载失败,点击重试"的兜底UI,不能让黑屏从头挂到尾。 - 确认
videoContext.stop()的清理:离开页面或切到其他Tab时,调用所有已创建上下文的stop()并置空,释放播放器资源。 - 确认触底加载与滚动末端的联动:抖音在滑动到倒数第二条就会触发预加载下一页,我这里的
scrolltolower阈值默认是底部50px,数据量大的时候建议根据屏幕高度调整这个阈值,让加载更早发生。
Manifest配置检查:
- App端需要在manifest的"App常用其它设置"里勾选视频播放相关的权限和模块。如果用了video组件但不打包原生播放器控件(controls=false),有些第三方统计SDK会把它识别成未使用音视频能力,但这不影响功能。
- 微信小程序端要确认
app.json里没有禁用scroll-view的增强特性,真机预览时打开"不校验合法域名",否则视频CDN域名未配置时视频直接加载不出来。正式发布前一定把视频域名加进小程序的downloadFile合法域名列表,注意不是request域名,是downloadFile。
低端机压力测试:
- 连续快速滑动50个视频,监控内存是否有持续上涨,如果内存回收正常,峰值会稳定在一个平台;如果一直上涨说明有播放器实例没被释放。
- iOS设备上连续滑动后返回监听一下音频会话,部分系统版本会出现视频暂停但音频还在播的异常。碰到这种情况,在生命周期
onHide里强制停掉所有播放即可。 - 断网状态下进入列表:封面图能显示,视频静止在首帧,不能出现白屏和卡死,用户点击重试后网络恢复要能继续播。
我个人做这款组件最大的感受是:仿抖音交互的难点根本不在UI还原,而在资源管理和时机控制。UI和样式一天就能做好,真正需要反复调的是"什么时候播、什么时候停、什么时候预加载、什么时候释放"这套循环。希望这篇能帮你在做的时候少踩几个坑,把精力放在真正有价值的业务逻辑上。
本文还有配套的精品资源,点击获取