Vue3项目里选哪个富文本编辑器?选了又怎么踩坑避坑?
先别急着搜“Vue3富文本编辑器Top10”,得先搞清楚你要做啥场景
最近很多刚上手Vue3的朋友来找我聊,第一句就是甩个链接甩个需求:“我要做个论坛,用哪个富文本编辑器?给个快速上手的!”“我是电商商家后台,要加商品详情,求推荐无bug能加自定义组件的!”其实根本没有所谓的“通用Top1”,不同场景、不同技术栈深浅、不同团队预算和维护成本,选出来的编辑器天差地别,我整理了下身边朋友、同事,还有之前帮客户接项目时用到的高频Vue3富文本编辑器,得先从「你要解决什么问题」这个核心切入分类。
如果你是做轻量级展示/评论区/私信草稿的项目
这类场景有几个共性:不需要太复杂的排版功能,字号加粗斜体下划线引用加个表情加个小图片(还可能限制格式和大小)、最多来个有序无序列表就行;加载速度要快,毕竟评论区或者私信框可能在移动端或者多弹窗的后台,太大会拖慢页面首屏或者交互响应;开发成本低,不用自己折腾太多配置或者二次封装,拿来就能用;出错率要低,毕竟不是人人都是专业排版师,输错了或者操作失误别直接崩了页面就行。 那选什么?Quill的Vue3封装版VueQuillEditor绝对是第一梯队,它原生的编辑器就很轻,Gzip压缩后大概只有20KB左右,连Markdown编辑器都可能比它大,而且配置超级简单,npm或者yarn装一下,然后在Vue3的组件里import注册一下,加个v-model双向绑定内容,一行div加class就能跑起来,表情的话有专门的emoji插件,不用自己一个个画;图片上传如果只需要本地展示或者上传到简单的服务器,自己写个handler函数替换掉默认的base64就行——默认base64会把图片直接塞到HTML里,评论存数据库太占空间,但拿来练手或者给内部小工具用没问题。 这里要提个小细节:轻量级场景也别太看不起配置,如果是新手,别一开始就加一堆插件把编辑器弄成大杂烩,插件多了不仅加载慢,还容易出兼容性问题,比如Quill的Delta格式,如果不是要做协同编辑,完全不用特意去研究,直接用getHTML()和setHTML()就行,和以前用的UEditor TinyMCE差不多逻辑,上手更快。
如果你是做内容创作类项目(公众号同款排版、CMS后台、在线文档轻量版)
这类场景就要进阶一点了:需要支持表格、插入音视频(最好能直接解析B站、抖音这种第三方链接)、自定义段落样式(比如首行缩进、行间距调整不是只有默认的那几个)、导出为PDF或者Word(哪怕只是基础版)、可能还要有撤销重做的历史记录更流畅的版本;二次封装的空间要大,比如要加个“一键生成标题图占位符”“一键插入电商商品链接卡片”这种业务相关的组件;社区生态要好,遇到问题比如在Vue3的setup语法糖里怎么绑定toolbar的自定义按钮,怎么处理移动端的触摸输入延迟,随便搜搜就能找到解决方案,不用自己啃源码啃好几天。 这个梯队的选择就多了,但我最推荐的是TinyMCE的Vue3封装版,很多人可能觉得TinyMCE太老了,但是新版本(TinyMCE 6.x)已经完全重构了,用的是TypeScript写的,和Vue3的组合式API适配得特别好,Gzip压缩后加上常用插件大概也只有100KB左右,不算太大。 为什么推荐它不推荐Slate或者Draft.js的Vue3封装版?Slate和Draft.js的好处是完全可定制,UI都能自己画,但问题是太重了,二次封装的成本极高,没有个前端团队专门维护根本搞不定,小公司或者个人项目用的话,很容易因为某个UI细节改不出来或者某个插件没人写就卡壳,而TinyMCE 6.x的官方UI已经很现代化了,移动端也做了响应式优化,工具栏可以折叠,还能根据不同用户角色设置不同的工具栏权限——这个对内容创作类项目超级重要,比如普通作者只能用基础排版,主编才能加表格音视频和导出。 还有一个备选是CKEditor 5的Vue3封装版,它的模块化做得比TinyMCE还要好,你可以按需引入插件,比如不需要导出PDF就可以不装这个模块,加载速度可能更快一点,但CKEditor 5的文档有时候会有点绕,新手可能要花点时间才能搞懂配置,而且它的商业版功能比免费版全很多,比如免费版的导出PDF功能是有水印的,如果是做收费项目可能要考虑预算。
如果你是做专业协同编辑、在线协作文档(比如飞书文档轻量版)
这类场景的要求就非常高了:必须支持多人实时协同编辑,冲突处理要流畅;要有完整的文档版本控制,能回退到任意版本;要支持Markdown实时预览、语法高亮、LaTeX公式(学术类项目);可以插入复杂的自定义组件,比如流程图、思维导图、甘特图;技术栈要和Vue3深度适配,最好是用Vue3写的底层。 这个时候Quill、TinyMCE、CKEditor 5的免费版可能就不太够用了,因为它们的协同编辑要么是商业版才有的,要么是用WebRTC或者Socket.io自己搭的,冲突处理逻辑很难写好,那选什么?可以试试BlockSuite或者Vditor的高级协同版。 BlockSuite是AFFiNE(一个开源的在线协作文档工具)的底层编辑器,完全用TypeScript和Lit写的,但是提供了专门的Vue3组件库BlockSuite Vue,和Vue3的组合式API、Pinia都适配得很好,它的核心是“块”(Block)架构,每个段落、每个图片、每个表格都是一个独立的块,这样不仅冲突处理起来更简单,插入自定义组件也超级方便——你可以把自己写的任何Vue3组件封装成一个BlockSuite的块,直接插入到编辑器里,而且BlockSuite的协同编辑是免费开源的,用的是CRDT(无冲突复制数据类型),没有中心服务器的话也能在本地协同,有中心服务器的话可以用Y.js作为后端,部署起来也不难。 Vditor的高级协同版是基于Monaco Editor和Y.js做的,支持Markdown实时双向渲染,语法高亮做得非常好,学术类项目用的话LaTeX公式支持也很全,但是Vditor的高级协同版是收费的,个人项目如果预算有限的话,可以试试BlockSuite,如果你公司有充足的预算和技术团队,也可以自己用Slate或者Draft.js加上Y.js搭一个,但那样成本太高了,除非你的项目核心就是编辑器。
选好了编辑器,Vue3里这些坑90%的人都会踩,提前避坑能省好几天时间
选型是第一步,踩坑才是常态,我整理了下最近半年帮朋友和客户解决的Vue3富文本编辑器的问题,挑几个最常见的、大家踩得最多的来说。
坑1:setup语法糖里用v-model绑定编辑器内容,要么不更新,要么更新乱跳
这个坑绝对是新手入门踩的第一个坑,不管是用VueQuillEditor、TinyMCE Vue还是CKEditor 5 Vue,都可能遇到,为什么会这样?因为有些编辑器的Vue3封装版没有完全适配组合式API的响应式系统,或者你绑定的不是正确的响应式数据。 比如用VueQuillEditor的时候,很多人会直接写:
<template>
<VueQuillEditor v-model="content" />
</template>
<script setup>
import { ref } from 'vue'
import { VueQuillEditor } from '@vueup/vue-quill'
import '@vueup/vue-quill/dist/vue-quill.snow.css'
const content = ref('')
</script>
看起来好像没问题,但有时候你在编辑器里输入内容,content的值会更新,但有时候不会,或者你手动修改content的值,编辑器里的内容会乱跳。 怎么解决?其实很简单,VueQuillEditor的官方文档里其实有写,setup语法糖里要用v-model:content或者用v-model加contentRef,不过我更推荐用v-model:content,因为更符合Vue3的组合式API的习惯:
<template>
<VueQuillEditor v-model:content="content" contentType="html" />
</template>
<script setup>
import { ref } from 'vue'
import { VueQuillEditor } from '@vueup/vue-quill'
import '@vueup/vue-quill/dist/vue-quill.snow.css'
const content = ref('<p>这是初始内容</p>')
</script>
哦对了,还要加contentType="html",如果你不加的话,默认绑定的是Quill的Delta格式,新手如果不熟悉Delta格式的话,拿到数据存到数据库或者展示的时候都会有问题。 那TinyMCE Vue和CKEditor 5 Vue呢?TinyMCE Vue 6.x已经完全适配setup语法糖了,直接用v-model绑定一个ref或者reactive里的属性就行,不会有问题,CKEditor 5 Vue的话,如果你用的是ClassicEditor或者InlineEditor,直接用v-model也没问题,但如果你用的是DecoupledEditor(分离式编辑器,工具栏和编辑区是分开的),可能需要手动绑定editor.model.document.on('change:data')来更新数据,不过官方文档里也有详细的例子。
坑2:图片上传默认用base64,存数据库太占空间,换自定义上传又报错
这个坑也是轻量级和内容创作类项目都会遇到的,特别是电商后台的商品详情,一张商品图可能就有几MB,用base64存的话,一条商品详情可能就有几十MB,数据库很快就满了,加载速度也会特别慢。 那怎么换自定义上传?以VueQuillEditor为例,很多人会直接在toolbar里加个image按钮,然后在组件里写个uploadHandler函数,但写了之后要么图片上传成功编辑器里不显示,要么显示的是损坏的图片。 为什么会这样?因为Quill的uploadHandler函数需要返回一个Promise,Promise的resolve值必须是一个对象,对象里要有一个url属性,指向上传后的图片地址,不能直接返回字符串。 正确的写法应该是这样的:
<template>
<VueQuillEditor
v-model:content="content"
contentType="html"
:options="editorOptions"
/>
</template>
<script setup>
import { ref } from 'vue'
import { VueQuillEditor } from '@vueup/vue-quill'
import '@vueup/vue-quill/dist/vue-quill.snow.css'
const content = ref('<p>这是初始内容</p>')
// 自定义上传函数
const uploadImage = async (file) => {
// 这里可以加文件类型和大小的校验
if (!file.type.startsWith('image/')) {
alert('请上传图片文件!')
return
}
if (file.size > 5 * 1024 * 1024) {
alert('图片大小不能超过5MB!')
return
}
// 这里替换成你自己的图片上传接口
const formData = new FormData()
formData.append('file', file)
const res = await fetch('https://your-api.com/upload/image', {
method: 'POST',
body: formData
})
const data = await res.json()
// 必须返回一个有url属性的对象
return {
url: data.url
}
}
const editorOptions = ref({
modules: {
toolbar: {
container: [
['bold', 'italic', 'underline', 'strike'],
['blockquote', 'code-block'],
[{ 'header': 1 }, { 'header': 2 }],
[{ 'list': 'ordered'}, { 'list': 'bullet' }],
[{ 'script': 'sub'}, { 'script': 'super' }],
[{ 'indent': '-1'}, { 'indent': '+1' }],
[{ 'direction': 'rtl' }],
[{ 'size': ['small', false, 'large', 'huge'] }],
[{ 'header': [1, 2, 3, 4, 5, 6, false] }],
[{ 'color': [] }, { 'background': [] }],
[{ 'font': [] }],
[{ 'align': [] }],
['link', 'image', 'video', 'formula'],
['clean']
],
handlers: {
image: uploadImage
}
}
}
})
</script>
那TinyMCE Vue呢?TinyMCE Vue的自定义图片上传更简单,直接在editorInit里加images_upload_handler属性就行,也是返回一个Promise,resolve值是图片的url字符串:
const editorInit = ref({
selector: 'textarea', // 不用管,Vue封装版会自动处理
plugins: 'image link code',
toolbar: 'image link code',
images_upload_handler: async (blobInfo, success, failure) => {
try {
const formData = new FormData()
formData.append('file', blobInfo.blob())
const res = await fetch('https://your-api.com/upload/image', {
method: 'POST',
body: formData
})
const data = await res.json()
success(data.url)
} catch (error) {
failure('图片上传失败!')
}
}
})
这里要注意,TinyMCE Vue的images_upload_handler有两种写法,一种是用success和failure回调,另一种是返回Promise,推荐用Promise,更符合现代JavaScript的习惯。
坑3:编辑器里的内容在展示页面乱码或者样式丢失
这个坑主要出现在内容创作类项目,比如你在TinyMCE里写了一篇文章,设置了首行缩进、行间距、自定义字体,但是在展示页面打开的时候,要么这些样式都没了,要么文字乱码。 为什么会乱码?乱码的问题比较简单,主要是因为数据库的字符集不是utf8mb4,或者后端返回数据的时候没有设置正确的Content-Type(text/html; charset=utf-8),解决方法就是把数据库的字符集改成utf8mb4,后端返回数据的时候加上正确的Content-Type。 为什么会样式丢失?这个问题就比较复杂了,主要有三个原因: 第一个原因是编辑器自带的样式没有引入到展示页面,比如VueQuillEditor的snow主题和bubble主题都有自己的CSS,你在展示页面也要引入对应的CSS,不然引用、代码块、有序无序列表这些样式都会没了,TinyMCE的话,官方提供了content.css,你可以在展示页面引入,或者自己提取需要的样式。 第二个原因是你用了自定义的CSS类或者内联样式,但是展示页面的全局样式覆盖了这些样式,比如你在编辑器里给某个段落加了font-size: 16px,但是展示页面的全局CSS里有p { font-size: 14px !important; },那这个段落的字号就会变成14px,解决方法就是给展示页面的编辑器内容容器加一个特定的类名,然后把编辑器的样式或者自定义样式写在这个类名下面,提高CSS的优先级:
<template>
<div class="editor-content" v-html="content"></div>
</template>
<style scoped>
/* 提高优先级,避免被全局样式覆盖 */
.editor-content :deep(p) {
font-size: 16px !important;
}
.editor-content :deep(.ql-indent-1) {
padding-left: 3em;
}
.editor-content :deep(.ql-indent-2) {
padding-left: 6em;
}
/* 引入VueQuillEditor的snow主题样式,scoped里引入的话要加:deep()或者去掉scoped,推荐去掉scoped */
</style>
<style>
@import '@vueup/vue-quill/dist/vue-quill.snow.css';
</style>
哦对了,Vue的scoped样式是通过给元素加data-v-xxx属性来实现的,而v-html渲染出来的内容是没有这个属性的,所以如果编辑器的样式是写在scoped里的,必须加:deep()才能生效,或者直接去掉scoped样式——不过去掉scoped样式的时候要注意,给内容容器加一个非常独特的类名,避免影响页面的其他元素。 第三个原因是你用了第三方的图片或者视频链接,但是展示页面的CSP(内容安全策略)禁止加载这些资源,解决方法就是修改CSP,允许加载这些第三方资源,或者把第三方资源下载到自己的服务器上。
坑4:在弹窗里使用编辑器,编辑器不显示或者编辑区无法输入
这个坑也是很多人会遇到的,特别是后台管理系统,经常会把编辑器放在弹窗里,为什么会这样?主要有两个原因: 第一个原因是弹窗是异步渲染的,编辑器初始化的时候弹窗还没有显示,编辑区的高度或者宽度是0,所以编辑器不显示,解决方法就是等弹窗完全显示之后再初始化编辑器,或者给编辑器的编辑区设置一个最小高度和最小宽度。 比如用Element Plus的el-dialog组件的时候,可以用@opened事件来初始化编辑器:
<template>
<el-dialog v-model="visible" title="编辑文章" @opened="initEditor" width="80%">
<VueQuillEditor ref="editorRef" v-model:content="content" contentType="html" style="min-height: 400px;" />
</el-dialog>
</template>
<script setup>
import { ref, nextTick } from 'vue'
import { VueQuillEditor } from '@vueup/vue-quill'
import '@vueup/vue-quill/dist/vue-quill.snow.css'
import { ElDialog } from 'element-plus'
const visible = ref(false)
const content = ref('')
const editorRef = ref(null)
const openDialog = () => {
visible.value = true
}
const initEditor = () => {
nextTick(() => {
// 如果编辑器还没完全初始化,可以在这里做一些操作
if (editorRef.value) {
console.log('编辑器初始化成功')
}
})
}
</script>
第二个原因是弹窗的z-index比编辑器的工具栏或者下拉菜单的z-index低,所以编辑器的工具栏或者下拉菜单被弹窗盖住了,看起来像是编辑区无法输入或者不显示,解决方法就是修改编辑器的工具栏或者下拉菜单的z-index,让它比弹窗的z-index高。 比如VueQuillEditor的snow主题的下拉菜单的z-index默认是1000,而Element Plus的el-dialog组件的z-index默认是2000,所以需要把VueQuillEditor的下拉菜单的z-index改成比2000高:
.ql-snow .ql-picker-options {
z-index: 3000 !important;
}
TinyMCE的话,可以在editorInit里加z_index属性,把编辑器的z-index改成比弹窗高:
const editorInit = ref({
z_index: 3000,
// 其他配置
})
进阶玩法:Vue3富文本编辑器怎么插入自定义业务组件?
刚才在选型的时候提到了,专业协同编辑类项目需要插入复杂的自定义业务组件,其实内容创作类项目也经常需要,比如电商后台的“一键插入商品链接卡片”“一键插入活动海报占位符”,内容创作平台的“一键插入投票组件”“一键插入问卷组件”。 那怎么插入自定义业务组件?以BlockSuite Vue为例,因为它的块架构最适合插入自定义组件,你需要安装BlockSuite Vue和相关的依赖:
npm install @blocksuite/vue @blocksuite/presets @blocksuite/store
你可以把自己写的任何Vue3组件封装成一个BlockSuite的块,比如你要写一个“商品链接卡片”组件:
<!-- ProductCardBlock.vue -->
<template>
<div class="product-card">
<img :src="product.image" alt="product.name" class="product-image" />
<div class="product-info">
<h3 class="product-name">{{ product.name }}</h3>
<p class="product-price">¥{{ product.price }}</p>
<a :href="product.link" target="_blank" class="product-link">立即购买</a>
</div>
</div>
</template>
<script setup>
import { defineProps } from 'vue'
const props = defineProps({
product: {
type: Object,
default: () => ({
name: '示例商品',
price: '99.00',
image: 'https://via.placeholder.com/150',
link: '#'
})
}
})
</script>
<style scoped>
.product-card {
display: flex;
gap: 16px;
padding: 16px;
border: 1px solid #eee;
border-radius: 8px;
margin: 16px 0;
}
.product-image {
width: 150px;
height: 150px;
object-fit: cover;
border-radius: 4px;
}
.product-info {
flex: 1;
}
.product-name {
margin: 0 0 8px 0;
font-size: 18px;
font-weight: bold;
}
.product-price {
margin: 0 0 12px 0;
font-size: 20px;
color: #f00;
}
.product-link {
display: inline-block;
padding: 8px 16px;
background-color: #007bff;
color: #fff;
text-decoration: none;
border-radius: 4px;
}
</style>
你需要注册这个块到BlockSuite的编辑器里:
import { defineVueBlockComponent } from '@blocksuite/vue'
import ProductCardBlock from './ProductCardBlock.vue'
// 定义块的schema
const ProductCardSchema = defineVueBlockComponent({
tag: 'product-card',
component: ProductCardBlock,
props: {
product: {
type: Object,
default: () => ({
name: '示例商品',
price: '99.00',
image: 'https://via.placeholder.com/150',
link: '#'
})
}
}
})
你需要在编辑器里添加一个按钮,点击按钮就可以插入这个块:
<template>
<div class="blocksuite-editor">
<div class="toolbar">
<button @click="insertProductCard">插入商品卡片</button>
</div>
<affine-page ref="pageRef" />
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { AffinePage } from '@blocksuite/presets'
import { createEmptyDoc, Workspace } from '@blocksuite/store'
import ProductCardSchema from './ProductCardSchema'
const pageRef = ref(null)
const workspace = new Workspace({ id: 'my-workspace' })
const doc = createEmptyDoc(workspace)
onMounted(() => {
// 注册块的schema
doc.schema.register(ProductCardSchema)
// 加载文档
pageRef.value.doc = doc
})
const insertProductCard = async () => {
// 模拟从后端获取商品信息
const product = await fetch('https://your-api.com/product/1').then(res => res.json())
// 插入商品卡片块
const block = doc.addBlock('product-card', { product }, doc.root)
// 选中插入的块
doc.setSelection({
type: 'block',
blockId: block.id
})
}
</script>
<style scoped>
.blocksuite-editor {
width: 100%;
max-width: 800px;
margin: 0 auto;
padding: 24px;
}
.toolbar {
margin-bottom: 16px;
padding: 8px;
border: 1px solid #eee;
border-radius: 4px;
}
.toolbar button {
padding: 8px 16px;
background-color: #007bff;
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
}
</style>
是不是超级简单?BlockSuite的块架构真的是为插入自定义组件而生的,你可以把任何Vue3组件封装成一个块,直接插入到编辑器里,而且支持多人实时协同编辑,回退到任意版本。 如果你用的是TinyMCE或者CKEditor 5,也可以插入自定义组件,但要麻烦很多,需要自己写插件,处理组件的渲染、编辑、保存等逻辑,没有BlockSuite那么方便。
总结一下Vue3富文本编辑器的选型和避坑要点
选型的核心是「场景优先」:
- 轻量级场景(展示/评论区/私信草稿):选VueQuillEditor创作类场景(公众号同款排版/CMS后台/在线文档轻量版):选TinyMCE 6.x Vue或者CKEditor 5 Vue
- 专业协同编辑场景(在线协作文档):选BlockSuite Vue
避坑的核心是「提前看官方文档」:
- setup语法糖里绑定内容要注意格式,用v-model:content或者v-model加正确的响应式数据
- 图片上传要返回正确的Promise格式,不要用base64存大图片
- 展示页面要引入编辑器的样式,提高CSS优先级,注意CSP
- 弹窗里使用编辑器要等弹窗完全显示之后再初始化,调整z-index
进阶玩法的核心是「选择合适的编辑器架构」:
- 如果需要插入大量自定义业务组件,选块架构的编辑器(比如BlockSuite Vue)
- 如果只是偶尔插入一两个简单的自定义组件,可以用TinyMCE或者CKEditor 5的插件功能
我想说的是,没有最好的富文本编辑器,只有最适合你的富文本编辑器,在选型的时候,不要只看网上的Top10榜单,要先搞清楚自己的项目场景、技术栈深浅、团队预算和维护成本,然后再做选择,在开发的时候,遇到问题不要急着去搜别人的解决方案,先去看官方文档,官方文档一般都有最详细、最准确的解决方案。
版权声明
本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。
code前端网


