资讯中心

Vue3富文本编辑器组件封装实践:选型、生命周期与踩坑指南

📅 2026/9/28 7:50:14
Vue3富文本编辑器组件封装实践:选型、生命周期与踩坑指南
做了几年后台管理系统我发现自己绕不开同一个坎Vue3项目里总要放一个富文本编辑器。技术方案从早年的百度编辑器换到Quill再到CKEditor每次都要重新读一遍文档、配一遍工具栏、调一遍样式还要跟当前Vue版本反复磨合项目一多就特别难受。后来我干脆自己封装了一套Vue3可即插即用的富文本组件把初始化、销毁、内容同步、图片上传这些脏活全部隔离在组件内部业务页面只需要v-model一把梭。这篇博文就把我的选型思路、组件设计、踩过坑以及扩展玩法完整记录下来希望能给正打算在 Vue3 项目里接入富文本的同事一点参考。这套组件非常适合后台管理系统、内容发布平台、运营配置后台、低代码表单设计器这类场景。它解决的痛点是开发同学不需要关心底层编辑器怎么实例化不需要手动处理组件卸载后的事件残留也不需要为每一个业务页面重复粘贴工具栏配置和上传逻辑。1. 为什么这套组件敢叫“即插即用”从选型说起“即插即用”这四个字听起来简单实际做起来的第一道难题就是底层编辑器的选型。Vue3生态里的富文本方案不少但真正能让你省心的不多。我先把自己调研过的几款主流编辑器列出来再讲我为什么做出最终决定。1.1 四款主流编辑器的对比与取舍我调研时重点看几个维度Vue3适配度、体积、工具栏定制成本、中文文档质量、活跃度以及二开难度。市面上讨论最多的四款各有各的脾气。编辑器Vue3适配体积/加载定制成本备注wangEditor 5官方直接支持Vue3组件约200KBJS低配置对象即所得中文文档友好社区活跃Quill 2需自己接入ref实例中等中等主题样式定制较绕经典老牌去掉了旧版对Vue3的别扭适配Tiptap官方支持Vue3TS友好基础包小功能靠扩展叠加偏高基于ProseMirror概念灵活但学习曲线陡CKEditor 5官方提供Vue3集成包较大高打包配置复杂能力强但重单看参数可能不够直观我结合真实项目经验说Quill的问题在于旧版本对Vue3的适配很别扭需要自己在onMounted里找DOM节点再new Quill()生命周期里还要处处留意事件清理。虽然 Quill 2 改善了很多但很多团队还在用老版本的惯性写法中文资料也停留在 1.x 时代。Tiptap是我个人很欣赏的编辑器它的扩展机制设计得非常好TypeScript 类型推导也很舒服。但正因为它灵活意味着如果你想把一个“开箱即用”的富文本抛给普通业务开发你需要先搞清楚 Node/Mark/Extension 的概念还得自己组织菜单栏 UI。这对一个工具型组件来说学习成本太高了。CKEditor 5功能最强但我实测下来在后台管理系统里有点“杀鸡用牛刀”而且接口风格偏重做轻度定制时反而被框架束缚。1.2 我为什么最终选择了 wangEditor最后我选了wangEditor 5作为底层。原因是它在“够用、稳定、简单”这三个词上最均衡官方直接给了 Vue3 组件版引入之后一个标签就能渲染成功工具栏配置就是一个普通对象业务上想隐藏哪些按钮直接过滤就行文档和示例基本都是中文团队里接手的同学上手极快而且它支持focus、blur、change等事件回调配合destroy()方法做资源释放也很干净。当然选它不代表其他方案不好。如果你们的项目对 TypeScript 类型推导要求苛刻、又有大量自定义内容节点Tiptap 可能更合适如果编辑器本来就是产品核心功能、需要文档协同那 CKEditor 或专业协同编辑器才是正解。我这里说的是“后台管理系统标配一个富文本”这种常规场景下wangEditor 是最省心的地基。2. 组件核心设计v-model 是门面生命周期是地基选好底层之后真正的活才开始。我的目标是让业务方使用组件的感受像用input v-modelform.content /一样自然同时把底层编辑器的所有生命周期细节都封印在组件内部。2.1 目录结构与对外API组件代码组织得尽量收敛我习惯叫它RichEditor目录结构大致是这样src/components/RichEditor/ ├── index.ts # 对外导出 ├── RichEditor.vue # 组件入口负责挂载实例 ├── config.ts # 默认工具栏、默认配置 └── utils.ts # 内容清洗、DOM解析辅助对外暴露的 props 刻意做到最少避免业务方被底层编辑器的复杂配置淹没interface RichEditorProps { modelValue: string // 内容HTMLv-model绑定 placeholder?: string // 占位符 height?: number // 编辑器高度默认300 toolbarKeys?: string[] // 需要显示的工具栏按钮默认全量 uploadUrl?: string // 图片上传地址默认走base64 uploadHeaders?: Recordstring, string disabled?: boolean // 是否只读 }使用方式简单到怀疑人生template RichEditor v-modelform.content :height400 :toolbar-keysnsKeys / /template script setup langts import { ref } from vue import RichEditor from /components/RichEditor/index const form reactive({ content: }) const nsKeys [headerSelect, fontFamily, fontSize, bold, italic, underline, color, bgColor, bulletedList, numberedList, todoList, justifyLeft, justifyCenter, justifyRight, insertImage, insertLink, insertTable, codeBlock] /script这样设计接口的考虑在于富文本组件的价值在于把“编辑器上下文”全部吞掉。业务方真正关心的只有“内容是什么”“能不能调高度”“图片往哪里传”这三件事剩下的还原、事件监听、资源释放都不应该出现在业务代码里。如果你把底层editor.config整个对象透传给业务方那跟让业务方直接用底层编辑器有什么区别2.2 生命周期管理的正确姿势富文本组件最容易出事故的地方就是生命周期编辑器实例创建在onMounted里但销毁如果照顾不周组件切换页面后事件监听还在栈里内存泄漏、重复初始化、状态错乱都会冒出来。我在RichEditor.vue内部的处理逻辑可以简化为script setup langts import { onBeforeUnmount, onMounted, ref, shallowRef, watch, nextTick } from vue import { createEditor, createToolbar } from wangeditor/editor const props withDefaults(definePropsRichEditorProps(), { height: 300, placeholder: 请输入正文内容..., }) const emit defineEmits([update:modelValue]) const editorRef shallowRef() const editorContainerRef refHTMLElement() let toolbarInstance: any null onMounted(async () { const editor await createEditor({ selector: editorContainerRef.value!, config: { placeholder: props.placeholder, onChange: (newHtml: string) { emit(update:modelValue, newHtml) }, // 图片上传等配置略 }, }) editorRef.value editor // 初始化时回显外部传入的内容 if (props.modelValue) { editor.setHtml(props.modelValue) } }) onBeforeUnmount(() { if (toolbarInstance) { toolbarInstance.destroy() toolbarInstance null } if (editorRef.value) { editorRef.value.destroy() editorRef.value null } }) /script template div classrich-editor div reftoolbarContainerRef/div div refeditorContainerRef classeditor-content :style{ height: ${height}px }/div /div /template这套逻辑的关键点有两个一是shallowRef存放编辑器实例。编辑器实例本身是个重度对象如果放在普通ref里Vue 的响应式系统会递归把它变成响应式代理性能和内存都受影响甚至可能导致编辑器内部判断出问题。用shallowRef只跟踪引用替换不追踪深层属性是最稳妥的姿势。二是销毁时先销毁工具栏再销毁编辑器。很多从旧版迁移过来的代码只销毁 editor忘了 toolbar 实例组件重新挂载后工具栏区域会残留旧 DOM 事件。我把toolbarInstance.destroy()放在前面形成固定的销毁顺序这套组件的稳定性就是这么一点一滴抠出来的。2.3 内容回显与数据同步的时序问题即插即用组件背后还有个隐藏细节初始化时回显内容和运行时监听外部更新的时序。如果组件在onMounted里立刻editor.setHtml(props.modelValue)此时操作 DOM 没问题但有一种情况会被坑到父组件在首次渲染时异步拉取文章详情modelValue初始值是空字符串等接口数据回来时组件已经初始化完成此时光靠一次性回显就不够了。所以我额外加了一个监听逻辑watch( () props.modelValue, (newVal, oldVal) { if (editorRef.value) { const currentHtml editorRef.value.getHtml() if (newVal ! currentHtml) { editorRef.value.setHtml(newVal || ) } } } )但是这里必须踩一脚刹车不能用户每次输入都触发watch重新 setHtml否则光标会跳到末尾编辑体验直接崩掉。我在组件里做了一层防重入判断只有当外部传入的 HTML 和编辑器当前内容不一致时才执行setHtml。这个不一致既包括接口异步回填的场景也包括父组件清空编辑器内容的场景。这样既保住了回显能力又不会干扰用户正在输入的过程。数据同步反向同样重要。编辑器每次 onChange 都 emit 一次update:modelValue这是天然双向绑定。但我遇到过有人把富文本内容同时绑定到多个组件实例上的场景这时候多实例的watch会互相触发导致 A 组件的输入把 B 组件内容覆盖。所以我又给每个实例加了个internalFlag在 emit 时短暂标记避免同源变更触发重复 setHtml。这套“外部回填向内走内部变更向外走”的循环是整个组件稳定性的第二块基石。3. 真实项目里踩过的坑四条排查链路光看设计可能觉得一切顺理成章但实际放到项目里特别是成熟的大型后台管理系统里总会冒出一些单测覆盖不到的问题。下面这四个坑是我在不同项目里真实踩过的每一个都花了不少时间去定位写出来帮大家少走弯路。3.1 坑一页面切换后编辑器没销毁浏览器越来越卡现象后台管理系统里用 Tab 标签页切换来回切换几次后页面变得明显卡顿打开任务管理器发现当前标签页内存持续上涨。排查链路我先怀疑是路由页面没有正确卸载在onUnmounted里打console.log确认组件确实卸载了。再看编辑器销毁代码发现editor.destroy()写了但当时没有销毁 toolbar。打开 DevTools Performance 录制反复切换页面观察事件监听器数量发现document和body上的事件监听数量在每次切换后都会增加。进一步验证只销毁 editor不销毁 toolbar工具栏实例上挂的事件监听依然存活。因为组件的销毁函数里没有调用toolbarInstance.destroy()而组件实例虽然被卸载了但工具栏 DOM 被移出时并未解绑事件。修复方案在onBeforeUnmount里先销毁 toolbar 再销毁 editor并且把两个实例都置空onBeforeUnmount(() { toolbarInstance?.destroy() toolbarInstance null editorRef.value?.destroy() editorRef.value null })这个问题提醒我第三方库的销毁接口不是摆设尤其是提供独立 Toolbar 实例的方案一定要把两个实例的生命周期绑定在一起。后来我在组件文档里专门加了一条约定所有使用createToolbar创建的变量必须在组件销毁时手动 destroy不允许依赖浏览器 GC 兜底。3.2 坑二图片 base64 直接塞进文章数据库字段差点撑爆现象编辑文章时粘贴/上传了一张 2MB 的截图保存后接口报错数据库字段超长。排查链路先检查前端提交的 payload发现content字段里有一大串data:image/png;base64,...字符串。打开编辑器配置发现没有配置任何自定义上传逻辑默认行为是把图片转为 base64 存进 HTML。看数据库字段类型content字段是text类型base64 膨胀后轻松超过 64KB 上限。修复方案在编辑器 config 里覆盖customUpload或者uploadImage方法由组件统一管理上传逻辑。因为不同项目的上传服务接口五花八门我把上传行为设计成了可覆盖的策略配置function defaultUpload(file: File): Promisestring { // 默认实现走统一的 OSS 直传地址返回图片 URL return requestUpload(file) } // 组件 props 增加一个 upload 函数 const props withDefaults(definePropsRichEditorProps(), { upload: () defaultUpload, }) // 编辑器 config 里启用自定义上传 config: { uploadImage: { async customUpload(file: File, insertFn: (url: string) void) { const url await props.upload(file) insertFn(url) }, }, }这个坑的本质是富文本的“所见即所得”会把非文本资源以文本形式带走。凡是接富文本组件的项目我建议第一件事就是关掉 base64 图片存储否则内容越长、图片越多接口传输和数据库存储都会成倍膨胀。3.3 坑三Vite 环境下样式被分片打包编辑器 UI 一会儿正常一会儿错乱现象项目从 Webpack 迁移到 Vite 后编辑器在某个路由页面打开工具栏按钮全部挤在一起样式明显错乱刷新页面又恢复正常但切换路由再回来又乱了。排查链路看浏览器控制台有大量 CSS 资源的警告但没报错。检查vite.config.ts的optimizeDeps配置发现没有把wangeditor/editor排除掉。打开 Network 面板发现编辑器的样式是通过动态注入的style标签加载的每次路由切换时样式标签的加载顺序和组件挂载顺序不一致导致部分样式没有生效。最终在 Vite 配置里手动优化依赖处理并要求编辑器组件内部先等待样式加载完成再创建实例。修复方案// vite.config.ts export default defineConfig({ optimizeDeps: { include: [wangeditor/editor], }, })另外在组件初始化时调用一下样式预加载确保样式不会在首屏之后才到import wangeditor/editor/dist/css/style.css onMounted(async () { // 让样式先落地 await nextTick() const editor await createEditor(...) })这个坑比较隐蔽因为它不是必现的只在路由懒加载场景下偶发。我的经验是富文本编辑器这种带独立样式表的库在 Vite 项目里一定要显式预引入样式并加入 optimizeDeps不能指望运行时动态注入来兜底。如果你是用了类似unplugin-vue-components这种自动导入方案更要小心样式被拆到不期望的 chunk 里。3.4 坑四TS 类型声明和组件实例类型冲突现象项目开启严格 TS 模式后引用组件时报错Property editor does not exist on type ...或者组件模板里绑定v-model时提示类型不匹配。排查链路先看组件的.vue文件在script setup里使用definePropsRichEditorProps()但外部引用时类型推导没有生效。检查组件导出方式发现index.ts直接默认导出组件对象没有补.d.ts或显式类型声明。进一步看vue-tsc的输出发现组件在未声明component类型时模板里的v-model会被推导成string | undefined和表单类型不匹配。修复方案在组件目录下提供index.ts显式导出并声明组件类型import RichEditor from ./RichEditor.vue export { RichEditor } export type { RichEditorProps } from ./types export default RichEditor同时组件内部保持modelValue: string的强类型不写宽松的any。如果项目里用了unplugin-vue-components自动按需注册请在components.d.ts生成后检查一次必要时手动声明RichEditor的自定义组件类型。这个坑给到的教训是“即插即用”在脚本上容易做到在类型上往往被忽略。Vue3 的 TS 生态已经非常主流组件封装的导出类型和v-model联合类型设计在一开始就要想清楚否则迁移到严格模式下再补类型声明会牵扯出一串连锁报错。4. 从能用到好用上传扩展、自定义菜单与性能取舍到了这个阶段基础组件已经能在任何 Vue3 项目里原地运行了。但真正让组件“好用”还需要在业务定制和性能细节上多走几步。这一节我把经常在后台管理系统里用到的扩展方式分享出来。4.1 自定义图片上传服务前面说过要关掉 base64 图片存储但真正接上传服务时不同项目之间的接口形态千差万别。组件设计上我推荐采用策略注入默认开启一个通用的upload函数业务方需要定制时传入自己的实现即可。template RichEditor v-modelform.content :uploadhandleUpload :upload-headersuploadHeaders / /template script setup langts async function handleUpload(file: File) { const formData new FormData() formData.append(file, file) const res await apiUpload(formData) return res.data.url } /script如果你的项目统一走 OSS 直传那handleUpload可能就是“获取 STS 临时凭证 - 直传 OSS - 返回 CDN 地址”。这块组件内部不需要关心业务方只管返回最终可访问的 URL 即可。这种做法的好处是图片上传逻辑和编辑器解耦哪怕后面项目从自建上传迁移到云厂商对象存储也只改业务函数组件一行不用动。4.2 业务自定义工具栏按钮默认工具栏虽然丰富但后台系统往往有专属业务按钮比如“插入人员选择器”“插入商品卡片”“插入审批单模板”。这类按钮通常需要站在编辑器视角插入一段自定义 HTML组件可以把这部分能力也透传出来。我的做法是给组件增加一个customMenus的 prop业务方传入菜单配置数组组件在createToolbar时挂到工具栏里。不过要提醒一点自定义菜单是业务耦合最深的地方没有现成经验照抄需要先理解 wangEditor 的菜单注册机制。核心流程是继承BaseMenu创建新菜单类重写exec方法。在exec里拿到editor实例调用editor.dangerouslyInsertHtml(...)或editor.insertText(...)插入业务内容。把菜单类通过toolbarConfig.insertKeys或toolbarConfig.excludeKeys配置插到指定位置。我自己写过一个“插入企业微信二维码”的菜单伪代码长这样import { BaseMenu } from wangeditor/editor class QrCodeMenu extends BaseMenu { exec(editor: any) { const html img src/imgs/qr-code.png altqrcode width200/ editor.dangerouslyInsertHtml(html) } } // 业务侧把菜单配置传给组件 const customMenus [QrCodeMenu]这种扩展能力让组件从一个“固定富文本”变成“业务富文本平台”尤其是低代码或者 OA 系统的内容发布模块几乎都会用到。4.3 性能与体积优化富文本组件体积都不小在 Vue3 项目里如果不加控制首屏从一开始就背上大包袱。我在这套组件里做了三个层面的优化第一是按需加载。如果组件只在“新建/编辑文章”页面出现就别在全局入口注册而是用异步组件加载const RichEditor defineAsyncComponent(() import(/components/RichEditor/index.vue))这样首屏不会加载编辑器相关的几百 KB JS 和 CSS只有用户真正进入编辑页时才下载。第二是关闭不需要的模块。wangEditor 5 的模块划分还算细默认会加载表格、代码块、表情、视频等功能。如果系统里只用到图文混排可以在 createEditor 时通过MENU_CONF关闭对应菜单或者直接从toolbarKeys里过滤掉。这样减少的不只是 UI 按钮还有对应模块的 JS 执行开销。第三是避免把富文本内容塞进响应式大对象。有些项目习惯把整个表单对象放进 Pinia 或者 reactive 里而富文本 HTML 可能很长一旦依赖响应式追踪每次用户输入都要把整段字符串交给 Vue 做依赖收集在低配电脑上会有明显输入卡顿。我建议把富文本内容单独用 ref 或 shallowRef 管理表单提交时再手动取值const contentModel ref() // 提交时直接取 contentModel.value 拼入 payload性能优化这件事不需要一步到位但你至少要知道卡顿到底是因为组件本身还是因为自己的响应式设计。很多时候问题不在编辑器而在我们给编辑器套上了太多响应式枷锁。5. 这套组件的适用边界与后续扩展思路组件做出来之后我还得泼一盆冷水没有万能组件只有合适组件。这套方案最适合的是后台管理系统、内容运营平台、企业 OA 这类“需要一个稳定可靠的内容生产入口”的场景。如果你的诉求是像 Notion 那样块状编辑、像飞书文档那样多人协同、像 IDE 那样 Markdown 与富文本混合编辑那底层选型就应该换赛道而不是在这套组件的边界内硬加功能。关于后续扩展目前我还考虑两条方向一是把组件再拆成“编辑单元格”和“预览单元格”两个无头子组件方便在表格类页面里嵌入多个富文本录入区域二是把自定义菜单的注册方式再做一层声明式封装让业务方通过 JSON 配置就能挂菜单不需要再写类继承。组件开发最有意思的部分恰恰不是调用一个第三方库而是把那些“第三方库默认行为”和“业务真实需求”之间的缝隙全部填平。页面调用处只剩下一行标签背后却站着完整的生命周期治理、上传策略、类型约束和样式隔离。如果你们项目也正在挣扎于“为什么富文本组件在 Vue2 能用迁到 Vue3 就一堆问题”那大概率不是编辑器本身的问题而是缺少一个像这样替 Vue3 版本差异兜底的封装层。把这套设计思路拿过去把底层换成你们团队已经熟悉的其他编辑器同样能整理出一套顺手的“即插即用”组件。

看完文章,想为自己的企业也做一次专业网站诊断?

尧图顾问免费为您评估现有网站,并给出建站/改版建议与报价方案。

免费获取方案