ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Vue 项目集成 wangEditor 富文本编辑器实战与避坑指南

Vue 项目集成 wangEditor 富文本编辑器实战与避坑指南

1. 为什么在 Vue 项目里选富文本编辑器是个技术活

做过后台管理系统的朋友大概率都遇到过这个需求:运营同学跑过来说,文章编辑页那个纯文本框太难用了,标题加粗、插入图片、调个字号都得手写 HTML,能不能给换成那种所见即所得的编辑器。这个需求听起来简单,但真动手选型的时候你会发现,市面上的富文本编辑器多如牛毛,Quill、TinyMCE、CKEditor、wangEditor 各有各的脾气,选错了后面全是坑。

我自己在好几个 Vue 项目里都用过 wangEditor,从早期的 3.x 版本一路用到现在的 5.x,踩过的坑能写满一页纸。这篇文章就把我在 Vue 项目中使用 wangEditor 的完整经验梳理一遍,包括为什么选它、怎么集成、图片上传怎么配、样式冲突怎么解决、光标丢失怎么排查这些实际问题。如果你正在做后台内容管理、社区发帖、商品详情编辑这类功能,或者你已经被某个编辑器的坑折磨得够呛,那这篇内容应该能帮你省下不少时间。

wangEditor 是一个国产的开源富文本编辑器,特点是轻量、API 设计对国内开发者友好、文档是中文的、开箱即用的功能比较全。它不像 CKEditor 那样庞大,也不像 Quill 那样需要你对它的数据模型有深入理解才能玩转。对于大部分中小型项目来说,wangEditor 在功能覆盖和接入成本之间找到了一个不错的平衡点。但平衡归平衡,实际用起来还是有不少细节需要处理,尤其是和 Vue 的响应式系统配合的时候,稍不注意就会出问题。

2. 版本选型与项目环境准备

2.1 wangEditor 版本差异与选择建议

wangEditor 目前主流的是 5.x 版本,和 4.x 相比变化非常大。4.x 是基于 textarea 的封装思路,而 5.x 是完全重写的,采用了类似 Slate 的数据模型,底层用 contenteditable 实现,API 也完全不一样。如果你在网上搜到的教程用的是new wangEditor()这种写法,那大概率是 4.x 的,直接套到 5.x 上会报错。

5.x 的核心 API 变成了createEditor和createToolbar两个工厂函数,配置项通过config对象传入。这个设计比 4.x 更灵活,但也意味着你需要重新熟悉一遍文档。我的建议是,新项目直接上 5.x,不要因为网上 4.x 的教程多就选旧版本,5.x 在图片上传、自定义菜单、粘贴处理这些方面都成熟很多。

安装很简单,用 npm 或者 yarn 都行:

npm install @wangeditor/editor @wangeditor/editor-for-vue

注意这里装了两个包,@wangeditor/editor是核心包,@wangeditor/editor-for-vue是 Vue 的封装组件。如果你用的是 Vue 3,要确认装的editor-for-vue版本是支持 Vue 3 的,目前@wangeditor/editor-for-vue@5.x同时支持 Vue 2 和 Vue 3,但引入方式略有不同。

2.2 Vue 2 与 Vue 3 的引入差异

Vue 3 项目里这样引入:

import { Editor, Toolbar } from '@wangeditor/editor-for-vue'

Vue 2 项目里需要这样:

import { Editor, Toolbar } from '@wangeditor/editor-for-vue/dist/index.esm.js'

这个差异是因为包的入口文件对 Vue 2 和 Vue 3 做了区分,如果你在 Vue 2 项目里用了 Vue 3 的引入方式,会报组件未注册或者渲染异常的错误。我第一次在 Vue 2 项目里用的时候就被这个坑了一下,控制台报了一堆看不懂的警告,后来翻文档才发现是引入路径的问题。

另外,wangEditor 的样式文件需要单独引入:

import '@wangeditor/editor/dist/css/style.css'

这个 CSS 文件包含了编辑器的主体样式和工具栏样式,不引入的话编辑器会显示得乱七八糟,工具栏按钮全部挤在一起,编辑区域也没有边框。

2.3 基础集成的完整代码结构

在 Vue 3 的<script setup>里,一个最基础的集成大概长这样:

<template> <div style="border: 1px solid #ccc"> <Toolbar :editor="editorRef" :defaultConfig="toolbarConfig" mode="default" style="border-bottom: 1px solid #ccc" /> <Editor v-model="valueHtml" :defaultConfig="editorConfig" mode="default" style="height: 400px; overflow-y: hidden" @onCreated="handleCreated" /> </div> </template> <script setup> import { ref, shallowRef, onBeforeUnmount } from 'vue' import { Editor, Toolbar } from '@wangeditor/editor-for-vue' import '@wangeditor/editor/dist/css/style.css' const editorRef = shallowRef() const valueHtml = ref('<p>请输入内容...</p>') const toolbarConfig = {} const editorConfig = { placeholder: '请输入内容...' } const handleCreated = (editor) => { editorRef.value = editor } onBeforeUnmount(() => { const editor = editorRef.value if (editor == null) return editor.destroy() }) </script>

这段代码里有几个关键点需要特别注意。第一,editorRef必须用shallowRef而不是ref,因为 wangEditor 的实例是一个复杂的对象,如果用ref包裹,Vue 会尝试对它进行深度响应式代理,这会导致编辑器内部状态混乱,出现光标跳动、输入延迟甚至崩溃的问题。这个坑非常隐蔽,因为你不一定会立刻发现异常,但用着用着就会出各种奇怪的问题。

第二,onBeforeUnmount里必须调用editor.destroy(),否则编辑器实例不会被正确销毁,在单页应用里切换路由时会内存泄漏,反复进入编辑页面几次之后浏览器就会变得很卡。

第三,v-model绑定的valueHtml是 HTML 字符串,编辑器的内容变化会同步到这个变量上。但要注意,这个同步不是实时的,wangEditor 内部做了防抖处理,如果你需要在用户输入时立刻获取最新内容,不能依赖valueHtml,而应该通过editor.getHtml()方法主动获取。

3. 核心功能配置与图片上传实战

3.1 工具栏菜单的定制与裁剪

wangEditor 默认的工具栏菜单非常全,从标题、加粗、斜体到表格、代码块、视频都有。但实际项目里往往不需要这么多功能,比如一个简单的公告编辑场景,可能只需要加粗、斜体和列表就够了。这时候就需要通过toolbarConfig来裁剪菜单。

const toolbarConfig = { excludeKeys: [ 'group-video', 'insertTable', 'codeBlock', 'fullScreen' ] }

excludeKeys是排除法,把不想要的菜单 key 列进去就行。菜单的 key 可以在官方文档的菜单配置页查到,每个菜单都有一个唯一的 key。如果你想用包含法,可以用toolbarKeys来指定只显示哪些菜单,但要注意分组菜单的 key 和子菜单的 key 不一样,比如group-image是图片分组,里面包含insertImage、uploadImage等子菜单。

我个人的经验是,对于大多数后台管理场景,保留标题、加粗、斜体、下划线、删除线、字体颜色、背景色、有序列表、无序列表、对齐方式、链接、图片上传、撤销重做这些就够了。表格和视频功能虽然强大,但配置起来麻烦,而且用户实际使用频率很低,不如砍掉让界面更清爽。

3.2 图片上传的完整配置方案

图片上传是富文本编辑器里最容易出问题的环节。wangEditor 默认的图片上传是把图片转成 base64 直接塞进 HTML 里,这在正式项目里肯定不能用,因为 base64 字符串会让内容体积暴增,数据库存储和传输都会受影响。必须配置成上传到自己的服务器或者对象存储。

wangEditor 5.x 的图片上传配置有两种方式,一种是customUpload,完全自己实现上传逻辑;另一种是menuConf配合uploadImage配置。我推荐用customUpload,因为控制权完全在自己手里,想怎么传就怎么传。

const editorConfig = { placeholder: '请输入内容...', MENU_CONF: { uploadImage: { customUpload(file, insertFn) { const formData = new FormData() formData.append('file', file) // 调用你自己的上传接口 axios.post('/api/upload', formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then(res => { const url = res.data.url insertFn(url, file.name, url) }).catch(err => { console.error('图片上传失败', err) }) } } } }

这里的insertFn是 wangEditor 提供的回调,接收三个参数:图片地址、alt 文本、链接地址。调用insertFn之后,编辑器会自动在光标位置插入图片。注意customUpload是一个同步函数,但内部可以执行异步操作,只要在异步完成后调用insertFn就行。

有一个细节需要注意:customUpload的file参数是File对象,但 wangEditor 在调用之前已经对图片做了压缩处理(如果开启了压缩配置)。默认情况下,wangEditor 会把超过一定尺寸的图片压缩后再上传,这个行为可以通过compressSize和maxSize来调整。如果你不希望编辑器压缩图片,可以把compressSize设为 0。

3.3 图片上传的踩坑记录

我在图片上传这块踩过几个比较典型的坑,这里分享一下。

第一个坑是上传接口返回的数据结构。wangEditor 的insertFn需要的是图片的完整 URL,但很多后端接口返回的是相对路径,比如/uploads/2024/01/abc.jpg。如果你直接把相对路径传给insertFn,图片在编辑器里能显示(因为浏览器会基于当前域名拼接),但保存到数据库后再在其他地方渲染时,如果域名变了或者前端部署在不同的子路径下,图片就会 404。正确的做法是在上传成功后拼接完整的域名前缀,或者让后端直接返回完整 URL。

第二个坑是上传失败的处理。customUpload里如果上传失败,你需要在 catch 里给用户一个提示,否则用户会以为图片上传成功了但看不到图片。wangEditor 本身不会自动处理上传失败的 UI 反馈,这部分需要自己实现。我一般会在 catch 里调用一个全局的 message 组件弹出错误提示。

第三个坑是并发上传。wangEditor 支持一次选择多张图片,它会为每张图片分别调用customUpload。如果你的上传接口不支持并发,或者后端有并发限制,就需要自己加一个队列来控制。我遇到过一次用户一次性选了 20 张图片,结果后端接口被并发请求打挂的情况,后来加了一个简单的 Promise 队列才解决。

4. 样式冲突与响应式数据的那些坑

4.1 编辑器样式被全局样式污染

这个问题在使用了 UI 组件库的项目里特别常见。比如你用了某个组件库,它可能对p、h1到h6、ul、ol这些标签设置了全局样式,而 wangEditor 的编辑区域内部也是用这些标签来渲染内容的,结果就是编辑器里的内容和外部页面的样式混在一起,显示效果完全不对。

解决思路有两个方向。第一个方向是给编辑器容器加一个高优先级的样式隔离,比如用 scoped 样式加上:deep()选择器来覆盖:

.editor-wrapper :deep(.w-e-text-container) p { margin: 0; line-height: 1.6; }

第二个方向是在编辑器初始化时,通过editorConfig的contentStyle来注入内联样式,这样样式会直接作用在编辑区域的内容上,优先级最高:

const editorConfig = { contentStyle: { fontSize: '14px', lineHeight: '1.6', color: '#333' } }

我一般两个方向同时用,contentStyle保证基础样式,scoped 样式处理特殊情况。但要注意,contentStyle只影响编辑区域的显示,不影响最终保存的 HTML 内容。如果你希望保存的 HTML 在其他地方渲染时也有一致的样式,需要在渲染端也做相应的样式处理。

4.2 v-model 双向绑定的性能问题

前面提到v-model绑定的 HTML 字符串不是实时同步的,wangEditor 内部做了防抖。但即使有防抖,在内容很长的时候,每次同步都会触发 Vue 的响应式更新,如果这个 HTML 字符串还被其他计算属性或者侦听器依赖,就可能引发连锁更新,导致输入卡顿。

我遇到过一个极端情况:编辑页面的valueHtml被一个watch监听,每次变化都去调用一个接口做草稿保存。结果用户每输入几个字就发一次请求,不仅浪费带宽,还导致输入框偶尔卡住。后来改成手动控制保存频率,用setInterval或者用户主动点击保存按钮才触发,问题就解决了。

另外,如果你不需要把编辑器内容实时同步到 Vue 的响应式系统里,可以考虑不用v-model,而是通过@onChange事件手动获取内容:

const handleChange = (editor) => { // 这里可以做一些轻量级的处理,比如更新字数统计 // 但不要直接赋值给响应式变量,除非确实需要 }

4.3 编辑器实例与 Vue 响应式系统的隔离

再强调一遍shallowRef的重要性。wangEditor 的实例上挂载了大量的内部状态和方法,如果被 Vue 的reactive或ref深度代理,会出现一些难以排查的问题。我遇到过的一个典型症状是:编辑器初始化后一切正常,但切换几次路由再回来,编辑器就变成了只读状态,无法输入。排查了很久才发现是因为实例被响应式代理后,内部的状态判断出了问题。

除了用shallowRef,还要注意不要把编辑器实例挂载到reactive对象的属性上。比如:

// 错误做法 const state = reactive({ editor: null }) // 正确做法 const editorRef = shallowRef(null)

这个细节在官方文档里没有特别强调,但实际项目中非常关键。

5. 常见问题排查与独家避坑指南

5.1 编辑器不显示或显示异常的排查思路

编辑器不显示是最常见的问题,可能的原因有好几种。第一,样式文件没有引入,这时候编辑器会渲染出来但完全没有样式,工具栏按钮堆在一起。第二,容器没有设置高度,wangEditor 的编辑区域默认没有高度,如果父容器也没有高度,编辑器就会塌缩成一条线。第三,v-model绑定的变量初始值不是字符串,比如绑了一个null或者undefined,编辑器初始化时会报错。

排查的时候可以按这个顺序来:先看控制台有没有报错,再看样式文件是否引入,然后检查容器高度,最后检查绑定值。我一般会在编辑器外面包一个固定高度的 div,确保编辑器有足够的显示空间。

5.2 光标丢失与输入法兼容问题

光标丢失是 contenteditable 类编辑器的通病,wangEditor 也不例外。常见的场景是:用户在输入中文的时候,输入法候选框还没消失,编辑器内容被外部程序更新了,导致光标位置重置。这个问题在 Vue 的响应式更新和编辑器内部更新同时发生时特别容易出现。

缓解这个问题的办法是尽量减少外部对编辑器内容的直接修改。如果你需要从外部设置编辑器内容,不要直接改v-model绑定的变量,而是调用编辑器的 API:

editorRef.value.setHtml('<p>新的内容</p>')

另外,在输入法组合输入期间(compositionstart 到 compositionend 之间),尽量避免任何可能触发编辑器重渲染的操作。wangEditor 内部对输入法做了一些处理,但如果你在onChange里做了比较重的操作,还是可能干扰输入。

5.3 粘贴内容的清洗与格式控制

用户从 Word 或者网页上复制内容粘贴到编辑器里,往往会带进来一堆乱七八糟的样式和标签。wangEditor 默认会保留一部分格式,但有时候保留的格式并不是我们想要的。比如从网页复制的内容可能带有背景色、特殊字体、外链图片等,这些内容保存到数据库后,在前端渲染时可能会破坏页面布局。

wangEditor 提供了pasteFilterStyle和pasteIgnoreImg等配置来控制粘贴行为:

const editorConfig = { pasteFilterStyle: true, // 过滤粘贴内容的样式 pasteIgnoreImg: true, // 忽略粘贴内容中的图片 }

如果这些内置配置不够用,还可以通过onPaste钩子完全自定义粘贴处理逻辑。我一般会开启pasteFilterStyle,然后对粘贴的图片做特殊处理,因为用户从网页复制的图片往往是外链,直接保存外链图片存在失效的风险,最好是拦截下来上传到自己的服务器。

5.4 常见问题速查表

问题现象可能原因解决方向
编辑器不显示样式未引入或容器无高度引入 CSS 文件,设置容器高度
输入卡顿实例被深度响应式代理使用 shallowRef 包裹实例
图片上传后不显示返回的是相对路径拼接完整域名或让后端返回完整 URL
切换路由后编辑器只读实例未销毁或重复初始化onBeforeUnmount 中调用 destroy
粘贴内容样式混乱未开启样式过滤配置 pasteFilterStyle 或自定义 onPaste
光标跳到开头外部修改了绑定值用 editor.setHtml 代替直接改变量
工具栏按钮不显示菜单 key 配置错误检查 excludeKeys 或 toolbarKeys
编辑器内容为空时 v-model 为 null初始值未设置给 v-model 绑定变量设初始空字符串

6. 一些进阶玩法与个人经验总结

6.1 自定义菜单的扩展思路

wangEditor 5.x 支持自定义菜单,虽然文档里写得比较简略,但实际用起来并不复杂。核心思路是继承Boot类,实现getValue、isActive、exec等方法,然后通过toolbarConfig注册进去。我做过一个"插入商品卡片"的自定义菜单,点击后弹出一个商品选择器,选完后在编辑器里插入一个带有商品信息的卡片 HTML。这个功能用自定义菜单实现起来很顺畅,比在编辑器外面加按钮再手动插入内容要优雅得多。

6.2 内容回显与只读模式

编辑器的只读模式通过mode属性控制,设为'simple'就是只读。但要注意,只读模式下工具栏也应该隐藏或者禁用,否则用户点击工具栏按钮不会有任何反应,体验很奇怪。我一般会在只读模式下把整个工具栏隐藏掉,只保留内容展示区域。

内容回显的时候,如果 HTML 字符串比较大,编辑器的初始化会有一个明显的延迟。优化办法是先用一个 loading 占位,等编辑器onCreated之后再设置内容。不要在编辑器还没创建完成的时候就试图设置内容,那样不会生效。

6.3 我个人在实际项目中的几点体会

第一,wangEditor 的文档虽然比较全,但有些细节需要自己试才能搞清楚。比如customUpload的insertFn到底接收几个参数,文档里写的是三个,但实际测试发现 alt 和链接参数在某些版本里是可选的。遇到不确定的地方,直接看源码或者写个 demo 试一下,比反复翻文档快。

第二,编辑器的版本升级要谨慎。5.x 的小版本之间偶尔会有一些不兼容的改动,升级前最好先看一下 changelog,在测试环境验证一遍再上生产。我有一次直接升级了 minor 版本,结果图片上传的配置项名字变了,线上图片上传直接挂了,排查了半天。

第三,对于内容安全要求比较高的场景,不要完全依赖编辑器的过滤功能。wangEditor 的粘贴过滤和 XSS 防护有一定作用,但最可靠的做法还是在后端保存内容之前再做一次清洗,把不允许的标签和属性过滤掉。前端编辑器负责用户体验,后端负责安全兜底,这个分工不能乱。

第四,如果项目里有多处用到富文本编辑器,建议封装成一个通用的组件,把图片上传、菜单配置、样式隔离这些逻辑都收敛进去。这样后续要换编辑器或者调整配置,只需要改一个地方,不用满项目找。我现在的做法是封装一个RichEditor组件,对外暴露v-model和几个常用的 props,内部处理所有 wangEditor 的细节,业务页面用起来就很简单了。

这个内容后续还可以这样扩展:如果你需要支持 Markdown 语法,可以在编辑器外面加一个 Markdown 预览面板,用editor.getText()获取纯文本内容再做解析;如果需要多人协作编辑,那就需要考虑换一个支持 OT 或 CRDT 算法的编辑器方案,wangEditor 本身不提供协作能力。但那是另一个话题了,对于大多数单用户编辑场景,wangEditor 已经足够好用。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进