2026/10/13 2:13:17

TinyMCE 富文本编辑器本地视频上传与预览完整实现方案

TinyMCE 富文本编辑器本地视频上传与预览完整实现方案 富文本编辑器里插入视频这件事看起来只是点一下上传按钮但真到落地的时候坑比想象中多。我最近在做一个内容管理后台用的就是 tinymce需求很明确运营同学要在文章里插入本地视频上传完能直接在编辑器里预览、能拖动进度条、能调尺寸而不是上传完只显示一个冷冰冰的链接或者一个占位图标。这个需求听起来朴素但把 tinymce 的媒体插件、上传接口、预览渲染这几块串起来中间涉及的技术点其实不少。这篇就把我完整的实现思路、踩过的坑和最终跑通的方案摊开讲一遍适合正在用 tinymce 做后台、需要处理本地视频上传预览的开发者参考前端新手也能跟着一步步复现。1. 需求拆解与整体方案设计1.1 为什么原生 media 插件不够用tinymce 自带一个 media 插件默认行为是让你填一个视频的 URL然后它生成一个video或者iframe标签塞进内容里。问题在于它只认 URL不认本地文件。运营同学手里拿到的是手机拍的 mp4、剪辑软件导出的 mov他们不可能先自己传到某个图床拿到链接再回来填。所以核心矛盾是编辑器需要接管本地文件选择 → 上传 → 拿到可访问地址 → 插入预览这一整条链路。原生 media 插件还有第二个问题它插入的预览在编辑区里往往只是一个占位框或者依赖浏览器对video标签的默认渲染尺寸、封面、控制条都不受控。我们要的是所见即所得上传完立刻能在编辑区里播放。第三个问题是上传本身。tinymce 提供了images_upload_handler这个钩子来处理图片上传但视频没有对应的官方钩子。很多人第一反应是去找media_upload_handler结果发现根本不存在这个配置项。这就是第一个大坑后面会详细说。1.2 整体链路设计我把整个方案拆成四段入口层在 tinymce 工具栏上加一个自定义按钮点击后弹出文件选择框限定视频类型。上传层拿到 File 对象后用 FormData 通过 fetch 或 axios 发到后端上传接口后端返回一个可访问的视频地址。插入层拿到地址后用editor.insertContent把一段videoHTML 插到光标位置。预览层确保插入的video在编辑区内可播放并且 tinymce 不会把它过滤掉或者转义。这四段里第一段和第三段是常规操作第二段是核心第四段是最容易翻车的地方。因为 tinymce 有一套自己的内容过滤机制schema 和 valid_elements如果你不告诉它 video 标签和它的属性是合法的它会在你插入的瞬间把标签吃掉或者把controls、src这些属性删掉最后你看到的就剩一个空标签。提示不要试图去改 media 插件的源码来支持本地上传升级 tinymce 版本时你的改动会被覆盖而且 media 插件的内部逻辑和它的弹窗 UI 耦合很深改起来得不偿失。正确做法是自定义按钮 自定义上传逻辑。1.3 方案选型的几个取舍关于上传方式我对比过三种方案优点缺点适用场景直接用 media 插件填 URL零开发不支持本地文件视频已在CDN的场景自定义按钮 手动上传完全可控需要写上传逻辑绝大多数后台改造 media 插件复用弹窗维护成本高不推荐关于预览方式也有两种选择一种是插入video原生标签另一种是插入一个自定义的占位 div编辑时显示封面预览模式才渲染 video。我最终选了原生video标签因为运营同学需要边写边看占位 div 的体验割裂。原生标签只要把 schema 配好在编辑区里是能正常播放的。2. 核心配置与关键细节解析2.1 tinymce 初始化配置里必须打开的几个开关先看初始化配置这里面有几个参数是成败关键tinymce.init({ selector: #myEditor, plugins: media image link code preview fullscreen, toolbar: videoUpload | media image | code preview, // 关键一允许 video 标签及其属性 extended_valid_elements: video[controls|src|width|height|poster|preload|class|style|data-*], // 关键二允许 script 之外的媒体标签 media_live_embeds: true, // 关键三不要强制把相对路径转成绝对路径视后端返回而定 convert_urls: false, // 关键四允许在编辑区直接播放 valid_children: body[video], setup: function (editor) { // 注册自定义按钮 editor.ui.registry.addButton(videoUpload, { text: 上传视频, tooltip: 上传本地视频并插入, onAction: function () { openVideoDialog(editor); } }); } });逐个解释为什么这么配。extended_valid_elements是最重要的一个。tinymce 默认的 schema 里video 标签虽然不算非法但它的很多属性会被过滤。比如controls这个布尔属性如果不显式声明某些版本下会被删掉导致插入的视频没有控制条用户以为上传失败了。poster是封面图属性preload控制预加载行为这些都要列进去。>function openVideoDialog(editor) { const input document.createElement(input); input.setAttribute(type, file); input.setAttribute(accept, video/mp4,video/webm,video/ogg,video/quicktime); input.style.display none; document.body.appendChild(input); input.addEventListener(change, async function () { const file input.files[0]; if (!file) return; // 大小校验比如限制 200MB const maxSize 200 * 1024 * 1024; if (file.size maxSize) { editor.notificationManager.open({ text: 视频不能超过 200MB当前 (file.size / 1024 / 1024).toFixed(1) MB, type: error, timeout: 4000 }); document.body.removeChild(input); return; } await uploadAndInsert(editor, file); document.body.removeChild(input); }); input.click(); }这里有几个细节值得说。accept属性我列了 mp4、webm、ogg、quicktime 四种。mp4 是通用性最好的webm 体积小但 Safari 老版本支持一般ogg 现在用得少quicktime 是苹果设备拍的 mov。实际项目里我建议后端做一次转码统一转成 mp4H.264 编码因为浏览器对 mp4 的支持是最稳的。前端 accept 只是给用户一个筛选提示不能作为安全校验真正的格式校验必须放后端。大小校验放在前端是为了体验避免用户传了 500MB 等半天才被告知超限。但前端校验可以被绕过后端必须再校验一次。200MB 这个阈值是我根据后台实际使用场景定的运营传的多是几分钟的产品演示视频200MB 够用。如果是长视频场景建议改成直传对象存储 分片上传不要走普通表单接口。editor.notificationManager.open是 tinymce 自带的轻提示比alert体验好不会打断操作而且样式和编辑器一致。2.3 上传逻辑与进度反馈上传这块我用 fetch 封装了一个带进度回调的函数。注意原生的 fetch 不支持上传进度要看进度得用 XMLHttpRequest。这是个很多人踩的坑以为 fetch 能监听onprogress其实不行。function uploadVideo(file, onProgress) { return new Promise((resolve, reject) { const xhr new XMLHttpRequest(); const formData new FormData(); formData.append(file, file); formData.append(type, video); xhr.upload.addEventListener(progress, function (e) { if (e.lengthComputable) { const percent Math.round((e.loaded / e.total) * 100); onProgress onProgress(percent); } }); xhr.addEventListener(load, function () { if (xhr.status 200 xhr.status 300) { try { const res JSON.parse(xhr.responseText); // 约定后端返回 { code: 0, data: { url: ... } } if (res.code 0 res.data res.data.url) { resolve(res.data.url); } else { reject(new Error(res.message || 上传失败)); } } catch (err) { reject(new Error(返回数据解析失败)); } } else { reject(new Error(HTTP xhr.status)); } }); xhr.addEventListener(error, function () { reject(new Error(网络错误)); }); xhr.open(POST, /api/upload/video, true); // 如果有登录态带上 token xhr.setRequestHeader(Authorization, Bearer getToken()); xhr.send(formData); }); }用 XHR 而不是 fetch 的唯一原因就是进度。如果你不需要进度条fetch 完全够用代码还更简洁。但视频文件动辄几十上百兆没有进度反馈用户会以为卡死了所以进度这块我建议一定要做。进度展示我用的是 tinymce 的 notification但 notification 不支持动态更新文本至少在我用的版本里不行所以我改成在编辑器上方浮一个自定义的进度条 div上传完再移除。这个细节后面在实操部分会展开。2.4 插入内容与预览渲染拿到 URL 之后插入 HTML 这一步看似简单其实有讲究function insertVideo(editor, url, posterUrl) { const html video src${url} controlscontrols preloadmetadata width480 stylemax-width:100%;height:auto;display:block;margin:10px auto; ${posterUrl ? poster${posterUrl} : } /videopbr/p; editor.insertContent(html); }几个关键点。controlscontrols要写成完整形式不要只写controls因为 tinymce 的 schema 解析对布尔属性的简写支持不稳定写全最保险。preloadmetadata是只预加载元数据时长、尺寸不预加载整个视频能省流量编辑区里多个视频时体验更好。width480给个默认宽度配合max-width:100%保证在窄屏下不溢出。后面跟一个pbr/p是为了让光标能移到视频下方继续输入否则光标可能卡在 video 标签里出不来这是很常见的体验问题。poster封面图是可选的。如果后端在上传时能截取视频第一帧作为封面一起返回那体验会更好编辑区里显示的是封面而不是黑屏。截帧一般用 ffmpeg 在后端做前端做的话可以用 canvas 抓 video 的第一帧但兼容性和性能都不如后端。3. 完整实操流程与关键环节实现3.1 从零搭建一个可运行的 Demo我把整个流程按顺序走一遍你可以直接照着搭。第一步页面引入 tinymce。用 CDN 或者 npm 都行我用 npm 举例npm install tinymce --save然后在入口文件里引入核心和主题、插件import tinymce from tinymce/tinymce; import tinymce/themes/silver; import tinymce/icons/default; import tinymce/plugins/media; import tinymce/plugins/image; import tinymce/plugins/code; import tinymce/plugins/preview; import tinymce/plugins/fullscreen; import tinymce/models/dom;注意 tinymce 6 之后把 model 拆出来了import tinymce/models/dom这行不能少少了编辑器初始化会报错。这是升级到 6.x 之后很多人遇到的第一个坑。第二步准备一个 textarea 作为挂载点textarea idmyEditor/textarea第三步初始化配置就是前面 2.1 那段。这里补充一个content_style用来控制编辑区内 video 的显示content_style: body { font-family: -apple-system, sans-serif; font-size: 14px; } video { max-width: 100%; height: auto; border-radius: 4px; } 第四步写上传和插入逻辑就是前面 2.2 到 2.4 的代码。第五步后端提供一个上传接口。我用 Node Express 写个最小示例const express require(express); const multer require(multer); const path require(path); const app express(); const storage multer.diskStorage({ destination: function (req, file, cb) { cb(null, public/uploads/); }, filename: function (req, file, cb) { const ext path.extname(file.originalname); const name Date.now() - Math.random().toString(36).slice(2, 8) ext; cb(null, name); } }); const upload multer({ storage: storage, limits: { fileSize: 200 * 1024 * 1024 }, fileFilter: function (req, file, cb) { const allowed [video/mp4, video/webm, video/ogg, video/quicktime]; if (allowed.includes(file.mimetype)) { cb(null, true); } else { cb(new Error(不支持的视频格式)); } } }); app.post(/api/upload/video, upload.single(file), function (req, res) { if (!req.file) { return res.json({ code: 1, message: 没有收到文件 }); } const url /uploads/ req.file.filename; res.json({ code: 0, data: { url: url } }); }); app.use(/uploads, express.static(public/uploads)); app.listen(3000);这个后端示例里fileFilter做格式校验limits做大小校验文件名用时间戳加随机串避免冲突。生产环境还要考虑文件类型不能只信 mimetype可以伪造要用文件头魔数校验存储不要放本地磁盘要放对象存储大文件要分片。但作为 Demo这个够用了。3.2 上传进度条的具体实现前面提到 notification 不支持动态更新我实际用的是在编辑器容器里插一个浮层。具体做法function showProgress(editor, percent) { let bar document.getElementById(video-upload-bar); if (!bar) { bar document.createElement(div); bar.id video-upload-bar; bar.style.cssText position: absolute; top: 40px; left: 50%; transform: translateX(-50%); width: 300px; padding: 12px 16px; background: #fff; border: 1px solid #ddd; border-radius: 6px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); z-index: 9999; font-size: 13px; color: #333; ; bar.innerHTML div stylemargin-bottom:8px;视频上传中 span idvideo-upload-percent0%/span/div div styleheight:6px;background:#eee;border-radius:3px;overflow:hidden; div idvideo-upload-inner styleheight:100%;width:0;background:#2d8cf0;transition:width 0.2s;/div /div ; // 挂到编辑器容器上保证定位正确 editor.getContainer().style.position relative; editor.getContainer().appendChild(bar); } document.getElementById(video-upload-percent).textContent percent %; document.getElementById(video-upload-inner).style.width percent %; } function hideProgress() { const bar document.getElementById(video-upload-bar); if (bar) bar.remove(); }这里有个细节浮层要挂到editor.getContainer()上而不是document.body。挂 body 的话如果编辑器在滚动区域里浮层位置会飘。挂容器上再把容器设为position: relative浮层就跟着编辑器走了。3.3 上传完成后的插入与光标处理上传成功后调用 insertVideo然后要处理光标。前面说了在 video 后面加pbr/p但有时候用户是在段落中间插入的直接插会把段落切断。更稳妥的做法是先判断当前选区async function uploadAndInsert(editor, file) { showProgress(editor, 0); try { const url await uploadVideo(file, function (percent) { showProgress(editor, percent); }); hideProgress(); insertVideo(editor, url); editor.notificationManager.open({ text: 视频插入成功, type: success, timeout: 2000 }); } catch (err) { hideProgress(); editor.notificationManager.open({ text: 上传失败 err.message, type: error, timeout: 4000 }); } }editor.insertContent默认是替换当前选区内容。如果用户没有选中任何东西就是在光标处插入。插入后光标会停在插入内容的末尾也就是那个pbr/p里用户可以继续打字这个行为是符合预期的。3.4 编辑区预览的样式调优插入的 video 在编辑区里默认是左对齐的而且没有圆角、没有间距看起来比较糙。我在content_style里加了样式但更精细的控制可以给 video 加 class然后在 content_style 里针对 class 写样式extended_valid_elements: video[controls|src|width|height|poster|preload|class|style|data-*], content_style: video.tinymce-video { max-width: 100%; height: auto; display: block; margin: 12px auto; border-radius: 6px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); } 插入时给 video 加上classtinymce-video。这样样式和内容分离后续要改样式只改 content_style 就行不用动插入逻辑。另外编辑区里的 video 默认是可以点击播放的但如果你发现点击没反应检查一下是不是media_live_embeds没开或者是不是被某个 CSS 的pointer-events: none挡住了。我遇到过一次是外层容器有个遮罩层没关排查了半天。4. 常见问题与排查技巧实录4.1 视频插入后被过滤掉了怎么办这是最高频的问题。现象是上传成功URL 也拿到了但插入后编辑区里什么都没有或者只剩一个空的 video 标签。原因几乎都是 schema 没配对。排查顺序打开浏览器控制台看插入的 HTML 到底是什么。可以在editor.insertContent之后打印editor.getContent()。如果getContent()里 video 标签在但编辑区不显示那是渲染问题检查media_live_embeds和 content_style。如果getContent()里 video 标签没了或者属性被删了那是 schema 问题检查extended_valid_elements和valid_children。我整理了一个速查表现象可能原因解决video 标签整个消失valid_children 没配加valid_children: body[video]controls 属性丢失extended_valid_elements 没列 controls补上 controlssrc 被转成绝对路径且错误convert_urls 为 true设为 false编辑区 video 灰显不可点media_live_embeds 未开设为 true插入后光标卡住后面没跟可编辑块加pbr/p视频能播但没声音浏览器自动播放策略用户手动点播放即可不要设 autoplay4.2 上传大文件超时或失败视频文件大上传慢容易遇到几个问题。一是 Nginx 默认的client_max_body_size是 1MB超过就返回 413。要在 Nginx 配置里改成比如 200MBclient_max_body_size 200m;二是后端框架的 body 限制。Express 用 multer 的话limits.fileSize要设够。三是网关或负载均衡的超时时间默认可能只有 30 秒大文件传不完就断了要调到几分钟。四是浏览器本身对 XHR 上传没有硬性大小限制但内存占用会随文件增大而增大几百兆的文件建议用分片上传把文件切成 5MB 一片逐片上传最后合并。分片上传的复杂度高不少如果业务上视频普遍在 100MB 以内普通上传够用。4.3 预览时视频格式不支持用户传了个 mov在编辑区里放不出来。这是因为浏览器对 mov 的支持很差Chrome 基本不支持 mov 里的某些编码。解决办法有两个一是前端 accept 只允许 mp4从源头限制二是后端收到 mov 后用 ffmpeg 转码成 mp4。ffmpeg 转码命令示例ffmpeg -i input.mov -c:v libx264 -c:a aac -movflags faststart output.mp4-movflags faststart这个参数很重要它把视频的元数据moov box移到文件头部这样浏览器可以边下边播不用等整个文件下完。不加这个参数大视频在网页上要缓冲很久才能播。4.4 编辑区多个视频导致卡顿如果一篇文章里插了五六个视频每个都preloadmetadata编辑区会同时发起多个请求虽然只加载元数据但视频多了也会卡。我的做法是编辑区里默认不加载用 poster 封面代替只有用户点击播放时才真正加载。实现方式是把preload设成none然后监听点击事件动态设 src。但这个交互有点复杂如果视频数量不多三五个以内preloadmetadata是可以接受的。注意不要为了省事把preload设成auto那会预加载整个视频编辑区里插三个视频就能把带宽吃满页面直接卡死。4.5 移动端上传的兼容问题移动端浏览器对input typefile acceptvideo/*的支持是 OK 的但有几个坑。iOS 上从相册选视频拿到的 File 对象的type可能是video/quicktime即使原文件是 mp4。所以后端校验不能只认video/mp4要把 quicktime 也放进去或者干脆用文件头魔数判断。另外 iOS 上选视频后浏览器可能会对视频做一次转码再上传导致上传的文件和原文件不一致体积也可能变化。这个行为无法控制只能接受。4.6 上传接口的安全加固最后说几个安全点虽然是后台系统但也不能裸奔。一是上传接口要鉴权不能谁都能调。二是文件名不要用用户传的原始名要服务端重新生成防止路径穿越比如文件名里带../。三是存储目录不要给执行权限防止上传可执行文件。四是如果视频要对外访问最好走 CDN 并设置合适的缓存头不要直接暴露服务器磁盘路径。我在实际项目里还加了一层上传完成后异步触发一次转码任务统一转成标准 mp4转码成功前的视频地址标记为处理中前端插入时提示用户稍等。这样能保证最终所有视频格式统一播放兼容性最好。5. 一些实操心得与扩展思路5.1 关于 tinymce 版本的选择我用的 6.x5.x 和 6.x 在 API 上有一些差异主要是editor.ui.registry.addButton这个 API 是 5.x 之后才有的4.x 用的是editor.addButton。如果你还在用 4.x建议升级因为 4.x 已经停止维护了。升级时注意 model 的引入和部分插件配置项的变化。5.2 关于自定义上传按钮的图标addButton里可以用text也可以用icon。用 icon 的话要引入对应的图标包tinymce 自带的图标里没有视频上传的专用图标用text: 上传视频最省事也最直观。如果非要图标可以用image参数指定一个自定义图标 URL。5.3 后续可以扩展的方向这套方案跑通之后可以往上加的东西不少。比如上传时自动截取视频封面后端 ffmpeg 截第一帧插入时带上 poster比如支持拖拽上传把视频文件直接拖到编辑区就触发上传比如支持粘贴上传从剪贴板粘贴视频文件比如加一个视频管理面板能看到已上传的所有视频支持复用。这些都是锦上添花核心链路跑通了加这些都不难。我个人在实际操作中的体会是tinymce 这类富文本编辑器的扩展难点从来不在写代码本身而在于摸清它的 schema 过滤规则和内容处理管线。你把extended_valid_elements、valid_children、convert_urls这几个参数吃透大部分插入的内容不见了的问题都能自己排查。另外就是上传这块前端能做的是体验优化真正的安全和格式保障必须放后端别偷懒。