最近继续开发 vx-sim 微信对话模拟器 gitee.com/maple2133/v... 这个项目,有兴趣的兄弟可以给帮忙点个Star⭐。
看了看微信,他的视频是显示的第一帧,然后加了一个播放图标。
简单实现了一下,后来想了想决定封装一下,免得大家有类似的需求还再鼓捣一遍。

用的是 Vue3+Ts 封装的自动提取视频第一帧作为封面的组件,纯前端实现、无第三方依赖,复制代码就能直接用。
核心原理:用"画布"给视频"拍照"
我们可以把视频想象成「一帧帧连续的图片合集」,而我们要做的,就是把这合集中的"第一张"(第一帧)单独抽出来,保存成图片。
这里用到了两个核心技术:HTML5的 video 标签和 canvas 标签。
实现步骤如下:
-
视频加载 :用户上传视频后,我们先通过
URL.createObjectURL()生成一个临时的视频预览地址,让浏览器能加载并渲染这个视频。 -
定格第一帧 :视频加载完成后,我们把视频的播放进度调到0.1秒(注意这里必须是0.1秒),此时视频会定格在第一帧。
-
绘制封面 :用canvas画布,把视频当前定格的那一帧"画"下来,再通过
canvas.toDataURL()把画布内容转成base64格式的图片。 -
交互优化:把封面图覆盖在视频上方,点击封面就播放视频,同时隐藏封面,还原自然的使用体验。
完整实现代码
html
<template>
<div class="video-upload-container">
<!-- 隐藏原生文件上传框,用自定义样式触发 -->
<input
ref="fileInputRef"
type="file"
accept="video/*"
class="file-input"
@change="handleFileChange"
/>
<!-- 视频预览区域:封面 + 视频播放器 -->
<div v-if="videoInfo.url" class="video-preview">
<!-- 视频播放器,原生控件,支持播放/暂停/进度条 -->
<video
ref="videoRef"
class="video-player"
:src="videoInfo.url"
controls
preload="auto"
@loadeddata="handleVideoLoaded"
>
您的浏览器不支持视频播放,请升级浏览器
</video>
<!-- 封面图:默认覆盖在视频上,点击播放后隐藏 -->
<img
v-if="videoInfo.cover"
:src="videoInfo.cover"
class="video-cover"
@click="handlePlayVideo"
alt="视频封面"
/>
</div>
<!-- 无视频时的占位提示,点击触发上传 -->
<div v-else class="upload-placeholder" @click="triggerFileInput">
<span>点击上传视频</span>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
interface VideoInfo {
url: string // 视频本地预览地址(临时URL)
cover: string // 封面图base64地址
}
const videoRef = ref<HTMLVideoElement | null>(null)
const fileInputRef = ref<HTMLInputElement | null>(null)
// 响应式存储视频和封面信息
const videoInfo = reactive<VideoInfo>({
url: '',
cover: ''
})
/**
* 触发文件选择框(自定义占位区点击时调用)
*/
const triggerFileInput = () => {
// 模拟点击原生文件输入框
fileInputRef.value?.click()
}
/**
* 处理文件选择变化(用户上传视频后触发)
*/
const handleFileChange = (e: Event) => {
const target = e.target as HTMLInputElement
const file = target.files?.[0]
if (!file) return // 没有选择文件则退出
// 生成视频本地预览地址:浏览器临时生成的URL,仅当前页面有效
videoInfo.url = URL.createObjectURL(file)
}
/**
* 视频加载完成后,提取第一帧作为封面
* 监听video的loadeddata事件(视频元数据加载完成,可开始渲染)
*/
const handleVideoLoaded = () => {
const video = videoRef.value
if (!video) return
// 解决跨域截图失败问题:允许跨域访问视频资源
video.crossOrigin = 'anonymous'
// 关键操作:将视频进度设为0.1秒(避免0秒时黑屏,后面讲坑点)
video.currentTime = 0.1
// 监听视频的canplay事件(视频可正常播放,此时帧已渲染完成)
video.oncanplay = () => {
// 调用截图方法,生成封面
captureVideoCover(video)
}
}
/**
* 核心方法:截取视频当前帧,生成封面图
* @param video 视频DOM元素
*/
const captureVideoCover = (video: HTMLVideoElement) => {
// 创建canvas画布(相当于"拍照的底片")
const canvas = document.createElement('canvas')
// 获取画布的2D绘图上下文(相当于"画笔")
const ctx = canvas.getContext('2d')
if (!ctx) return // 浏览器不支持canvas则退出
// 关键:设置画布尺寸与视频实际尺寸一致,避免封面拉伸变形
canvas.width = video.videoWidth
canvas.height = video.videoHeight
// 把视频当前帧画到画布上(x,y坐标从0开始,宽高与画布一致)
ctx.drawImage(video, 0, 0, canvas.width, canvas.height)
// 将画布内容转成base64格式的图片(jpeg格式,画质0.8,可调整)
videoInfo.cover = canvas.toDataURL('image/jpeg', 0.8)
// 优化体验:截图完成后,暂停视频并回到初始位置
video.pause()
video.currentTime = 0
}
/**
* 点击封面图,播放视频并隐藏封面
*/
const handlePlayVideo = () => {
const video = videoRef.value
if (!video) return
video.play() // 播放视频
videoInfo.cover = '' // 隐藏封面
}
</script>
<style scoped>
.video-upload-container {
width: 100%;
max-width: 600px;
margin: 0 auto;
}
.file-input {
display: none;
}
.upload-placeholder {
width: 100%;
height: 320px;
border: 2px dashed #ccc;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: #666;
font-size: 16px;
transition: border-color 0.3s;
}
.upload-placeholder:hover {
border-color: #42b983;
}
.video-preview {
position: relative;
width: 100%;
height: 320px;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.video-player {
width: 100%;
height: 100%;
object-fit: cover;
}
.video-cover {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
cursor: pointer;
transition: opacity 0.3s;
}
.video-cover:hover {
opacity: 0.9;
}
</style>
避坑!避坑!避坑!
一定要把currentTime设为0.1秒
如果把video.currentTime设为0秒,很多时候会出现"封面黑屏"的情况。
原因是:视频的0秒的帧可能还没加载完成,或者部分视频的0秒帧是黑屏(比如视频开头的过渡帧)。
把时间设为0.1秒,相当于"等视频加载完第一帧后再截图",既能保证帧已渲染完成,又能避免黑屏问题。
视频跨域
如果你的视频是从服务器加载的(不是本地上传),可能会出现"canvas截图失败"的情况,控制台会报跨域错误。
这是因为浏览器的同源策略限制:canvas不能绘制跨域的资源。
加上 video.crossOrigin = "anonymous";,相当于告诉浏览器"允许跨域访问这个视频资源"。
另外配合服务器设置CORS(跨域资源共享),就能解决跨域截图的问题。
如果是本地上传的视频,这行代码也可以加上,提前规避问题。
封面拉伸变形
canvas的宽高必须设置为 video.videoWidth 和 video.videoHeight(视频实际宽高),而不是固定值,防止出现拉伸变形。
总结
其实这个视频组件的封装,核心就是"利用HTML5原生API,把复杂的操作拆分成简单的步骤":
上传视频 → 加载视频 → 截取帧 → 生成封面 → 优化交互。

没有复杂的第三方依赖,全靠原生API和Vue3的组合式API就能实现。