☰
Vue+Element UI集成tinymce富文本编辑器:图片上传与本地视频上传实战
2026/10/15 8:53:14 网站建设 项目流程

最近在做后台管理系统的时候,需要在一个用 Vue + Element UI 搭起来的中台项目里加富文本编辑器。需求看上去不复杂:能传图片、能插本地视频、编辑完内容能回显。可真做起来,踩的坑比想象中多。尤其是 tinymce 这套东西,初次接触的人很容易在“本地化资源加载”和“自定义上传”这两块卡住。我把我完整的接入步骤和踩坑记录整理出来,项目里正好要用到的朋友可以直接照搬。

先说结论:tinymce 本身功能非常全面,文档也写得细,但它在 Vue 里的坑基本都在“资源路径”和“自定义上传”上,跟编辑器核心能力关系不大。只要把这两块捋顺,整个接入过程会很顺畅。

1. 方案选型:为什么在 Vue + Element UI 项目里选 tinymce

在确定用 tinymce 之前,我其实对比过市面上主流的几款 Vue 富文本方案。大部分国内团队习惯用 wangEditor,因为它中文文档友好,Element UI 风格也搭;但它的生态和扩展性相对弱,遇到“上传本地视频”这种场景时,往往要自己写很多代码去补。quill 虽然轻量,但工具栏、插件体系又是另一套心智负担。CKEditor 也很强,可是商业授权和包体积对很多项目来说并不友好。

tinymce 的优势在于,它本身是一个完整的编辑器内核,自带一整套插件机制。图片上传、媒体插入、表格、代码块、Word 粘贴清理这些能力,要么开箱即用,要么通过官方定义的 hook 去扩展。尤其在“上传本地视频”这种需求上,tinymce 的file_picker_callback机制能让你在不改写编辑器源码的前提下,直接把 Element UI 上传组件接进去。这一点我在调研的时候就觉得非常合适。

另外一个很实际的原因是:tinymce 的渲染结果就是标准的 HTML 片段。保存到后端后,再用v-html回显,几乎不需要额外转换。项目的后端同事不需要为某个编辑器特有的数据结构做适配,这对前后端联调来说能省不少沟通成本。

当然,tinymce 也有它的缺点,最大一个就是“重”。默认包体积不小,加载一堆插件和语言包后首屏体验会受影响。但我们的业务后台本身就不是给 C 端用户访问的,对首次加载速度没那么敏感,所以我可以接受。如果你是在移动端 H5 或者强交互 C 端页面里做富文本,那建议再斟酌一下。

2. 环境搭建与初始化配置

2.1 版本选择和依赖安装

第一步是装包。这里第一个坑就来了:@tinymce/tinymce-vue这个官方 Vue 封装组件,不同大版本对应不同 Vue 版本。Vue 2 项目请用 3.x,Vue 3 项目请用 4.x。我在一个 Vue 2 老项目里一开始直接装了最新版,结果组件起不来,控制台报一堆莫名其妙的错误,后来才发现是版本不对。

以 Vue 2 项目为例,安装命令如下:

npm install tinymce@5 @tinymce/tinymce-vue@3

这里我特意把tinymce锁在 5.x 而不是直接装 6.x,是因为 6.x 对浏览器兼容性要求更高,而且一些老的第三方插件还没跟上。如果你的项目是现代浏览器环境,直接上 6.x 也没问题。核心 API 差异不是特别大。

装完包之后,还需要把一些静态资源复制到项目里。tinymce 不像一般 npm 包那样把所有 CSS、字体、图标都打包进 JS 里,它在初始化的时候会去加载皮肤、图标、主题等资源。如果你不做任何处理,直接启动项目,编辑器大概率会白屏或者报“Failed to load skin”之类的错误。

我推荐的做法是,在项目的public目录下建一个tinymce文件夹,把node_modules/tinymce里的skins目录整体复制过去,再手动放一个中文语言包进去。具体命令:

cp -r node_modules/tinymce/skins public/tinymce/skins

至于langs目录,npm 包里面默认是不带全量语言包的,需要去官方文档或者 GitHub 仓库下载zh_CN.js,然后放到public/tinymce/langs/zh_CN.js。这里特别提醒一句:下载语言包的时候要注意版本,tinymce 5 和 tinymce 6 的语言包文件结构不完全一样,混用可能会导致初始化报Invalid language pack。

2.2 在 Vue 组件里引入 tinymce

公共静态资源准备好之后,接下来就是把它接进 Vue 组件。最省事的方式是直接使用官方封装组件Editor。下面是一个最基础的用法:

<template> <editor v-model="content" :init="editorInit" /> </template> <script> import Editor from '@tinymce/tinymce-vue' import 'tinymce/tinymce' export default { name: 'RichTextEditor', components: { Editor }, props: { value: { type: String, default: '' } }, data() { return { content: this.value, editorInit: { language_url: '/tinymce/langs/zh_CN.js', language: 'zh_CN', skin_url: '/tinymce/skins/ui/oxide', content_css: '/tinymce/skins/content/default/content.css', height: 400, menubar: false, plugins: 'lists image media paste code', toolbar: 'undo redo | styleselect | bold italic | image media | bullist numlist outdent indent | code' } } } } </script>

需要注意,上面这段代码里import 'tinymce/tinymce'这行必不可少。官方封装组件会去全局找tinymce实例,如果你只是引入了Editor组件,而不引入 tinymce 核心,运行时就会报错。

还有一个很多人忽略的点:如果不通过打包器显式引入 tinymce 核心代码,官方组件默认会尝试从 CDN 加载 tinymce,这时候又会要求你配置api_key。所以我们直接本地引入核心,可以完全绕开 API Key 这个东西。

2.3 用 v-model 还是自定义事件

@tinymce/tinymce-vue这个组件本身是支持v-model的,内部会触发input事件把最新内容同步出来。但如果你在父组件里直接使用它,并且希望跟 Element UI 的 Form 表单校验配合,我更建议包一层自定义组件,显式地控制值的变化:

<template> <editor :init="editorInit" :value="value" @input="handleInput" /> </template> <script> export default { props: { value: String }, watch: { value(newVal) { // 外部重置内容时,需要调用 editor.setContent } }, methods: { handleInput(content) { this.$emit('input', content) } } } </script>

这里埋了一个常见问题:如果你只用了v-model,而父组件在某个时刻重置了value,富文本编辑器里的内容不会自动跟着变。因为 tinymce 初始化之后,内部内容和 Vue 的响应式变量是“半脱离”的。正确的做法是在watch里拿到新的值,然后调用this.editor.setContent(newVal)。这个我后面在 Bug 排查章节会专门讲。

3. 图片上传:从配置到实战

3.1 先理解 tinymce 的上传机制

tinymce 里和图片上传相关的配置项有三个,分别是images_upload_url、images_upload_handler和images_upload_base_path。很多人第一次看文档会懵,其实关系很简单:

  • images_upload_url:如果你只是把图片交给一个后端接口,不做任何前端逻辑,那就填这个地址。tinymce 内部会把选中的文件 POST 到这个地址,然后要求后端返回特定格式的 JSON。
  • images_upload_handler:当你有自定义上传逻辑时,比如要加 token、要处理分片,或者要用 Element UI 的el-upload组件,就写这个函数。它完全接管上传过程。
  • images_upload_base_path:给上传后返回的相对路径加前缀用的,一般不怎么用。

在实际项目里,我们当然不可能直接用images_upload_url,因为几乎每个项目的上传接口都需要鉴权、封装统一返回值。所以核心就是配置images_upload_handler。

使用 Handler 时,tinymce 内部逻辑是:用户点击图片按钮选中文件后,生成一个blobInfo对象,然后调用你的函数。你上传成功之后,调用success回调并传入图片的 URL,tinymce 会自动在编辑器内容里插入一个<img>标签。如果失败,调用failure并传入错误信息。

3.2 对接自己的上传接口

我的项目里上传接口统一走 axios,而且返回值封装成了{ code: 0, data: { url: '...' }, msg: '' }这样的格式。所以图片上传 Handler 写成这样:

images_upload_handler: (blobInfo, success, failure) => { const formData = new FormData() formData.append('file', blobInfo.blob(), blobInfo.filename()) axios.post('/api/upload/image', formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then((res) => { if (res.data.code === 0) { success(res.data.data.url) } else { failure(res.data.msg || '上传失败') } }).catch(() => { failure('接口异常,请稍后重试') }) }

这里有几个细节值得展开说。

第一,blobInfo.blob()拿到的是 Blob 对象,直接放进FormData即可。网上有些教材会让你用blobInfo.base64(),然后后端再做一次 Base64 转文件,这完全没必要,既增加传输体积,又增加后端处理复杂度。

第二,blobInfo.filename()拿到的文件名有时候是一长串随机字符,但不要紧,后端重命名自己的文件才是标准做法。

第三,success回调传入的 URL 最好是完整的绝对地址,或者至少是以/开头的根路径。如果传入uploads/xxx.jpg这种不带斜杠的相对路径,tinymce 会根据当前页面的相对路径去拼接,最后很可能拼出一个 404 地址。

第四,如果后端返回的本身就是一整段图片 URL 字符串,你不用再拼任何前缀,直接传给success。但如果后端返回的是一个对象,一定记得取里面的 URL 字段再传。

3.3 支持粘贴图片和拖拽上传

除了点按钮上传,用户还经常直接把截图粘贴到编辑器里。这个功能默认是不开的,需要在plugins里加上paste,并配置paste_data_images: true。

plugins: 'paste image', paste_data_images: true,

开启后,用户从剪贴板粘贴图片时,tinymce 会把图片转成 Base64 或者 Blob,然后走images_upload_handler上传。对用户来说体验非常自然,对开发者来说也无非是 Handler 里多处理一种情况。

但你可能会遇到一个问题:粘贴的图片虽然触发了 Handler,但blobInfo.filename()拿到的名字是类似image.png的通用名字,后端如果拿这个当最终文件名,容易重复。所以后端对上传文件重命名是必须的。

3.4 图片 URL 的坑

图片上传成功但页面里不显示,是我遇到得最多的问题。排查思路并不复杂:

先打开浏览器 Network,看图片的src到底是什么。如果地址明显不对,比如被拼成了admin/xx/uploads/xxx.jpg,那就是初始配置里少了convert_urls: false。tinymce 默认会重写 HTML 里的链接地址,把它转成相对当前文档的路径。在我们这种前后端配合的项目里,这个行为容易帮倒忙,建议直接关掉:

convert_urls: false,

另一个常见问题是后端返回的 URL 带了http://localhost:xxxx,但前端是通过域名访问的,浏览器里自然加载不出。我一般会在后端统一处理成相对路径或者统一替换成 CDN 地址,而不是把本机 IP 拼进去。

还有一个不太起眼但很坑的点:如果项目用了 Element UI 的el-upload做图片上传,Upload 组件默认的action会走它内部的 XMLHttpRequest 逻辑,和 tinymce 的上传机制并不直接冲突,但如果你既在 Handler 里传了图片,又手动触发了一次el-upload,就会上传统一文件两次。所以最好的方案是只保留一条路径,不要混用。

4. 本地视频上传:tinymce 默认不支持,怎么补齐

4.1 为什么自带的 media 按钮只能填外链

tinymce 的media插件是一个很经典的“大坑”。它的本意是支持嵌入第三方视频平台的内容,比如 YouTube、Vimeo,或者直接填一个视频文件地址。所以在它的弹窗里,你只能填 URL,没有任何“本地上传”入口。

如果你强行在media弹窗里粘贴一个本地相对路径,编辑器有时还会用 iframe 包裹这个地址,生成一个div结构去引用它,而不是我们期望的<video>标签。这就会导致前端页面无法正常播放。

所以结论很明确:要不就别用media按钮来处理本地视频,要不就自己写一个完整的“上传本地视频”功能。我们采用后者,因为真正自然的产品交互应当是:点“上传视频”,选择本地文件,上传完成,编辑器里插入可播放的<video>标签。

4.2 自定义“上传本地视频”按钮

我们可以通过setup配置项在 tinymce 初始化时注册一个自定义工具栏按钮:

setup(editor) { editor.ui.registry.addButton('uploadVideo', { tooltip: '上传本地视频', icon: 'video', onAction: () => { // 触发 Element UI 的 upload 组件 } }) }

同时在toolbar里加上uploadVideo。注意,如果media插件没有加载,video这个图标可能未被注册。更稳妥的做法是自己传一个带content的按钮,比如:

editor.ui.registry.addButton('uploadVideo', { text: '上传视频', onAction: () => { this.$refs.videoUpload.$el.querySelector('input').click() } })

当用户点击按钮时,我们触发一个隐藏的el-upload组件,让它弹出文件选择框。

4.3 用 el-upload 实现上传过程

在 Vue 组件里放一个隐藏的el-upload:

<el-upload ref="videoUpload" action="/api/upload/video" :show-file-list="false" :before-upload="beforeVideoUpload" :on-success="handleVideoSuccess" :on-error="handleVideoError" accept="video/*" style="display: none" />

这里用 Element UI 的action直接走后端地址,有一个好处是可以借用它的on-success、on-progress回调,代码写起来最直接。如果你的上传接口需要自定义 headers 或者要自己处理进度,也可以把action换成http-request,本质是一样的。

上传成功之后,后端会返回视频地址,然后我们把它插入编辑器:

handleVideoSuccess(res) { if (res.code === 0) { const videoUrl = res.data.url const videoHtml = `<video src="${videoUrl}" controls="controls" style="width: 100%;"></video>` this.editor.insertContent(videoHtml) } }

这一步有一个很容易忽略的地方:如果是通过media插件来插入,tinymce 可能不会原样保留我们的<video>标签,它会对标签属性做过滤。所以这里我建议在初始化配置中,明确让 tinymce 允许这些属性存在:

extended_valid_elements: 'video[src|controls|width|height|poster|preload],source[src|type]',

这一步极其重要,不然你会发现编辑器里看起来有视频,但保存后再回显,video标签已经被编辑器“净化”掉了。

4.4 视频 HTML 的生成与回显

还有一个问题:上传视频这个动作本身只能让你“插入一段 HTML”,但如果用户想在视频上传后继续编辑它的宽度、封面之类的,麻烦就又来了。我在实际项目里选择了一个折中的方案:上传成功后直接生成<video>标签,并在旁边附带一个“视频属性”按钮,让用户可以重新设置宽高。

如果你不想做那么复杂,也可以趁一开始就限定视频宽高,比如强制width: 100%,在移动端和后台管理端都能自适应,省掉属性编辑功能。

视频回显时还有一个坑:如果编辑器内容是保存在数据库里的,后端返回时可能把<video>标签里的src写成相对路径,那么前端用v-html渲染的时候没问题,但再拿回到 tinymce 编辑器里编辑,因为convert_urls默认开启,它可能又会做一些路径改写。所以回显到编辑器时,最好也保持根路径开头。

大文件上传是另一个话题。视频文件通常很大,如果后端上传接口不支持分片,前端至少要做一个文件大小校验:

beforeVideoUpload(file) { const maxSize = 200 * 1024 * 1024 if (file.size > maxSize) { this.$message.error('视频大小不能超过 200MB') return false } return true }

超过一定大小后,建议后端配合做分片上传,前端可以用el-upload的http-request自定义上传逻辑,这里就不展开讲分片细节了,但至少得把大小限制和进度条体验做好,否则大视频上传时用户会以为页面卡死了。

5. 高频 Bug 与排查实录

5.1 编辑器空白 / 皮肤加载失败

如果你一打开页面,富文本区域是白屏,控制台报错里带skin字样,基本都是skin_url或content_css路径不对。

先检查public/tinymce/skins目录是否存在。很多前端脚手架会把public目录的根路径放到项目根目录,而不是src下,所以/tinymce/skins/ui/oxide这个地址需要你的skaFolders从public/tinymce开始。如果用的是相对路径,可能部署到子目录时又出问题,这属于另外一个部署问题。

另外提醒一点:如果你复制 skins 目录时用了cp -r node_modules/tinymce/skins public/tinymce/skins,注意 Tinymce 5 的皮肤结构是skins/ui/oxide/skin.css,Tinymce 6 也是这个结构。但如果你从网上下载的皮肤包结构不一样,初始化时就会报“Skin is not properly structured”。

5.2 v-model 不同步

@tinymce/tinymce-vue的v-model在大多数情况下是好用的,但如果你把editor实例保存在 Vue 的data里,再在watch里做一些赋值操作,很容易出现一个问题:编辑器里内容已经改了,但 Vue 的value还是老值。

原因在于,tinymce 不会在每次键盘输入时都触发 Vue 的input事件,它默认的change事件是在失焦时才触发的。如果你业务里需要在输入过程中实时同步,可以监听input事件,或者设置:

init_instance_callback: (editor) => { editor.on('input', () => { this.$emit('input', editor.getContent()) }) }

另外一个反向问题也比较常见:父组件重新拉取数据后,通过:value把新内容传给了子组件,但编辑器不刷新。解决办法是在子组件里用 watch 监听value,然后调用:

this.editor.setContent(newValue)

注意,setContent之后再调用getContent才能拿到最新值,如果直接改 Vue 里的data,再想拿内容时不一定会同步。

5.3 中文语言包不生效

英文界面是好的,但language_url配置了中文包后,界面上没有任何变化,甚至控制台报Failed to load language pack。

我遇到过的原因有这些:

  • 语言包文件没放在public目录下,而是放在src目录,导致打包后路径不存在。
  • 文件里定义的语言标识不是zh_CN。有些下载好的zh_CN.js文件内容语言名是zh_CN,有些可能是zh-Hans,需要打开文件确认一下。
  • 你配置了language_url,但又同时在初始化参数里写了language: 'zh_CN',如果language和语言包的内容不对应,也会加载失败。

正确的做法是,将zh_CN.js文件放在public/tinymce/langs/zh_CN.js,然后在init里配置:

language_url: '/tinymce/langs/zh_CN.js', language: 'zh_CN',

5.4 图片上传成功但不显示

这个问题我在第 3 章已经提到了。核心原因无外乎两个:一是上传返回的 URL 不能被当前页面访问,二是 tinymce 改写了 URL。

如果是后者,初始化配置里加上convert_urls: false基本能解决。如果是前者,请回后端看返回的 URL 到底拼了什么前缀,有些后端会返回http://127.0.0.1:8080/upload/xxx.jpg,这种地址在部署环境里是必挂的,最好让后端返回/upload/xxx.jpg这种根路径。

还有一些比较隐蔽的情况:后端对上传图片做了防盗链,前端渲染时带referrer导致图片被拦截。这个如果发现图片单独打开能看见,但放在页面里就裂图,多半就是防盗链问题。可以尝试给<img>标签加referrerpolicy="no-referrer",这不是 tinymce 的默认行为,需要在 Handler 上传成功之后自己调整插入的 HTML:

success(res.data.data.url)

如果上面的方案不好使,可以直接用editor.insertContent插入自定义的带referrerpolicy的 img 标签。

5.5 在 el-dialog 里下拉被遮挡

后台管理页面里,富文本经常会放在el-dialog弹窗里。tinymce 内部有一些弹层组件,比如字体下拉、颜色选择器,这些弹层的z-index肯定比不过 Element UI 的el-dialog遮罩层,所以被挡住是家常便饭。

一个比较稳妥的解决办法是,在el-dialog上设置:append-to-body="true",让弹窗渲染到 body 层,而不是嵌套在某个overflow: hidden的容器里。同时给el-dialog设置一个较高的z-index。如果你用的是 tinymce 5,有时还需要配合popper相关的样式去调整,但在大多数项目里append-to-body就够用了。

5.6 视频插入后在编辑状态异常

视频插入后,在预览页面播放没问题,但在编辑器里点一下视频,可能直接触发浏览器选择文件等莫名行为。

这是因为 tinymce 在处理media相关内容时,会加一层它的“媒体占位符”。如果我们是手动插入<video>,而没有跟着media插件走,那编辑器在保存成 HTML 时可能保留一些额外标签,比如mce-开头的临时属性。

这类问题没有银弹,我的处理办法是:两端都使用<video>标签,不依赖编辑器内部的媒体嵌入逻辑。然后通过extend_valid_elements把video标签的属性放过。保存前再把mce-*开头的临时属性过滤掉,保证数据库里存的是干净的 HTML。

6. 一些值得留意的细节

到这里,整个集成流程基本完整了。我最后再补充几个实际使用中总结出来的细节,帮你少走弯路。

第一,版本敏感。tinymce和@tinymce/tinymce-vue的版本必须跟 Vue 大版本严格对应。最好在package.json里写死版本号,不要用^或者~这种范围符号,免得依赖升级之后静态资源结构变化,影响线上环境。

第二,初始化实例别放在data里。如果你需要在父组件里操作编辑器实例,比如调用setContent、getContent,建议用一个非响应式变量来存:

this.editorInstance = editor

如果把editor直接赋值给data,Vue 会对它做响应式代理,某些操作会变慢,甚至出现实例方法被包装后行为异常的情况。

第三,内容保存前记得做一次校验。tinymce 返回的 HTML 里会有很多无意义的空段落<p><br></p>,如果后端字段长度有限制,可能会超限。可以选择在提交前做一次清洗:

const content = this.editor.getContent().replace(/<p><br><\/p>/g, '')

第四,初始化时不要一股脑把插件全开。每个插件都会往编辑器里注入内容,开得越多越卡。按需开启lists、image、media、paste、code、link这些基础插件就够了。

第五,后端接口要做好文件类型校验。前端限制accept只是一个用户体验层面的过滤,真正的安全校验必须放在后端。图片和视频的 MIME 类型要检查,文件大小要限制,还要防范常规的上传绕过。

在我个人实际操作中,最耗时间的反而是“资源路径配置”这个看起来不起眼的环节。一旦skins、语言包、主题这三样东西都各就各位,后续不管是图片上传还是视频插入,都只是按部就班地写逻辑。希望这篇文章能帮你省下我当初踩坑花掉的那些时间。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询