
做后台管理系统久了你会发现富文本编辑器是绕不开的一块。最早我图省事直接用组件库里现成的封装结果真到了业务方手里需求很快就来了“我们需要直接改HTML源码。”于是就有了这个项目在Vue项目里集成TinyMCE富文本编辑器并加上源码模式编辑功能。源码模式这件事听起来很小的一个按钮实际做起来牵扯到插件的加载机制、Vue的双向绑定、编辑器资源托管方式还有内容回显和安全的坑。这篇文章就是我把这一整套从零到一落地下来的完整记录。先说清楚这篇文章适合谁如果你正在用Vue做后台项目正打算集成富文本编辑器或者你已经集成了TinyMCE但发现工具栏上没有源码编辑入口又或者在用的时候遇到了源码与可视化内容不同步、按钮不显示、中文配置无效之类的问题那这篇文章基本就是为你写的。就算你是刚接触前端不久的新人我把每一步该做什么、为什么这么做、踩过什么坑都写清楚了照着抄作业完全没问题。1. 项目核心拆解TinyMCE 的选型逻辑与源码模式的价值场景1.1 同为富文本编辑器为什么最后选了 TinyMCE富文本编辑器在前端生态里算是老牌领域了可选方案很多。我在这个项目里排过一轮这里用一张表把当时核心考虑的几款列出来编辑器插件生态源码编辑能力Vue集成成本适用场景TinyMCE非常丰富官方插件几十个内置code插件即装即用官方维护 tinymce/tinymce-vue后台中后台系统、对定制要求高的项目Quill中等需要自己找模块没有内置需自己开发一般需要桥接轻量、简洁的编辑场景wangEditor中等国内社区活跃需扩展较低国内项目快速集成CKEditor丰富内置源码模式较高复杂集成需求但体积和授权需要评估最终选TinyMCE核心原因是两个一是源码模式编辑能力是官方内置的不用自己从头造轮子二是它的插件生态非常成熟后面加图片上传、表格、代码块、字数统计都是现成的插件减少了很多重复开发成本。不过选型的时候有一点要提醒大家TinyMCE有开源社区版GPL也有商业付费的云版本和高级插件。千万别以为所有功能和所有插件都是免费的。本项目用的核心插件如code、fullscreen、lists、table等都是社区版内置可用的这已经覆盖了我们80%的需求。如果你的项目需要专业的文档协同、权限控制、修订历史这类功能才需要考虑商业版。1.2 源码模式解决的是哪几类真实痛点很多产品经理或者运营同事第一次提“要源码模式”的时候往往说不出特别具体的理由但真上线之后你会发现这个功能在某些业务场景里几乎是必需品。我在这个项目里整理下来主要有四类刚需第一类是嵌入自定义代码片段。最常见的是运营要往文章里插入埋点统计代码、自定义的按钮组件、带特定class的结构块。可视化编辑器永远只能覆盖有限的预设格式而源码模式能让他们直接粘贴一段HTML进去甚至在可视化工具栏没有入口的情况下也能完成特殊排版。第二类是排查“脏数据”。后台内容经常从Word、公众号、其他网页复制过来一粘贴就带了一大堆内联样式、垃圾标签、语义不明确的嵌套结构。这些垃圾在可视化模式下很难看出来但在源码模式下能一目了然运营可以快速删掉多余的span、font、style标签。第三类是批量调整结构。比如活动页面里几十个相同的卡片结构需要统一改某个class名或者批量替换属性值可视化模式下根本不可能操作源码模式配合编辑器自带的搜索替换就能高效完成。第四类是数据排查和二次开发。前端在调试时如果发现渲染出来的页面结构和预期不一致通过源码模式可以直接看到编辑器里真实存储的HTML能快速定位是存储的问题还是渲染的问题。一句话总结就是源码模式不是给所有用户用的但它是给那部分“需要掌控完整HTML结构”的用户兜底的它的存在决定了编辑器的可扩展上限。2. 集成前的准备工作依赖安装与基础初始化2.1 三种引入方式的取舍与踩坑记录TinyMCE在Vue项目里落地主要有三种引入方式npm包自托管、官方云托管、CDN引用。我先说结论生产项目推荐第一种npm包自托管其他两种我有过教训。官方云托管的方式最简单的是注册账号之后拿到一个API key然后配置api_key参数并且把编辑器资源指向官方CDN。好处是你不需要维护任何静态文件而且可以按需压缩加载。坏处也很明显一是你的站点必须能稳定访问官方资源域名在部分网络环境下会非常慢甚至直接加载不出来二是项目上线后编辑器资源依赖于第三方服务的稳定性一旦对方调整策略直接影响你的线上功能。CDN引用的方式和云托管类似同样是依赖外链虽然很多前端项目会用但国内有些场景对外链域名有限制。而且CDN引用的版本和本地构建的版本如果没锁死版本号很容易在缓存过期后拿到不兼容的版本。所以最终这个项目采用npm包方式。安装命令如下npm install tinymce tinymce/tinymce-vue安装好只是第一步。TinyMCE的npm包里自带skins皮肤样式和plugins插件资源但默认情况下它们不会被打包工具自动复制到你的静态资源目录浏览器在运行时会请求这些资源文件路径对不上就报404。这是新手最容易踩的第一个坑。我当时用Vite构建常用方案是在vite.config.js里做静态资源复制import { defineConfig } from vite import vue from vitejs/plugin-vue import { viteStaticCopy } from vite-plugin-static-copy export default defineConfig({ plugins: [ vue(), viteStaticCopy({ targets: [ { src: node_modules/tinymce/skins, dest: tinymce }, { src: node_modules/tinymce/plugins, dest: tinymce }, { src: node_modules/tinymce/themes, dest: tinymce }, { src: node_modules/tinymce/models, dest: tinymce }, ] }) ] })然后把TinyMCE的全局配置指向这些公共资源tinymce.baseURL /tinymce如果你是Webpack项目思路一样用copy-webpack-plugin把同样几个目录复制到public或static目录。核心逻辑就是让浏览器运行时能通过你指定或默认的baseURL找到皮肤和插件文件。2.2 最小初始化配置从零跑通一个编辑器实例依赖装好、资源复制好就可以写一个最小可用的编辑器了。Vue3下最简单的写法template editor v-modelcontent :initeditorInit / /template script setup import { ref, reactive } from vue import Editor from tinymce/tinymce-vue const content ref() const editorInit reactive({ height: 400, menubar: true, plugins: code, toolbar: code }) /script如果你用的是Vue2那注意版本差异Vue2项目请安装 tinymce/tinymce-vue3Vue3项目安装 tinymce/tinymce-vue4或更高版本。这个坑我帮别人排查过好几次很多人装完组件不渲染翻一下版本就明白了。运行起来之后页面上会显示一个带“/”源码按钮的编辑器。点击这个按钮会弹出一个对话框里面是一个文本域放着当前的HTML源码编辑完点“确定”内容回到可视化编辑区。到这里一个最基础的源码模式就已经通了。2.3 Vue 双向绑定与编辑器内容同步的关键点很多人误以为有了v-model就万事大吉实际上TinyMCE是一个内部状态自己管理的编辑器实例它不是一个常规的input元素v-model不能直接生效。tinymce/tinymce-vue这个组件帮我们做了一层桥接监听编辑器的input和change事件调用editor.getContent()拿到HTML内容再触发update:modelValue事件从而让Vue侧的v-model数据同步更新。理解这套机制很重要尤其是涉及源码模式的时候。在源码模式对话框里改内容改完点“确定”编辑器实例才会通过setContent把源码写回内部内容区。换句话说在源码编辑过程中Vue侧绑定的数据一直是旧值直到你确认提交后才会更新。这不是bug而是TinyMCE源码编辑器的设计如此。后面排查“源码模式下数据不更新”的问题很大程度就是对这一步理解不到位。另外在给父组件回传数据的时候我建议不要监听过于频繁的input事件。后台编辑场景下用户输入过程中实时同步HTML到Vue数据会造成不必要的性能开销尤其是内容很长时。更稳妥的做法是监听change事件或者使用tinymce/tinymce-vue提供的onChange回调在用户切换焦点或者提交时才同步数据。这个取舍在长文档编辑场景中体感差距很大。3. 核心实现源码模式编辑的添加与定制3.1 让“源码”按钮出现在工具栏插件加载与按钮配置源码模式在TinyMCE里是通过code插件提供的。它不是默认加载的所以你必须做两件事在plugins数组里引入它在toolbar里重放对应的按钮。plugins: code, toolbar: code如果你还想在源码模式中支持代码示例的插入与高亮展示还需要引入codesample插件并在plugins中同时配置plugins: code codesample, toolbar: code codesample注意codesample和code是两个不同功能的插件一个是编辑模式下插入带高亮的代码片段一个是切换整个内容的HTML源码视图别混了。我见过不少人在配置里只写了codesample然后到处问怎么没有源码编辑按钮其实就是漏了code。工具栏按钮的位置也很灵活可以通过竖线分隔分组。比如toolbar: undo redo | blocks | code code Sample | bullist numlist实际项目里很多后台编辑器上方已经有一排工具栏此时可以把code按钮放在最后一个分组让它看起来像一个“高级”入口。这里顺带说一个配置的坑TinyMCE有promotion插件社区版在右下角会有一个“升级”的标识以及菜单栏里可能会展示一些付费的菜单项。如果你的UI设计对品牌展示比较敏感可以在init里显式配置promotion: false前提是你确认版本支持该配置或者通过branding: false来关闭默认的Powered by标识。版本不同参数效果有区别建议实际验证一下。按钮出现之后建议第一时间测试它在不同浏览器里的表现。TinyMCE对modern浏览器支持一直不错但源码对话框在个别浏览器里可能出现textarea的滚动条样式异常或高度不够的问题这和皮肤版本有关经验是不要随便把皮肤文件裁剪掉太多内容最好保留完整皮肤。3.2 源码模式的底层机制可视化视图与源码视图如何互转从使用者视角看源码模式就是弹出一个文本框让你改HTML看起来很简答但从功能实现角度有几个细节值得搞明白第一次点击“源码”按钮时编辑器会调用getContent()把当前可视区域的内容转成HTML字符串填充到textarea里。实际上TinyMCE内部并不是把可视区域里的DOM直接序列化就完事它会经过一次内容过滤和规范化处理例如清理掉不符合schema的标签、将光标位置相关的临时标记移除、把内部使用的CSS类名清理掉等。这也就意味着可视化界面中的一些“临时状态”并不会出现在源码里。当你在源码框中改完点击“确定”编辑器会调用setContent()把源码字符串解析成DOM再渲染到可视区域。这一步同样会经过内容过滤你写的HTML里有不合规的标签、属性或非法嵌套会被自动修正或丢弃。这个机制对普通用户是保护但对确实想写入一些“非标准”结构的开发者来说就会很烦人后面我会讲到怎么配置放宽过滤规则。理解这个互转机制之后你就明白为什么“源码模式刚切完再切回来标签少了一层”这类问题时有发生。不是编辑器丢数据而是它的schema在起作用。想要避免核心是在init里配置extended_valid_elements等参数明确定义哪些自定义标签和属性是允许保留的。3.3 进阶定制源码高亮、全屏编辑与快捷键绑定默认的源码模式对话框是个普通文本框没有任何高亮、缩进提示、括号匹配之类的体验。很多人一开始觉得够用但在后台系统中编辑较大段的HTML时纯文本框确实费劲。有两种进阶方案可以尝试。方案一是结合fullscreen插件让源码编辑和全屏预览配合使用。在init里加入fullscreen插件并在toolbar中加入fullscreen按钮这样运营同学可以在阅读不便时切换到全屏沉浸式编辑源码对话框也在全屏状态下获得更大的编辑区域。方案二是把TinyMCE的源码编辑替换成CodeMirror等专业代码编辑器。这个做法相对重思路是通过TinyMCE的对话框API创建一个自定义对话框在对话框里渲染CodeMirror实例然后把编辑器实例的getContent和setContent跟CodeMirror的内容打通。这样就能获得语法高亮、自动缩进、标签匹配等体验。需要注意的是这种方式增加了依赖和复杂度但也有成熟的实现路径如果你的团队有类似定制需求可以朝着这个方向做。还有一个小细节TinyMCE支持自定义快捷键很多资深用户习惯直接按快捷键切到源码视图。可以通过editor.addShortcut方法注册editor.addShortcut(ctrlshifts, 切换源码模式, () { editor.execCommand(mceCodeEditor) })在封装Vue组件时可以在init的setup回调里拿到editor实例后做上述注册。我在实际项目里给运营团队配了CtrlShiftS快捷键他们说这个比鼠标点击快太多了属于投入极小但体验提升明显的定制点。4. 实操过程完整实现一个带源码模式的可复用 Vue 组件4.1 组件代码全览与逐段浅析下面是我在项目中实际使用的组件结构为了方便说明做了简化但核心配置都在。你可以在组件基础上继续加图片上传、文件上传、字数统计等能力。先建一个RichTextEditor.vuetemplate div classrich-editor editor v-modelcontent :initeditorInit :disableddisabled onChangehandleChange onInithandleInit / /div /template script setup import { ref, reactive, watch, onBeforeUnmount } from vue import Editor from tinymce/tinymce-vue const props defineProps({ modelValue: { type: String, default: }, disabled: { type: Boolean, default: false }, height: { type: Number, default: 400 } }) const emit defineEmits([update:modelValue, change]) const content ref(props.modelValue) let editorInstance null const editorInit reactive({ height: props.height, baseURL: /tinymce, language: zh_CN, language_url: /tinymce/langs/zh_CN.js, menubar: file edit view insert format tools table, plugins: code codesample lists table image link fullscreen wordcount, toolbar: undo redo | blocks | bullist numlist table | image link | code codesample fullscreen, branding: false, promotion: false, content_style: body { font-family: sans-serif; font-size: 14px; } img { max-width: 100%; }, // 允许保留常见的class属性 extended_valid_elements: span[class],div[class],i[class],a[class|href|target], setup(editor) { editor.on(init, () { editorInstance editor }) editor.addShortcut(ctrlshifts, 切换源码模式, () { editor.execCommand(mceCodeEditor) }) } }) function handleChange() { emit(change, content.value) } watch(() props.modelValue, (val) { if (val ! content.value editorInstance) { content.value val || editorInstance.setContent(content.value) } }) onBeforeUnmount(() { editorInstance null }) /script这段代码有几点值得细说一是v-model的双向消息通过onChange里手动emit(change)来增强因为有些后台场景需要把“内容变化”事件抛给父组件做自动保存而不仅仅是更新表单值。二是baseURL的配置。这段代码里写的是/tinymce如果你的项目部署在子路径下务必改成你实际的静态资源前缀否则编辑器会找不到皮肤和插件文件。三是初始化回调里注册的快捷键。我把源码模式切换快捷键绑定到编辑器实例上这样用户直接用CtrlShiftS就能呼出源码对话框配合前面说的源码编辑场景体验会顺手非常多。4.2 必填配置项与推荐组合整理一份常见的TinyMCE配置项方便你在集成时快速定位要配置的字段配置项作用推荐值/说明selector / inline指定编辑区域Vue组件方式下一般不需要selectorheight编辑器高度后台编辑建议 400~600language / language_url中文语言包需下载中文包放入静态资源目录plugins加载哪些功能插件按需加载别一次全上影响初始化速度toolbar工具栏按钮布局用竖线分组把常用按钮放前面menubar顶部菜单按需控制也可设为falsebranding显示官方标识社区版设false可移除部分标识promotion是否显示升级入口有的版本支持设false可隐藏content_style编辑区自定义样式保证编辑区视觉和展示端一致extended_valid_elements扩展允许保留的HTML元素防止自定义属性被过滤images_upload_handler自定义图片上传接入公司的上传接口paste_data_images是否允许粘贴图片建议开启配合上传处理这些配置里图片上传是最常见的一环。TinyMCE默认的图片上传处理需要你写好images_upload_handler不然上传按钮只是个摆设。源码模式本身和上传关系不大但编辑器整体功能必须成套考虑别顾此失彼。另外一个容易被忽略的配置是entity_encoding。如果业务需要存原始字符实体比如保留“”而不是存成“”就需要设置它的值。对于偏向“所见即所得”的后台系统我一般设置entity_encoding: raw这样源码模式下看到的HTML更接近人工理解的原文减少那种“源码里全是编码符号”的困惑感。4.3 后端配合内容存储、安全过滤与回显源码模式带来的一个直接后果是用户可能在内容里写入任意HTML。可视化编辑器模式下格式是受控的源码模式下理论上用户甚至可以尝试插入script标签、内联事件、iframe等。这块必须处理后端防XSS不能只靠前端。我在项目里的做法是前置两层前端在提交内容前做一次基础清理比如去掉script标签、去掉onclick等事件属性但在这里要说明前端清理只是体验层面的辅助安全底线永远在后端。后端接收内容时用白名单策略过滤HTML默认仅允许p、br、strong、em、ul、ol、li、table、tr、td、img、a、code、pre、blockquote这些常见标签并去掉所有on开头的属性和javascript:伪协议链接图片的src只允许http、https、data:image三种协议。回显也很重要。前端拿到后端返回的内容后用v-html渲染富文本本质上是在“展示可执行HTML”。在把内容交给v-html之前同样建议跑一遍前端侧的过滤或转义逻辑。对内部运营系统来说自己的内容可信度较高但不能因此放松底线尤其是如果你允许用户贡献内容比如评论、社区帖、个人简介等安全过滤就是一个必须反复检查的环节。另外提交给后端时注意保留原始HTML而不是经过可视化渲染后的“残片”。TinyMCE的getContent返回的内容通常正是需要的不要太早做trim或转义避免后端拿到的数据无法还原源码模式下的结构。我在项目里出现过因为全局过滤器自动编码了引号导致后端把HTML当纯文本存储回显全变文本的惨案排查了半天才定位是全局过滤器的锅。5. 问题排查与避坑实录5.1 工具栏上找不到“源码”按钮这个问题要分几层排查。最基础的是检查plugins和toolbar两个配置里是否都有code关键字缺一个按钮都不会出现。其次看浏览器控制台有没有插件资源加载404错误有的话就是baseURL或静态资源复制路径配置不对。还有一种情况是缓存了旧版本TinyMCE编辑器实例是从缓存里读取的旧plugin列表按钮自然加载不出来。清除缓存、确保构建产物是新版本后重试。如果想快速定位是不是baseURL问题可以在init后打印editor.plugins对象里面会列出所有已注册的插件。如果code不在里面说明插件资源没有正确加载到。5.2 源码模式编辑后 Vue 数据不更新最常见的原因是onChange没有触发或者内容在源码对话框里编辑完点击取消导致内容没有被提交回TinyMCE。记住源码模式的提交时机只有点击“确定”按钮setContent才会被执行源码内容才会同步到编辑器内容区。如果你希望用户每次输入都实时同步到Vue数据那就需要放弃默认的code对话框自己基于TinyMCE的API做自定义源码编辑面板监听每次change事件并调用getContent同步。还有一类情况是父组件给子组件传的v-model数据是经过处理的字符串处理后内容和TinyMCE内部内容不一致watch逻辑覆盖了编辑器里的内容导致用户刚改完就被外部数据覆盖回去。这种情况下需要认真设计“外部数据与编辑器内容谁优先”的规则一般只在外部数据变化且与编辑器当前值不一致时才setContent这里我在组件代码里已经用了一个比对判断大家可以参考。5.3 编辑器自动“吃掉”了源码中的自定义属性TinyMCE默认有一套严格的HTML schema很多自定义属性如data-xxx、类名、自定义标签会被过滤掉。源码模式只是把“当前已经通过过滤的内容”展示给你别指望它能把所有你输入的内容原封不动地保存下来。要放宽必须显式配置extended_valid_elements: span[class|data-*],div[class|data-*],a[href|target|class|data-*], custom_elements: my-component,此外如果内容里包含非标准的自定义元素还可能要配合schema: html5或valid_children配置来允许特定嵌套。我在接一个活动页系统时业务需要往文章里插入自定义的卡片组件标签是la-card一开始总是被删掉。最后就是在custom_elements里声明同时告诉对方编辑器环境不支持JS的复杂交互只能做结构级别的展示问题才解决。5.4 中文界面失效与多语言配置TinyMCE默认是英文界面。想切成中文需要下载对应的语言包js文件一般放到public/tinymce/langs/zh_CN.js然后配置language: zh_CN, language_url: /tinymce/langs/zh_CN.js常见坑有两个一是language_url路径不对控制台404二是插件自带的一些UI文本和核心语言包版本不匹配导致工具栏部分英文、部分中文。建议尽量使用接近的TinyMCE版本语言包避免混用造成文案错乱。如果你的业务需要自定义编辑器内部文案还可以通过init里的自定义语言包覆盖特定键值这个能力在有特殊术语的后台系统里很有用。6. 内容调优与后续扩展思路6.1 让源码模式更顺手的小技巧结合几次实战体验分享几个省心的小技巧。第一个是在工具栏里把源码按钮放到最右侧用竖线隔开这样运营同事不会误点但需要时又很好找。第二个是把快捷键绑定做好这是拉高使用率最有效的单点投入。第三个技巧是针对长文档的TinyMCE的源码对话框默认textarea高度有限内容多时滚动操作很累。可以在点击源码按钮前先通过execCommand(mceToggleFullScreen)进入全屏再打开源码对话框编辑完再退出全屏。虽然多两步操作但编辑体验提升非常明显。如果团队经常编辑带大段源码的内容强烈建议全屏和源码模式一起用。第四个技巧是做内容模板。后台编辑场景经常有“插入标准组件”的需求比如联系方式块、引用块、分割线、视频嵌入块等。可以在工具栏上自定义一个按钮点击后弹出预设的HTML源码片段列表选中后通过editor.insertContent直接插入并且保留源码结构不被过滤。相比让用户自己去源码模式里写完再粘贴模板方案更可控也减少出错。6.2 从源码模式延伸到扩展插件开发当你对TinyMCE的机制熟悉之后可以尝试用它的自定义命令功能把常用的源码处理操作固化出来。比如我在项目里注册过两个自定义命令一个用于“清理当前内容的行内样式”另一个用于“移除所有空白字符合并连续空行”。运营团队一键即可完成源码清洗不用手动去源码框里逐个改。实现思路很简单在setup回调中通过editor.addCommand注册命令命令内部通过TinyMCE的content API操作DOM或字符串最后setContent写回。这些命令还可以配合自定义按钮出现在工具栏上效果等于给编辑器加装了定制插件。工程量不大但对外展现出来的专业度和效率提升非常可观。如果你需要更复杂的源码编辑体验还可以尝试把源码视图从默认的textarea升级成CodeMirror或Monaco Editor结合前面提到的对话框API做一套自研的源码编辑面板。这条路实现成本高一些但能达到桌面IDE级别的代码编辑体验。我个人建议先低成本运行一段时间再根据真实用户反馈决定是否需要投入做高配版避免过度设计。6.3 多实例共存与组件封装复用后台系统里富文本编辑器往往不止一处使用比如文章编辑、公告编辑、活动配置都可能单独引用编辑器组件。使用Vue组件方式封装之后多实例共存其实非常自然每个实例都会有一个独立的editor实例互不干扰。但这里有三个容易踩的坑。第一个是资源加载并发问题多个编辑器同时初始化时TinyMCE会对同一资源文件发起多个加载请求大部分情况下浏览器缓存会兜底但如果你是通过外链加载且网络不稳可能出现其中一个编辑器初始化失败。解决思路是在全局做一次预加载或者用官方推荐的通过脚本加载器统一管理。第二个坑是内存泄漏。后台系统经常做路由切换编辑器组件卸载时如果没调用editor.destroy()会在内存里残留实例。tinymce/tinymce-vue组件一般会在beforeUnmount时自动处理但如果你自己注册了额外的事件监听、共享对象引用需要手动清理。我在组件卸载前会主动置空editorInstance引用并且把绑定在window上的事件全部解绑。第三个坑是组件库风格的适配。编辑器默认的皮肤样式是自带的和你们自己的设计系统风格未必统一。可以通过设置skin: oxide或换用其他skin再配合content_style里覆盖字体、字号、行高、标题样式等让编辑区的视觉和最终展示页面接近。这个对用户体验影响很大值得认真调一遍。最后再分享一个个人习惯在受控组件的数据流上我会把“用户输入”和“服务端回填”走两条通道。用户输入通过onChange事件同步给父组件服务端回填直接通过外部props变化触发setContent。这样不会出现一边输入一边被回填数据反复覆盖的恶性循环。在之前好几个项目里这个方法都帮我少加了很多哑火的bug。如果你正在封装自己的富文本组件这个思路可以直接用上。