在Vue2项目中使用vue-quill-editor时,默认的视频上传功能往往无法满足实际业务需求。我遇到过不少项目,客户都提出要支持大文件上传、显示视频封面、适配移动端播放等特殊要求。默认的视频上传功能有几个明显痛点:
首先是上传流程不可控。默认会直接将视频转为base64嵌入到内容中,这种方式对于小文件还行,但遇到几十MB的视频就会导致内容异常臃肿。实测下来,一个5分钟的视频就能让内容体积暴涨到几十MB,严重影响页面加载速度。
其次是缺乏必要的视频控制。默认生成的video标签缺少封面图设置,在移动端经常出现黑屏或者只有播放按钮的情况,用户体验很差。特别是在微信内置浏览器中,各种兼容性问题更是层出不穷。
最后是样式适配问题。PC端看起来正常的视频,到了手机上要么显示不全,要么被拉伸变形。有次客户发来截图质问为什么视频在iPhone上显示异常,排查半天才发现是缺少响应式处理。
先确保项目已经初始化并安装了Vue2。我推荐使用以下命令安装核心依赖:
bash复制npm install vue-quill-editor quill --save
npm install quill-image-resize-module quill-image-drop-module --save-dev
这里有个坑要注意:如果直接安装vue-quill-editor出现"Cannot read property 'imports' of undefined"错误,多半是quill版本兼容问题。我建议显式安装quill作为主依赖,而不是让它作为vue-quill-editor的子依赖。
在main.js中需要完成三件事:注册Quill组件、加载CSS样式、配置图片处理模块:
javascript复制import VueQuillEditor from 'vue-quill-editor'
import 'quill/dist/quill.core.css'
import 'quill/dist/quill.snow.css'
import 'quill/dist/quill.bubble.css'
// 图片处理模块
import ImageResize from 'quill-image-resize-module'
import { ImageDrop } from 'quill-image-drop-module'
Quill.register('modules/imageResize', ImageResize)
Quill.register('modules/imageDrop', ImageDrop)
Vue.use(VueQuillEditor)
在vue.config.js中还需要添加webpack配置,避免运行时出现Quill未定义的问题:
javascript复制const webpack = require('webpack')
module.exports = {
configureWebpack: {
plugins: [
new webpack.ProvidePlugin({
'window.Quill': 'quill/dist/quill.js',
'Quill': 'quill/dist/quill.js'
})
]
}
}
在页面中使用编辑器时,建议封装成独立组件。这是我常用的基础配置:
html复制<template>
<div class="editor-container">
<quill-editor
ref="myQuillEditor"
v-model="content"
:options="editorOption"
class="my-quill-editor"
/>
</div>
</template>
<script>
export default {
data() {
return {
content: '',
editorOption: {
placeholder: '请输入内容...',
modules: {
toolbar: [
['bold', 'italic', 'underline'],
['blockquote', 'code-block'],
[{ header: 1 }, { header: 2 }],
['link', 'image', 'video']
],
imageResize: {
displayStyles: {
backgroundColor: 'transparent'
}
}
}
}
}
}
}
</script>
移动端适配需要特别注意两点:图片大小和编辑器高度。这是我总结的CSS方案:
css复制/* 基础编辑器样式 */
.ql-editor {
min-height: 300px;
background: #fff;
}
/* PC端样式 */
@media (min-width: 768px) {
.ql-editor img,
.ql-editor video {
max-width: 80%;
height: auto;
}
}
/* 移动端样式 */
@media (max-width: 767px) {
.ql-editor {
padding: 10px;
}
.ql-editor img,
.ql-editor video {
width: 100%!important;
height: auto!important;
}
}
/* 固定编辑器高度避免工具栏溢出 */
.ql-toolbar.ql-snow + .ql-container.ql-snow {
height: 500px;
overflow-y: auto;
}
默认的视频处理使用iframe嵌入,我们需要自定义Video Blot来替换它。在utils目录下创建quillVideo.js:
javascript复制import { Quill } from 'vue-quill-editor'
const BlockEmbed = Quill.import('blots/block/embed')
const Link = Quill.import('formats/link')
class Video extends BlockEmbed {
static create(value) {
const node = super.create()
node.setAttribute('controls', 'controls')
node.setAttribute('poster', value.poster || '')
node.setAttribute('src', this.sanitize(value.url))
node.setAttribute('style', 'object-fit: contain; max-width: 100%;')
return node
}
static value(domNode) {
return {
url: domNode.getAttribute('src'),
poster: domNode.getAttribute('poster')
}
}
}
Video.blotName = 'video'
Video.className = 'ql-video'
Video.tagName = 'video'
export default Video
在组件中注册这个自定义模块:
javascript复制import Video from '@/utils/quillVideo'
Quill.register(Video, true)
使用Element UI的Upload组件实现上传对话框:
html复制<el-dialog title="上传视频" :visible.sync="showVideoDialog">
<el-upload
class="video-uploader"
drag
action="/api/upload/video"
:before-upload="beforeVideoUpload"
:on-success="handleVideoSuccess"
:headers="uploadHeaders"
>
<i class="el-icon-upload"></i>
<div class="el-upload__text">将视频拖到此处,或<em>点击上传</em></div>
<div class="el-upload__tip" slot="tip">
支持MP4格式,大小不超过50MB
</div>
</el-upload>
</el-dialog>
对应的上传处理方法:
javascript复制methods: {
beforeVideoUpload(file) {
const isMP4 = file.type === 'video/mp4'
const isSizeValid = file.size / 1024 / 1024 < 50
if (!isMP4) {
this.$message.error('请上传MP4格式视频!')
}
if (!isSizeValid) {
this.$message.error('视频大小不能超过50MB!')
}
return isMP4 && isSizeValid
},
handleVideoSuccess(res) {
if (res.code === 200) {
this.insertVideo({
url: res.data.url,
poster: res.data.poster || this.defaultPoster
})
this.showVideoDialog = false
} else {
this.$message.error(res.message || '上传失败')
}
},
insertVideo(video) {
const quill = this.$refs.myQuillEditor.quill
const range = quill.getSelection()
const index = range ? range.index : 0
quill.insertEmbed(index, 'video', {
url: video.url,
poster: video.poster
})
quill.setSelection(index + 1)
}
}
视频封面是提升用户体验的关键。我推荐几种处理方案:
javascript复制function getVideoPoster(file) {
return new Promise((resolve) => {
const video = document.createElement('video')
video.src = URL.createObjectURL(file)
video.addEventListener('loadeddata', () => {
const canvas = document.createElement('canvas')
canvas.width = video.videoWidth
canvas.height = video.videoHeight
canvas.getContext('2d').drawImage(video, 0, 0)
resolve(canvas.toDataURL('image/jpeg'))
})
})
}
javascript复制data() {
return {
defaultPoster: 'https://example.com/default-poster.jpg'
}
}
html复制<el-upload
class="poster-uploader"
action="/api/upload/image"
:show-file-list="false"
:on-success="handlePosterSuccess"
>
<img v-if="customPoster" :src="customPoster" class="poster">
<i v-else class="el-icon-plus"></i>
</el-upload>
微信内置浏览器对video标签有许多限制,需要特殊处理:
javascript复制static create(value) {
const node = super.create()
// 微信特定属性
node.setAttribute('x5-video-player-type', 'h5')
node.setAttribute('x5-video-orientation', 'portrait')
node.setAttribute('x5-playsinline', 'true')
node.setAttribute('playsinline', 'true')
node.setAttribute('webkit-playsinline', 'true')
// 其他标准属性
node.setAttribute('controls', 'controls')
node.setAttribute('poster', value.poster)
node.setAttribute('src', value.url)
return node
}
移动端经常遇到触摸事件冲突导致视频无法播放的问题,可以通过CSS解决:
css复制.ql-video {
pointer-events: auto !important;
touch-action: manipulation;
}
/* 防止视频被页面滚动影响 */
.ql-editor {
-webkit-overflow-scrolling: touch;
}
针对不同设备优化视频显示效果:
css复制/* 横屏设备 */
@media (orientation: landscape) {
.ql-video {
max-height: 60vh;
}
}
/* 竖屏设备 */
@media (orientation: portrait) {
.ql-video {
max-height: 40vh;
}
}
/* 平板设备 */
@media (min-width: 768px) and (max-width: 1024px) {
.ql-video {
max-width: 90%;
}
}
增强用户体验的上传进度显示:
html复制<el-progress
v-if="uploadPercent > 0"
:percentage="uploadPercent"
status="success"
class="upload-progress"
/>
对应的上传配置:
javascript复制data() {
return {
uploadPercent: 0
}
},
methods: {
beforeVideoUpload(file) {
this.uploadPercent = 0
const timer = setInterval(() => {
if (this.uploadPercent >= 100) {
clearInterval(timer)
return
}
this.uploadPercent += 10
}, 300)
// ...
}
}
前端视频压缩可以显著提升上传体验:
javascript复制async compressVideo(file) {
if (!window.FFmpeg) {
console.warn('FFmpeg not available, skip compression')
return file
}
const ffmpeg = createFFmpeg({ log: true })
await ffmpeg.load()
ffmpeg.FS('writeFile', 'input.mp4', await fetchFile(file))
await ffmpeg.run('-i', 'input.mp4', '-vcodec', 'libx264', '-crf', '28', 'output.mp4')
const data = ffmpeg.FS('readFile', 'output.mp4')
return new Blob([data.buffer], { type: 'video/mp4' })
}
以下是整合了所有功能的完整组件代码:
html复制<template>
<div class="rich-text-editor">
<!-- 编辑器主体 -->
<quill-editor
ref="editor"
v-model="content"
:options="editorOptions"
@change="onEditorChange"
/>
<!-- 视频上传对话框 -->
<el-dialog
title="上传视频"
:visible.sync="showVideoDialog"
width="80%"
>
<el-upload
drag
action="/api/upload/video"
:before-upload="beforeVideoUpload"
:on-success="handleVideoSuccess"
:on-error="handleUploadError"
:headers="uploadHeaders"
:data="uploadData"
>
<i class="el-icon-upload"></i>
<div class="el-upload__text">
将视频拖到此处,或<em>点击上传</em>
</div>
<div class="el-upload__tip" slot="tip">
支持MP4/WebM格式,不超过50MB
</div>
</el-upload>
<el-progress
v-if="uploadPercent > 0"
:percentage="uploadPercent"
:status="uploadStatus"
/>
</el-dialog>
</div>
</template>
<script>
import Video from '@/utils/quillVideo'
Quill.register(Video, true)
export default {
props: {
value: String,
uploadUrl: String,
token: String
},
data() {
return {
content: this.value,
showVideoDialog: false,
uploadPercent: 0,
uploadStatus: 'success',
editorOptions: {
modules: {
toolbar: {
container: [
['bold', 'italic', 'underline'],
['blockquote', 'code-block'],
['link', 'image', 'video'],
['clean']
],
handlers: {
video: this.showVideoUploadDialog
}
}
}
}
}
},
computed: {
uploadHeaders() {
return {
Authorization: `Bearer ${this.token}`
}
},
uploadData() {
return {
type: 'video'
}
}
},
methods: {
showVideoUploadDialog() {
this.showVideoDialog = true
this.uploadPercent = 0
},
async beforeVideoUpload(file) {
this.uploadStatus = 'success'
// 格式验证
const validTypes = ['video/mp4', 'video/webm']
if (!validTypes.includes(file.type)) {
this.$message.error('请上传MP4或WebM格式视频')
return false
}
// 大小验证
const maxSize = 50 * 1024 * 1024 // 50MB
if (file.size > maxSize) {
this.$message.error('视频大小不能超过50MB')
return false
}
// 模拟上传进度
const timer = setInterval(() => {
if (this.uploadPercent >= 90) {
clearInterval(timer)
return
}
this.uploadPercent += 10
}, 300)
return true
},
handleVideoSuccess(res) {
this.uploadPercent = 100
if (res.success) {
setTimeout(() => {
this.insertVideo({
url: res.data.url,
poster: res.data.poster
})
this.showVideoDialog = false
this.uploadPercent = 0
}, 500)
} else {
this.uploadStatus = 'exception'
this.$message.error(res.message || '上传失败')
}
},
handleUploadError() {
this.uploadStatus = 'exception'
this.$message.error('上传过程中发生错误')
},
insertVideo(video) {
const quill = this.$refs.editor.quill
const range = quill.getSelection()
const index = range ? range.index : 0
quill.insertEmbed(index, 'video', {
url: video.url,
poster: video.poster
})
quill.setSelection(index + 1)
},
onEditorChange() {
this.$emit('input', this.content)
}
},
watch: {
value(newVal) {
if (newVal !== this.content) {
this.content = newVal
}
}
}
}
</script>
<style scoped>
.rich-text-editor {
margin: 20px 0;
}
/* 编辑器高度控制 */
.ql-container {
min-height: 300px;
max-height: 600px;
overflow-y: auto;
}
/* 移动端适配 */
@media (max-width: 768px) {
.ql-toolbar {
flex-wrap: wrap;
height: auto;
}
.ql-toolbar button {
margin: 2px;
}
}
</style>