做后台管理系统的人早晚会碰到富文本编辑器这道坎。文章发布、商品详情、公告通知、帮助文档、售后条款只要页面里需要排版就绕不开它。我从 Vue2 时代的 UEditor、TinyMCE 一路用过来中间还折腾过 Quill 和 wangEditor踩的坑足够写一本小册子。这篇就聚焦一个很具体的问题在 vue3 项目里把 wangEditor 用起来、用对、用到生产环境不翻车。这不是一篇搬运官方文档的教程而是我在几个真实后台系统里反复打磨后留下的记录包括选型的取舍、组件的封装方式、上传接口怎么接、只读模式怎么切、以及那些官方文档里不会写但一定会遇到的诡异故障。刚上手 vue3 的同学能照着抄出一个可用组件带过项目的同学也能在里面找到几个能省下半天排查时间的细节。1. 选型前的冷静判断wangEditor 到底适合谁1.1 富文本编辑器其实分三条技术路线在动手写代码之前先想清楚富文本编辑器这个领域到底有几种做法因为选错方向比写错代码更致命。第一条路线是基于 contenteditable 直接操作 DOMUEditor、wangEditor 早期版本、Quill 都算这一类特点是实现直观、可控性强但浏览器兼容和光标处理是一大堆脏活。第二条是基于文档模型如 ProseMirror、Slate的编辑器像 Tiptap、飞书文档编辑器数据以结构化 schema 存储协同编辑、撤销重做、节点级操作都很优雅学习曲线也陡。第三条是Markdown 编辑器比如 Vditor、md-editor-v3输入的是纯文本输出靠解析适合技术博客但不太适合运营同学写商品详情。wangEditor 属于第一条路线的成熟产物。它的取舍非常明确上手快、中文文档友好、开箱即用代价是深度定制能力不如 Tiptap 这类模型驱动的编辑器。选择它之前先问自己一个问题——我要的是能写、能排版、能传图还是要做类 Notion 的块级编辑、要支持多人协同。如果是前者wangEditor 很合适如果是后者直接去看 Tiptap别在小工具上耗时间。1.2 v5 相比 v4 是一次重写别混着用很多人栽的第一个跟头是把 v4 的用法套到 v5 上。这两个大版本几乎是两个不同的库。v4 的用法是new E(#editor)通过editor.txt.html()读写内容菜单配置是一套老的对象结构。v5 全面转向了模块化核心包是wangeditor/editorVue 封装包是wangeditor/editor-for-vue编辑器实例通过createEditor创建内容通过 v-model 或editor.getHtml()获取销毁必须显式调用editor.destroy()。这个变化带来的直接影响是你在网上搜到的大部分中文教程如果是 2022 年以前写的八成是 v4直接抄会报错最典型的就是TypeError: xxx is not a function。判断版本有个简单办法——看安装的包名。npm i wangeditor装的是 v4npm i wangeditor/editor装的才是 v5。v5 新增了mode: simple这种更简洁的工具栏形态也把菜单配置统一收进了MENU_CONF整体更符合现代前端工程化的习惯。1.3 和其他主流方案的横向对比光说 wangEditor 好没有意义得放到坐标系里看。我把实际项目里用过的几款放在一起做了个对比判断维度就三个接入成本、定制深度、生态活跃度。编辑器数据模型上手成本深度定制中文支持典型场景wangEditor v5HTML 字符串低中优秀后台管理、CMSTinyMCEHTML 字符串中高商业版更强良好企业级表单Quill 2.xDelta 模型中中高一般轻量嵌入TiptapProseMirror 文档树高极高一般协同、块编辑md-editor-v3Markdown 文本低中优秀技术社区从表格能看出来wangEditor 在接入成本低和中文支持好这两个维度上优势明显正好命中后台管理系统的核心诉求——运营同学要的是一个打开就能用、工具栏看得懂、图片能拖拽上传的编辑器而不是一个需要专门培训的协同工具。所以接下来的内容都建立在后台管理系统 Vue3 TypeScript Element Plus这个组合上这也是目前国内企业项目最主流的形态。2. 环境搭建依赖装错后面全是白费2.1 先把项目基线和 Node 版本确认清楚开工前先node -v看一眼。wangEditor v5 依赖现代浏览器 API构建侧对 Node 版本有要求Node 16 以下在 Vite 4/5 环境下经常出现依赖解析失败。我的习惯是 Node 18 LTS 起步配合 Vite 5 和 Vue 3.4这个组合目前最稳。如果你接手的是一个老项目用 Vue CLI 5 Webpack 5 也能跑只是打包体积优化空间会小一些。用 Vite 创建项目的话一路默认走下去就行npm create vitelatest my-admin -- --template vue-ts cd my-admin npm install装完之后检查package.json里 vue 的版本确保是 3.2 以上。3.2 之前没有script setup的稳定支持而 wangEditor 的 Vue 封装在组合式 API 下写起来最舒服。这一步看着啰嗦但我见过太多次编辑器渲染不出来最后发现是 Vue 版本太老或者依赖装了两份的问题——尤其是当项目里已经存在一个旧版本编辑器依赖时npm 的扁平安装策略会把两个版本都塞进node_modules运行时就随机命中症状是偶发的白屏。2.2 两个包各管什么一个都不能少v5 的 Vue3 集成需要装两个包这是很多人第一次会困惑的地方npm install wangeditor/editor npm install wangeditor/editor-for-vuenext第一个wangeditor/editor是编辑器内核包含编辑器实例、菜单、插件、样式体积大头都在这里。第二个wangeditor/editor-for-vue是 Vue 的组件封装把Editor和Toolbar两个组件暴露出来让你能在模板里直接写Editor /。注意next这个标签非常关键。Vue3 版本必须用next不加的话会装上适配 Vue2 的版本然后在script setup里 import 就报错。装完可以确认一下版本号npm list wangeditor/editor-for-vue正常应该看到wangeditor/editor-for-vue5.x.x之类。如果看到的是 1.x那就是装错了卸载重装。2.3 样式文件必须单独引入这是新手最容易漏的一步也是导致工具栏变成一堆无样式的文字链接这个经典问题的元凶。wangEditor 的样式不在组件内部自动注入需要你手动引入一次import wangeditor/editor/dist/css/style.css引入位置有讲究。写在script setup顶部是最简单的方式但要注意它会被打进当前页面的 chunk。如果你在多个页面都用编辑器可以把这个引入放到全局的main.ts里代价是首屏会多加载一份样式。我更推荐的做法是配合路由懒加载让编辑器页面的 chunk 自己带上样式首屏用户不受影响。还有个坑是style scoped。有些同学想在 scoped 样式里覆盖编辑器的样式比如改工具栏背景色结果发现不生效。原因是 wangEditor 的 DOM 结构是动态生成的scoped 的属性选择器加不到那些节点上。要覆盖样式得用:deep()或者干脆写一个不带 scoped 的全局样式块专门管编辑器。3. 组件封装从能跑到好用3.1 最小可用版本与 shallowRef 的生死线先上能跑起来的最小版本。新建src/components/RichEditor.vuetemplate div classrich-editor-wrapper :style{ border: 1px solid #dcdfe6 } Toolbar :editoreditorRef :defaultConfigtoolbarConfig :modemode classeditor-toolbar / Editor :defaultConfigeditorConfig :modemode :style{ height: height px, overflowY: hidden } v-modelvalueHtml onCreatedhandleCreated onChangehandleChange / /div /template script setup langts import wangeditor/editor/dist/css/style.css import { onBeforeUnmount, ref, shallowRef } from vue import { Editor, Toolbar } from wangeditor/editor-for-vue import type { IDomEditor, IEditorConfig, IToolbarConfig } from wangeditor/editor const props withDefaults( defineProps{ modelValue: string height?: number mode?: default | simple readonly?: boolean }(), { modelValue: , height: 400, mode: default, readonly: false } ) const emit defineEmits{ (e: update:modelValue, value: string): void }() const editorRef shallowRefIDomEditor() const valueHtml ref(props.modelValue) const mode props.mode const toolbarConfig: PartialIToolbarConfig { excludeKeys: [group-video, insertVideo] } const editorConfig: PartialIEditorConfig { placeholder: 请输入内容... } const handleCreated (editor: IDomEditor) { editorRef.value editor if (props.readonly) editor.disable() } const handleChange (editor: IDomEditor) { emit(update:modelValue, editor.getHtml()) } onBeforeUnmount(() { const editor editorRef.value if (editor) editor.destroy() }) /script这段代码里最需要强调的是shallowRef。editorRef必须用shallowRef绝对不能用ref或者reactive。原因是 Vue3 的ref会对对象做深度响应式代理而 wangEditor 内部维护了大量 DOM 引用和循环引用被 Proxy 包一层之后编辑器在获取选区、操作节点时会拿不到原始对象症状是光标乱跳、菜单点击无响应、插入图片报错。这个坑我在两个项目里都踩过第二次才反应过来是响应式代理的问题。官方文档里提了一句但很容易被忽略这里我必须再强调一次。3.2 工具栏裁剪与模式切换默认工具栏把所有菜单都堆出来视频、公式、代码块全在对一个只发公告的后台系统来说太冗余。toolbarConfig的excludeKeys负责排除insertKeys负责插入或调整顺序。常用的菜单 key 我整理了一份方便你对着删功能分类菜单 key建议标题/引用headerSelect, blockquote保留基础格式bold, underline, italic, through, code保留颜色color, bgColor按需字号字色fontSize, fontFamily, lineHeight按需列表缩进bulletedList, numberedList, indent, delIndent保留对齐justifyLeft, justifyCenter, justifyRight, justifyJustify保留图片uploadImage, insertImage保留 uploadImage链接insertLink保留表格insertTable按需代码块codeBlock技术类保留分割线divider保留撤销重做undo, redo保留全屏fullScreen保留视频group-video后台一般删掉mode有两个取值default是完整模式simple是简洁模式工具栏只留最核心的十几个菜单视觉上更轻。选哪个取决于使用场景——面向运营的公告编辑器用simple面向内容编辑的 CMS 用default。注意mode一般只在初始化时设置运行中动态切换会导致工具栏重建体验上会有闪动不建议做成用户可切换的选项。3.3 图片上传必须从 base64 切换到服务端不加任何配置时wangEditor 插入图片走的是 base64 内联。写几篇文章没问题但要是一篇长图文里插了十几张高清图生成的 HTML 字符串会膨胀到几兆存进数据库字段会直接崩接口传输也会超时。所以图片上传必须改成服务端模式。配置位置在editorConfig.MENU_CONF[uploadImage]editorConfig.MENU_CONF[uploadImage] { server: /api/file/upload, fieldName: file, maxFileSize: 5 * 1024 * 1024, allowedFileTypes: [image/*], headers: { Authorization: Bearer getToken() }, customInsert(res, insertFn) { if (res.code ! 200) return insertFn(res.data.url, res.data.name, res.data.url) }, onFailed(file, res) { ElMessage.error(图片上传失败 (res.message || 未知错误)) } }几个要点拆开说。server是上传地址走相对路径让 Vite 的代理去转发避免开发环境跨域。fieldName要和后端接口约定的字段名一致后端用MultipartFile接的话通常就是file。maxFileSize单位是字节5MB 是我个人的经验值太大容易拖慢接口太小运营会抱怨截图传不上去。真正关键的是customInsert。默认情况下 wangEditor 会按自己的规则去解析返回数据它假设返回体里有个data.url字段。但你项目后端的返回结构很可能不是这个形状比如是{ code, msg, data: { fileUrl, fileName } }这时候默认解析就插不进去控制台能看到 undefined。customInsert给了你完全接管解析的机会拿到res之后自己取出 URL调用insertFn(url, alt, href)插进去就行三个参数分别是图片地址、替代文字、跳转链接后两个可以传空字符串。如果你的上传逻辑更复杂比如要先拿签名再传对象存储用customUpload完全接管上传过程editorConfig.MENU_CONF[uploadImage] { async customUpload(file, insertFn) { const formData new FormData() formData.append(file, file) const { data } await request.post(/api/upload, formData) insertFn(data.url, data.name, data.url) } }用一个/upload接口同时处理粘贴图片和拖拽上传是最省心的做法。3.4 v-model 绑定要绕开光标跳动这个雷直观上大家会写v-modelvalueHtml然后监听valueHtml的变化往外传。这能跑但有个隐患编辑器内部也会修改valueHtml如果你在外层又监听valueHtml并回写父组件父组件再把值传回来就形成了一个回环。光标位置在每次回写后都会被重置到开头用户打字时感觉像有人在抢键盘。更稳的做法是放弃v-model的自动双向绑定改用onChange手动往外抛Editor :defaultConfigeditorConfig :modemode onCreatedhandleCreated onChangehandleChange /const handleChange (editor: IDomEditor) { emit(update:modelValue, editor.getHtml()) }父组件用v-model:modelValue接收即可。这里要理解一个原则——编辑器的内容只有两个出口初始化时的赋值和用户编辑时的 onChange。除此之外任何时候去改编辑器的内容都可能触发重渲染和光标问题。回显历史数据时直接把值放到初始化的valueHtml里之后就别再动它。4. 高频故障排查与避坑清单4.1 编辑器实例为 null 和重复创建最常见的报错是Cannot read properties of undefined (reading getHtml)原因通常是你在onCreated之前就去调用了editorRef.value的方法。editorRef是在handleCreated回调里才被赋值的而handleCreated在组件 mounted 之后才触发。所以任何需要在编辑器就绪后做的事比如回填内容、设置只读、聚焦都得放在handleCreated里面或者用一个isReady标志位守着。第二个坑是重复创建。如果你在onMounted里手动createEditor同时又用了Editor组件编辑器会被创建两次页面上出现两个工具栏或者内容区闪烁。用官方组件就不要自己 create两套东西二选一。还有一种情况是热更新导致的重复实例开发时改代码触发热更新旧实例没销毁干净表现是切换页面后旧内容残留。解决方式就是在onBeforeUnmount里老老实实editor.destroy()一行都不能省。editor.destroy()不只是清理 DOM它还会解绑所有事件监听、清空内部定时器和选区缓存。漏掉它长时间运行的 SPA 会在连续打开关闭几十个编辑器页面后明显卡顿内存占用一路往上涨。4.2 样式丢失与下拉菜单被遮挡工具栏渲染成纯文字、菜单弹层藏在别的元素底下这两类是样式问题的高发区。第一个问题的解法前面说过补上style.css的引入。如果引入了还是不生效检查一下项目里是不是有其他全局样式把.w-e-*类的样式覆盖了尤其是那些用*选择器重置 margin、padding 的老代码会把编辑器的间距全干掉。下拉菜单被遮挡的根源是z-index。wangEditor 的弹层默认层级不算太高如果编辑器放在 Element Plus 的 Dialog 或者 Drawer 里而那个容器的层级更高菜单就会被压住。常规解法是给外层容器和编辑器加个更大的z-index.rich-editor-wrapper { position: relative; z-index: 100; }如果弹层还是出不来可以用editorConfig里的scroll配置成false让工具栏不吸顶某些布局下能绕开层级计算。我遇到过最麻烦的一次是编辑器在抽屉里抽屉本身有transform动画导致position: fixed的弹层定位基准错乱最后是把编辑器移到抽屉外的做法才彻底解决。所以选容器的时候尽量别把富文本编辑器塞进有动画的浮层里。4.3 与 Element Plus 表单联动的校验问题后端管理系统基本都配 Element Plus编辑器要嵌进el-form-item里做必填校验。这里有两个细节。第一个是触发时机。el-form-item的trigger要设成change然后编辑器的onChange里手动触发校验const handleChange (editor) { const html editor.getHtml() emit(update:modelValue, html) formRef.value?.validateField(content) }不手动触发的话用户输入完内容点提交校验规则拿到的还是旧值会误报内容不能为空。第二个是内容为空的判断。用户如果只敲了几个空格或者插入了一张图片editor.getHtml()返回的是pbr/p或者pimg ...//p直接判空字符串是不准的。稳妥的做法是先剥标签再判空const isEmptyHtml (html) { return html.replace(/[^]/g, ).replace(/nbsp;/g, ).trim() }这里要注意纯图片的内容会被判成空但业务上往往认为有图就算有内容。所以更精细的规则可以判断是否包含img标签包含则视为非空。这种边界判断看着琐碎却直接决定上线后运营会不会天天来找你。4.4 只读模式的几种实现方式对比详情页展示历史内容时需要只读商品预览时需要只读审核页面也需要只读。wangEditor v5 提供了几种实现各有适用场景实现方式写法优点局限disable()editor.disable()灵活、可动态切换需拿实例时机要对defaultConfig 中设 readOnly{ readOnly: true }初始化即生效无法运行中切换点击后 blureditor.blur()简单只挡焦点仍可编辑换纯 HTML 渲染v-html不加载编辑器性能好无编辑器样式能力我的选择是需要保留编辑器样式且随时可能切回编辑态的场景用editor.disable()纯展示、不打算再编辑的场景直接v-html渲染 HTML 字符串别加载整个编辑器内核。这个区分很重要详情页如果也加载编辑器首屏体积白白多几百 KB明明用户只是想看文章。顺带说一个只读相关的细节。editor.disable()生效后工具栏上的菜单会变灰但如果你希望工具栏整个隐藏用v-if控制Toolbar的渲染更干净Toolbar v-if!readonly :editoreditorRef /另外要提醒切换只读状态后editor.getHtml()依然能正常调用别担心 disable 之后拿不到内容。4.5 常见问题速查表把上面这些整理成一张排查表出问题时对着找能省不少时间。现象可能原因处理方式工具栏无样式未引入 style.css补上全局样式引入光标跳到开头v-model 双向回写改用 onChange 单向抛出菜单点击无效editorRef 用了 ref/reactive换成 shallowRef插入图片报错返回结构不匹配配置 customInsert内容拿不到在 onCreated 之前调用加 isReady 守位页面切换后卡顿未调用 destroyonBeforeUnmount 中销毁弹层被遮z-index 冲突调高层级或换容器内容判空不准空标签被判为有内容剥标签后再判空开发环境正常、线上白屏样式 chunk 未跟随检查懒加载配置5. 进阶内容安全、框架集成与体积控制5.1 富文本的 XSS 防护不能省富文本编辑器最容易被忽视的安全问题就是 XSS。用户尤其是后台运营提交的 HTML 会原样存储如果详情页用v-html直接渲染攻击者只要在编辑器里塞一段带事件的标签比如img srcx onerroralert(document.cookie)页面一打开就可能被利用。虽然 wangEditor 编辑时过滤了一部分危险标签但通过接口直接提交构造好的 HTML编辑器的过滤根本不起作用。所以渲染侧必须自己再过一道。我的做法是引入 DOMPurify在提交前和渲染前各清洗一次import DOMPurify from dompurify const safeHtml DOMPurify.sanitize(rawHtml, { ALLOWED_TAGS: [p, br, strong, em, u, h1, h2, h3, ul, ol, li, a, img, blockquote, table, tr, td, th], ALLOWED_ATTR: [href, src, alt, title, target, style, class] })白名单式过滤比黑名单靠谱得多只放行你明确需要的标签和属性其余一律去掉。注意style属性本身也可能藏东西如果你对安全要求更高可以把 style 也去掉改用自定义类名控制样式。这一步做起来有点烦但一旦内容被注入事故等级完全不一样值得花两个小时配好。5.2 在若依、JeecgBoot 这类框架里集成的注意点很多同学是在若依RuoYi-Vue3或者 JeecgBoot 的 Vue3 版本里做二次开发这些框架本身有自己的请求封装、全局样式、权限指令直接塞编辑器会撞上几个问题。第一个是请求实例。框架里通常把 axios 封装成了request或者service带了统一的 token 注入、错误提示、结果解包。图片上传时最好复用这个实例而不是在新开一个 axios否则会出现 token 没带上、上传接口 401 的情况。如果用 wangEditor 的server配置走内置上传记得在headers里手动补上 Authorization或者直接用customUpload走项目自己的 request。第二个是全局样式污染。若依那套后台框架有一大堆覆盖性的全局 CSS有时候会把编辑器里的列表符号干掉、把表格边框清掉。排查方式是在编辑器的样式后面追加一段更高优先级的覆盖.w-e-text-container ul { list-style: disc !important; } .w-e-text-container table { border-collapse: collapse; }第三个是 TypeScript 报错。有些框架的 tsconfig 开了strict而wangeditor/editor的类型声明在某些版本里不够严谨会在editorRef.value的赋值处提示类型不匹配。简单处理是用shallowRefIDomEditor | undefined()拿到实例后做一次非空断言别为了消错就把它改成any那等于把类型检查全关了。5.3 体积优化别让编辑器拖慢首屏我把wangeditor/editor单独打出来看过gzip 后大概两百多 KB对于一个管理后台来说不算夸张但如果它被打进首屏 chunk登录页的加载时间会明显变长——用户根本用不到编辑器凭什么要等他加载。解法是让它按需进入路由 chunk。如果你用的是 Vite路由懒加载本身就做了这件事只要编辑器组件被用在懒加载路由里它就不会进首屏const routes [ { path: /article/edit, component: () import(/views/article/Edit.vue) } ]如果编辑器被用在一个不懒加载的公共布局里用defineAsyncComponent包一层const RichEditor defineAsyncComponent(() import(/components/RichEditor.vue))配合Suspense或者加载占位用户体验会好很多。还有一个容易被忽略的体积点——如果项目里只用到了基础排版能力可以在toolbarConfig里把视频、公式这些重插件的菜单去掉虽然不能直接摇掉这部分代码但能避免加载相关资源。5.4 详情页只读展示的取舍最后聊聊详情页。我见过太多项目在详情页也挂一个完整的编辑器组件只为展示一段富文本。这样做有两个代价一是多加载两百多 KB 的编辑器内核二是编辑器的 DOM 结构比直接渲染 HTML 复杂得多列表长了之后页面滚动会明显发涩。如果详情页不需要任何编辑能力正确做法是只渲染 HTMLtemplate div classarticle-detail v-htmlsafeHtml/div /template然后在全局样式里给富文本标签补上基础样式让它看起来和编辑器里一致。这里有个小技巧——把编辑器里用到的样式抽一份到公共样式文件里编辑态和展示态共用避免出现编辑时好看、展示时排版全乱的尴尬.article-detail img { max-width: 100%; height: auto; } .article-detail table { border-collapse: collapse; width: 100%; } .article-detail p { margin: 8px 0; line-height: 1.8; }如果详情页确实需要保留编辑器外壳比如用户要看到和编辑时一样的界面再退回用editor.disable()。判断标准很简单用户在这页会改内容吗会用编辑器不会用 v-html。6. 一些不那么官方但很有用的经验折腾了这么多项目有几个体会想单独拎出来说。第一个是关于版本锁定。wangEditor v5 更新频率不算高但每次小版本升级偶尔会带出小问题比如菜单配置字段微调、样式类名变化。生产项目里我建议把版本号写死不要用^放开次版本号等新版本在测试环境跑过一轮再升。这个建议对所有第三方 UI 依赖都适用编辑器尤其敏感因为它的行为直接暴露给运营同学。第二个是关于封装粒度的取舍。我一开始把编辑器封成了支持十几项配置的万能组件结果每次维护都要在一堆 props 里翻找。后来改成只暴露四个核心 propsmodelValue、height、readonly、mode图片上传配置通过一个uploadConfig对象整体传入。这样既保证了复用又不至于让组件变成一个配置黑洞。组件这东西暴露的接口越少别人越愿意用。第三个是关于图片上传的联调。开发环境和生产环境的图片域名经常不一样配置里写的server如果是绝对地址切环境就得改代码。我现在的做法是统一走相对路径让 Nginx 或者 Vite 代理去转发配置里一行都不用改。如果后端返回的图片地址是完整的对象存储域名那在customInsert里可以做一层转换开发环境替换成代理前缀生产环境原样输出。第四个是关于内容回显的时机。从接口拿到历史 HTML 之后不要急着往valueHtml里塞。因为编辑器初始化是异步的你在onMounted里赋值的时候组件可能还没 ready内容会被吃掉。稳妥的流程是接口数据先存进一个变量在handleCreated里再赋给编辑器的初始内容或者直接用editor.setHtml()主动设置。这一点我在两个项目里都翻过车症状是编辑已有文章时内容展示不出来排查半天才发现是时序问题。最后一个提醒关于测试。富文本编辑器的坑大多不是逻辑问题而是浏览器差异和时序问题所以别只在 Chrome 里点两下了事。至少要在你要支持的浏览器里各走一遍完整流程新建内容、插入图片、加表格、保存、回显、切只读、再切回编辑。尤其是插入图片后内容能否正确保存是最容易在不同环境表现不一致的环节。我现在的习惯是给编辑器页面写一个两分钟的冒烟 checklist每次升级依赖或者改样式前都过一遍花的时间很少省下的事故排查时间却很多。