最近在项目里封装了一个视频上传组件,核心功能有 3 个:
- 支持常见视频格式上传
- 文件过大时,前端先压缩再上传
- 上传后支持视频预览、删除、回显
这篇文章就结合一个实际 Vue 组件,讲清楚 Vue 视频上传、MediaRecorder 压缩、预览弹窗、自定义上传 的完整实现思路。
一、效果说明
这个组件主要实现了下面几个能力:
- 基于
el-upload实现视频上传 - 支持上传数量限制、格式限制、大小限制
- 超过大小限制时,如果系统配置允许,就走前端压缩
- 压缩时展示进度条、预计剩余时间,并支持取消上传
- 上传完成后展示视频封面
- 点击可直接预览视频
- 支持回显服务端已有视频列表
二、上传区域怎么做
组件外层直接使用 Element UI 的 el-upload,但这里没有走默认上传,而是通过 http-request 接管上传请求:
vue
<el-upload
ref="uploadRef"
:action="uploadVideoUrl"
:http-request="submitUploadRequest"
list-type="picture-card"
:on-success="handleUploadSuccess"
:before-upload="handleBeforeUpload"
:limit="limit"
:on-error="handleUploadError"
:on-exceed="handleExceed"
name="file"
:accept="fileType.join(',')"
:show-file-list="true"
:headers="headers"
:file-list="fileList"
:disabled="disabled"
:on-remove="handleRemove"
>
</el-upload>
这里最关键的是两个钩子:
before-upload:上传前校验格式、大小,必要时执行压缩http-request:自己用axios上传压缩后的文件
这种方式比默认上传更灵活,因为我们可以在真正请求接口之前,对文件做加工。
三、先做格式和大小校验
上传前先进入 handleBeforeUpload,这里主要做了两件事:
1. 校验文件类型
组件通过 file.type 和文件后缀双重判断,避免某些浏览器拿不到标准 MIME 类型时误判。
js
if (!isVideo) {
return this.blockUpload(`文件格式不正确, 请上传${this.fileType.join("/")}视频格式文件!`);
}
2. 校验文件大小
如果视频没有超过限制,直接上传;如果超出限制,就看是否允许压缩:
js
const isLt = file.size / 1024 / 1024 < this.fileSize;
if (!isLt) {
if (!this.configNeedCompress) {
return this.blockUpload(`上传视频大小不能超过 ${this.fileSize} MB!`);
}
}
这里的设计很实用:
- 小文件直接传,节省前端算力
- 大文件才压缩,避免不必要的等待
- 是否开启压缩由配置控制,方便线上开关
四、MediaRecorder 为什么能做视频压缩
很多人提到前端压缩视频,第一反应是 ffmpeg.wasm。但这个组件走的是另一条路线:MediaRecorder + canvas.captureStream()。
实现思路是:
- 先把原视频加载到隐藏的
video元素中 - 用
canvas按目标尺寸不断绘制视频帧 - 通过
canvas.captureStream(30)生成新的视频流 - 再配合
MediaRecorder按目标码率重新编码输出 - 最后生成新的
File对象再上传
这部分非常关键。
五、压缩前先拿到视频元信息
组件先通过 video 标签读取原视频的时长、宽高、码率,用于判断是否需要压缩:
js
getVideoMeta(file) {
return new Promise((resolve, reject) => {
const video = document.createElement("video");
const objectUrl = URL.createObjectURL(file);
video.preload = "metadata";
video.onloadedmetadata = () => {
const duration = Number(video.duration) || 0;
const width = Number(video.videoWidth) || 0;
const height = Number(video.videoHeight) || 0;
URL.revokeObjectURL(objectUrl);
resolve({
duration,
width,
height,
bitrateMbps: duration > 0 ? (file.size * 8) / duration / 1024 / 1024 : 0,
});
};
video.onerror = () => {
URL.revokeObjectURL(objectUrl);
reject(new Error("failed to load video metadata"));
};
video.src = objectUrl;
});
}
当前组件的压缩判断逻辑是:
- 分辨率超过
1080P - 或码率超过
4Mbps
只要满足其中一个条件,就进入压缩流程。
六、如何生成压缩方案
拿到元信息后,组件会构建压缩计划:
js
buildCompressionPlan(file, meta) {
const over1080P = this.isOver1080P(meta);
const overBitrate = meta.bitrateMbps > BITRATE_LIMIT_MBPS;
if (!over1080P && !overBitrate) {
return null;
}
return {
outputBaseName: `${Date.now()}_${file.name.replace(/\.[^.]+$/, "") || "video"}_compressed`,
targetVideoBitrate: over1080P ? TARGET_HD_VIDEO_BITRATE : TARGET_SD_VIDEO_BITRATE,
shouldScaleTo1080: over1080P,
};
}
这里的策略比较清晰:
- 超过 1080P,就缩到 1080 高度以内
- 视频码率统一控制到目标值
- 输出一个新的压缩文件名
这个设计适合后台管理系统,优先保证"能传、够清晰、速度可控"。
七、MediaRecorder 压缩核心实现
真正的压缩逻辑在 compressVideoByMediaRecorder 里。
1. 先判断浏览器是否支持
js
isMediaRecorderCompressionSupported() {
return typeof window !== "undefined"
&& typeof window.MediaRecorder !== "undefined"
&& typeof document !== "undefined";
}
然后再通过 MediaRecorder.isTypeSupported() 选择当前浏览器可用的编码格式,比如:
video/mp4video/webmvideo/ogg
这样能尽可能兼容不同浏览器环境。
2. 通过 canvas 重绘视频帧
js
const drawFrame = () => {
if (video.ended || stoppedByCancel) {
return;
}
canvasContext.drawImage(video, 0, 0, width, height);
animationFrameId = window.requestAnimationFrame(drawFrame);
};
这一步的作用是:
- 可以顺便做分辨率缩放
- 让新输出的视频使用新的画面尺寸
3. 把音频也混进新流里
很多前端压缩方案只处理画面,结果上传后视频没声音。这个组件用 AudioContext 把原视频音轨重新混入录制流:
js
audioContext = new AudioContextClass();
audioSourceNode = audioContext.createMediaElementSource(video);
audioDestination = audioContext.createMediaStreamDestination();
audioSourceNode.connect(audioDestination);
audioDestination.stream.getAudioTracks().forEach((track) => {
mixedStream.addTrack(track);
});
这个细节非常重要,不然你得到的往往是一个"静音压缩版视频"。
4. 使用目标码率重新录制
js
const recorder = new MediaRecorder(mixedStream, {
mimeType,
videoBitsPerSecond: this.parseBitrate(plan.targetVideoBitrate),
audioBitsPerSecond: this.parseBitrate(TARGET_AUDIO_BITRATE),
});
这其实就是压缩的关键点:
- 分辨率降了
- 码率降了
- 最终文件体积自然就降下来了
八、压缩进度条怎么做
MediaRecorder 本身不会直接给你压缩百分比,所以组件采用了一个比较实用的思路:
- 用
video.currentTime / video.duration估算进度 - 用已耗时反推剩余时间
js
progressTimer = window.setInterval(() => {
const duration = Number(video.duration) || meta.duration || 0;
const current = Math.min(Number(video.currentTime) || 0, duration);
const ratio = duration > 0 ? current / duration : 0;
this.compressProgress = Math.max(1, Math.min(99, Math.round(ratio * 100)));
if (ratio > 0 && ratio < 1) {
const elapsed = (Date.now() - startTime) / 1000;
this.compressEtaText = this.formatSeconds((elapsed * (1 - ratio)) / ratio);
}
}, 200);
虽然这不是"真实编码进度",但对于用户体验已经足够友好了。
九、如何支持取消压缩和取消上传
组件还做了一个很实用的功能:用户可以取消上传。
实现思路是自己维护一个 compressAbortController,本质上是手动封装的中断器:
js
this.compressAbortController = {
abort: () => {
stoppedByCancel = true;
if (recorder.state !== "inactive") {
recorder.stop();
}
},
};
点击取消按钮后:
- 标记当前任务已取消
- 移除待上传文件
- 停止录制
- 清理上传列表里的临时项
这部分把边界处理得比较完整,用户体验会比"只能等压缩结束"好很多。
十、为什么还要自定义上传
压缩后的文件和原始文件不是同一个对象,所以默认上传已经不够用了。
组件里通过 pendingUploadFiles 暂存真正要上传的文件:
js
this.pendingUploadFiles[file.uid] = uploadFile;
然后在 submitUploadRequest 中取出压缩后的文件,通过 axios 手动上传:
js
const uploadFile = this.pendingUploadFiles[option.file.uid] || option.file;
const formData = new FormData();
formData.append("file", uploadFile, uploadFile.name);
axios({
method: "post",
url: this.uploadVideoUrl,
data: formData,
headers: {
...this.headers,
"Content-Type": "multipart/form-data",
},
onUploadProgress: (event) => {
const percent = event.total ? (event.loaded / event.total) * 100 : 0;
option.onProgress({ percent });
},
});
这样就把"压缩"和"上传"完整串起来了。
十一、视频预览怎么做
视频预览实现就比较直接了:
js
handlePreviewVideo(file) {
this.previewVideoUrl = file.url;
this.previewVideoDialog = true;
}
弹窗中放一个 video 标签:
vue
<el-dialog title="预览视频" :visible.sync="previewVideoDialog" width="900px">
<video id="video" height="520" style="width: 100%;" :src="previewVideoUrl" controls></video>
</el-dialog>
另外在关闭弹窗前调用 pause(),避免关闭后视频还在后台继续播放:
js
beforePreviewVideoClose(done) {
let videoDom = document.getElementById('video');
if (videoDom) {
videoDom.pause();
}
done();
}
十二、已上传视频如何回显
组件支持把接口返回的字符串或数组,转换成上传列表需要的数据结构:
js
const list = Array.isArray(val) ? val : val.split(",");
this.fileList = list.map((item) => {
if (typeof item === "string") {
let url;
let coverPicture;
if(item?.startsWith('http://') || item?.startsWith('https://')){
url = item;
coverPicture = item.replace('.mp4','.png');
} else {
url = this.imgUrl + item;
coverPicture = this.imgUrl + item.replace('.mp4','.png');
}
item = { name: item, url, serverPath: item, coverPicture };
}
return item;
});
这样编辑页面打开时,就能自动显示历史视频和封面图。
十三、总结
这套 Vue 视频上传组件的优点:
- 基于
el-upload,接入成本低 - 前端压缩只在超限时触发,比较省资源
MediaRecorder + canvas不依赖重型 wasm 库,方案相对轻量- 上传、预览、删除、回显都做全了
- 还有取消压缩、进度展示这类体验优化
当然,它也有几个注意点:
MediaRecorder编码能力受浏览器支持影响较大mp4在不同浏览器里的兼容性要重点测试- 超大视频前端压缩依然会比较耗时
- 管理后台适合这套方案,超专业视频处理场景更推荐服务端转码