1. 需求场景与问题回顾
1.1 默认的视频插入方式为什么不够用
Tinymce作为富文本编辑器,在Vue后台项目里用得非常多,尤其是企业后台的内容管理、公告发布、课程详情编辑这类场景。视频插入是一个高频需求,但很多人在第一次接触Tinymce时都会遇到同一个问题:编辑器的媒体插件默认只支持通过URL地址插入视频,也就是你得先有一个公网可访问的视频文件地址,才能粘贴进去。可是实际业务里,运营和编辑手里拿到的往往是本地的mp4、webm文件,他们希望像上传图片一样,直接点击一个按钮选择本地视频,然后自动上传到服务器,插入到内容中。这时候就需要我们自己定制本地上传视频的能力。
我在做一个某高校课程内容管理后台的时候,就碰到了这个场景。教务人员需要把课程录屏、实验演示视频插入到课程介绍页面里,每次都要让技术人员先去运维那边要一个可用的静态资源地址,再把链接粘贴到编辑器里,流程极其痛苦。后来产品提了个需求:在编辑器工具栏里加一个“视频上传”按钮,点击后可以选择本地视频文件,上传完成后自动把视频地址插入到编辑区,并且还能正常预览播放。
这个需求听起来简单,但真正动手做的时候,我踩了不少坑,尤其是Tinymce的媒体插件逻辑和自定义上传之间有不少冲突。这篇文章把我整个实现过程和排查经验整理出来,希望能帮到正在被这个需求折磨的人。
1.2 自定义本地上传视频的完整需求边界
在动手写代码之前,先把需求边界理清楚。很多人一上来就写代码,结果做到一半发现这里不对那里不对,根源就是没把需求边界定好。我最终把需求拆成了下面几个点:
- 编辑器工具栏上有视频上传按钮,点击后能够弹出本地文件选择框。
- 只允许选择视频格式,比如mp4、webm、ogg,其他类型文件直接拦截。
- 视频文件大小需要限制,避免用户误传几个G的大文件。
- 选择视频后自动上传到后端,上传期间需要有进度提示。
- 上传成功后将返回的URL自动插入到编辑器内容中,并且以video标签的形式插入,保证前端能正常播放。
- 如果后端返回错误,需要给出可读的错误提示,而不是静默失败。
- 整个流程要兼容Tinymce内部的内容过滤逻辑,不能被编辑器自动修改掉video标签的属性。
定好边界后,再去查Tinymce的文档动手实现,思路就清晰多了。实际上Tinymce本身提供了一套扩展机制,我们只需要在初始化配置里挂上钩子就行。
2. Vue项目接入Tinymce的准备工作
2.1 组件安装与本地化资源处理
Tinymce的npm包分两块:运行时脚本和语言包。很多人在安装时只装了tinymce主包,却忘记把语言包和主题资源复制到项目里,导致编辑器白屏或者全是英文界面。
我用的是Vue3 + Vite的技术栈,安装命令很简单:
npm install @tinymce/tinymce-vue npm install tinymce安装完成后,需要把tinymce包里的skins、themes、plugins、icons这几个目录拷贝到Vue项目的公共资源目录。以Vite为例,我习惯放在public/tinymce目录下,这样构建时不会被打包处理,避免资源路径被hash后找不到。
然后还需要单独安装中文语言包。Tinymce的npm包里默认不带语言包,需要去官网下载langs文件夹,或者直接从jsdelivr拉取:
curl -o public/tinymce/langs/zh_CN.js https://cdn.jsdelivr.net/npm/tinymce@6/lang/skins/...注意语言包路径要和初始化配置里的language_url对应,否则中文翻译不会生效。我这一套操作做完后,编辑器就能正常显示中文界面和工具栏了。
2.2 视频插件的基础配置
Tinymce里负责视频的插件叫media,初始化配置时要在plugins数组里加上它,同时还要在toolbar配置里加入media按钮。不加插件的话,工具栏上是不会出现视频按钮的。
基础的配置大致长这样:
tinymce.init({ selector: '#myTextarea', plugins: 'media', toolbar: 'media', media_live_embeds: true, media_alt_source: false, media_filter_html: false, media_dimensions: true, media_poster: false });这里有一个关键的配置项容易忽略:media_filter_html。默认情况下这个值是true,意味着编辑器在渲染时会过滤掉视频标签内它认为不合法的属性,比如自定义的>const editorInit = { height: 500, language: 'zh_CN', language_url: '/tinymce/langs/zh_CN.js', plugins: 'media image code lists link table', toolbar: 'undo redo | media image | code', // 关键是这个回调 file_picker_callback: (callback, value, meta) => { // meta.filetype 会告诉你当前点击的是 image 还是 media if (meta.filetype === 'media') { // 创建一个隐藏的 input file const input = document.createElement('input'); input.setAttribute('type', 'file'); input.setAttribute('accept', 'video/mp4,video/webm,video/ogg'); input.style.display = 'none'; document.body.appendChild(input); input.click(); input.onchange = async function () { const file = input.files[0]; if (!file) return; // 校验文件大小,超过100MB直接提示 if (file.size > 100 * 1024 * 1024) { alert('视频大小不能超过100MB'); return; } try { // 上传到自己的接口拿到可访问URL const videoUrl = await uploadVideo(file); // 调用回调,把URL传给编辑器内部逻辑 callback(videoUrl, { title: file.name, error: false }); } catch (err) { alert('上传失败:' + err.message); } finally { // 销毁临时input document.body.removeChild(input); } }; } } };
这里有个细节需要注意:meta.filetype在点击媒体插件按钮时值是'media',点击图片按钮时值是'image'。判断好这个类型,才能让同一个回调同时处理图片和视频上传而不互相干扰。
另一个容易踩的坑是:callback的第二个参数如果传了{ error: true },编辑器会认为上传出错,可能会弹出错误提示或者不插入内容。我第一次实现时传的是{ error: false },后面发现必须显式传这个字段,否则某些版本会提示资源加载失败。
3.2 前端上传细节:FormData与进度反馈
视频文件通常比较大,上传过程不能没有反馈。file_picker_callback本身不提供进度条的UI,但我们可以自己在回调里创建进度提示元素,或者使用现成的UI库全局Loading。
实际项目中我是用了Element Plus的ElUpload和ElMessage组件的那套交互风格,但在file_picker_callback里没法直接复用Upload组件的完整能力,所以自己封装了上传函数:
function uploadVideo(file) { return new Promise((resolve, reject) => { const formData = new FormData(); // 后端约定的字段名,一定要和后端一致 formData.append('file', file); // 还可以加一些自定义参数 formData.append('source', 'tinymce_video'); const xhr = new XMLHttpRequest(); xhr.open('POST', '/api/video/upload', true); // 监听上传进度 xhr.upload.onprogress = (e) => { if (e.lengthComputable) { const percent = Math.round((e.loaded / e.total) * 100); // 这里可以更新自定义进度条 if (window.showUploadProgress) { window.showUploadProgress(percent); } } }; xhr.onload = function () { if (xhr.status === 200) { try { const resp = JSON.parse(xhr.responseText); if (resp.code === 0) { // 返回的视频完整URL resolve(resp.data.url); } else { reject(new Error(resp.message || '上传失败')); } } catch (e) { reject(new Error('服务端返回数据解析失败')); } } else { reject(new Error(`HTTP ${xhr.status}`)); } }; xhr.onerror = function () { reject(new Error('网络异常')); }; xhr.send(formData); }); }为什么不用fetch?因为fetch监听上传进度需要依赖stream相关支持,兼容性和可操作性都不如XHR直观。在上传大视频这种场景下,XHR虽然老,但足够稳定。
我这里用了自定义进度提示,实际项目中还可以通过window.showUploadProgress挂一个全局函数,也可以把这个函数作为参数传入编辑器配置中,但那样会让初始化配置变得臃肿。更简洁的做法是封装一个工具函数,重写window.showUploadProgress来驱动页面上的进度条组件。
3.3 后端接口设计与参数约定
前端封装好了,还得看后端买不买账。视频上传接口和普通图片上传接口最大的区别在于:视频文件大,服务端需要处理流式接收,同时要对文件类型和大小做严格校验。我以Node.js的Express为例,写一个最基础的接收接口:
const multer = require('multer'); const path = require('path'); const storage = multer.diskStorage({ destination: (req, file, cb) => { cb(null, 'uploads/videos/'); }, filename: (req, file, cb) => { const ext = path.extname(file.originalname); // 生成随机文件名,避免覆盖 cb(null, Date.now() + '-' + Math.round(Math.random() * 10000) + ext); } }); const upload = multer({ storage: storage, limits: { fileSize: 100 * 1024 * 1024 }, // 限制100MB fileFilter: (req, file, cb) => { // 只接受视频格式 const allowedTypes = ['video/mp4', 'video/webm', 'video/ogg']; if (allowedTypes.includes(file.mimetype)) { cb(null, true); } else { cb(new Error('仅支持mp4/webm/ogg视频格式')); } } }); app.post('/api/video/upload', upload.single('file'), (req, res) => { if (!req.file) { return res.status(400).json({ code: 1, message: '未接收到文件' }); } // 拼接完整可访问URL,注意域名和静态资源前缀 const videoUrl = `https://your-cdn-domain.com/videos/${req.file.filename}`; res.json({ code: 0, data: { url: videoUrl, name: req.file.originalname } }); });这里有两个关键约定,前端和后端必须保持一致:
file字段名:前端FormData.append('file', file),后端upload.single('file'),一旦不一致,后端收到的就是空文件。code字段含义:前端约定code === 0表示成功,后端也必须按这个口径返回,不能一会儿是code: 0,一会儿是success: true。
我一开始做联调时,后端返回的结构是{ status: 200, data: { url: '...' } },前端判断code === 0永远等于false,导致上传一直报“上传失败”。后来统一了返回结构才跑通。
4. 上传过程中的典型坑与排查实录
4.1 file_picker_callback重复触发的问题
在我第一次接入的时候,出现了一个很奇怪的现象:点击一次视频按钮,浏览器会弹出两个文件选择框。排查的时候我一度以为是点击事件冒泡导致按钮触发了两次,后来在file_picker_callback里加了打印日志才发现,这个回调本身就被调用了两次。
原因出在初始化配置上。我的plugins里同时配置了image和media两个插件,而file_picker_callback是全局回调,这两个插件在初始化时都会去注册自己的文件选择器逻辑。当我在代码里写了一个隐藏的input并调用click()时,编辑器内部的某种状态被重复触发,导致弹出了两次文件选择框。
后来我采用了一个更稳妥的方案:不用input.click()直接触发,而是在回调里统一管理一个单例的input元素,并且给input加一个标记,避免重复创建:
let videoInput = null; file_picker_callback: (callback, value, meta) => { if (meta.filetype !== 'media') return; if (!videoInput) { videoInput = document.createElement('input'); videoInput.type = 'file'; videoInput.accept = 'video/*'; videoInput.style.display = 'none'; document.body.appendChild(videoInput); } videoInput.onchange = () => { // ...上传逻辑 }; // 先将值清空,确保选择同一文件也能触发change videoInput.value = ''; videoInput.click(); }这里有个小技巧:videoInput.value = ''很重要。因为如果用户第一次选择了a.mp4,第二次还想选同一个a.mp4,浏览器会认为文件没变化而不会触发change事件。清空value后每次都能正常触发。
4.2 插入编辑器后不显示预览
上传成功,callback也调用了,URL也传入编辑器了,但编辑器里看到的是一个空白区域,没有视频预览,或者只有一个链接文本。这个问题我折腾了很久。
后来发现,Tinymce内部调用callback时传入的URL,会被media插件用默认模板包一层div,然后里面放一个video标签。默认的video标签会带有controls属性,但如果后端返回的URL是http而页面是https,浏览器会直接阻止混合内容加载,导致预览空白。
还有一种常见情况是,插入的video标签没有指定poster属性,也没有设置宽高,一些老版本的浏览器中,如果视频第一帧还没有加载出来,视频区域会显示成黑色或空白。我当时的视频区域只有黑色,后来通过设置media_dimensions: true,并且在回调中传入width和height,问题才明显改善:
callback(videoUrl, { title: file.name, width: 640, height: 360, error: false });如果你希望上传的视频最大宽度自适应容器,可以不传固定宽高,让编辑器默认插入的video保持比例,但要注意部分场景下需要保证视频不超过内容区宽度。可以在后端返回的HTML中做一层包装,或者在前端初始化配置里设置media_embed_handler,不过这个回调相对冷门,能不用尽量别用。
4.3 路径与回显问题
视频上传到服务器后,访问路径的拼接是另一个大坑。开发环境下,后端返回的URL可能是http://localhost:3000/uploads/videos/xxx.mp4;生产环境下,如果后端和前端不在同一个域名,URL必须写成完整的CDN地址。很多人在本地一切正常,部署到线上后视频全部加载不出来,基本就是这个原因。
我建议后端在返回URL时不要自己拼域名,而是把相对路径返回给前端,由前端根据环境变量拼接:
// 后端返回相对路径 res.json({ code: 0, data: { url: '/uploads/videos/xxx.mp4' } });前端拿到后,在插入编辑器前根据环境变量补全:
const videoUrl = normalizeUrl(resp.data.url); function normalizeUrl(url) { if (url.startsWith('http')) return url; const base = import.meta.env.VITE_STATIC_BASE; return base + url; }这样就把域名管理统一收敛到前端,后端的接口可以对接多个域名环境,不用每次环境变更都改后端代码。
另外,在富文本内容保存到数据库、再次回显到编辑器时,还要注意HTML清洗逻辑。我遇到过保存时video标签正常,但回显时被后端的安全过滤库把src属性里的?token=xxx给截断掉了。遇到这种问题,要么把token放在请求头里而不是URL中,要么在后端HTML过滤白名单里加入video标签的src、controls、poster、width、height属性。
4.4 格式限制与安全校验
很多人以为在前端设了accept就万事大吉,但对后端来说,用户完全可以绕过前端直接调用接口上传。所以后端必须做二次校验。我遇到过一个案例:用户把一个伪装成mp4的脚本文件上传成功,虽然暂时没有造成危害,但如果这个文件被当作视频直接在页面中加载,风险非常大。
后端校验要从两个维度做:
- MIME类型和扩展名双重校验,防止伪造后缀。
- 真实文件头校验,视频文件的前几个字节通常是固定的,比如mp4格式通常以
ftyp开头。
在Node.js中可以直接读取文件buffer做校验:
const buffer = require('buffer').Buffer; const fs = require('fs'); function checkVideoMagicNumber(filePath) { const buffer = Buffer.alloc(12); const fd = fs.openSync(filePath, 'r'); fs.readSync(fd, buffer, 0, 12, 0); fs.closeSync(fd); // mp4文件偏移4字节后通常有ftyp标识 return buffer.toString('ascii', 4, 8) === 'ftyp'; }如果校验不通过,直接删除文件并返回错误。这一层防护虽然有性能开销,但对于管理后台上传场景来说完全值得。
5. 常见问题排查速查表
我把实际开发和维护期间遇到的高频问题整理成了一张表,方便你后续遇到类似问题时快速定位:
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 点击视频按钮没反应 | 未引入media插件,或file_picker_callback中未判断meta.filetype | 在plugins中加入media,并检查meta.filetype值 |
| 弹出两个文件选择框 | file_picker_callback重复注册或重复创建input | 使用单例input,设置value=''后click() |
| 上传成功但编辑器不显示视频 | 返回URL域名协议与页面不一致,或未设置宽高 | 统一用HTTPS,回调中传入width和height |
| 视频可以播放但保存后丢失 | media_filter_html过滤了属性,或后端HTML清洗 | 设置media_filter_html: false,后端白名单加video属性 |
| 上传进度条不更新 | 使用了fetch而非XHR,或后端未返回Content-Length | 改用XHR的upload.onprogress |
| 选择同一视频文件不触发change | input的value未清空 | 每次点击前设置input.value = '' |
| 后端报文件字段缺失 | FormData字段名与后端接收字段名不一致 | 统一字段名为file |
| 大文件上传超时 | 后端或网关默认超时时间太短 | 调整服务端超时和Nginx的client_max_body_size |
| 编辑器内容回显后播放不了 | 相对路径未被正确拼接 | 前端统一根据环境变量补全静态资源地址 |
| 文字显示异常或编辑器白屏 | 语言包或皮肤资源未正确拷贝 | 检查language_url和content_css路径是否可访问 |
表中最后一条,其实也常被忽略。Tinymce默认会从npm包内部加载皮肤,但在Vite打包环境下,资源路径经常对不上。我后来统一在index.html里通过script标签全局引入Tinymce脚本,再配合tinymcevue组件初始化,反而减少了打包时的很多报错。
我的经验是:Tinymce的视频上传定制,核心难点不在于file_picker_callback怎么写,而在于各种边缘情况的处理。上面这些坑,每一个我都亲自动手验证过,有的甚至来回改了好几版。如果你的项目里还有其他特殊需求,比如要支持视频裁剪、封面图上传、多视频同时上传,大部分情况都可以基于这套方案继续扩展。你只需要把file_picker_callback里的单文件上传改成批量上传,然后把返回的多个URL循环调用callback即可。但有一个前提,后端接口得支持多文件接收。
最后再分享一个小技巧:如果你们项目的富文本内容是给用户自己发布的公开内容,建议在保存时不仅仅过滤视频标签,还要对onload、onerror、onclick这类事件属性做白名单处理,防止有人通过视频标签注入恶意脚本。毕竟视频上传是编辑器的入口,而内容安全是系统长期稳定运行的底线。