Code前端首页关于Code前端联系我们

Vue3项目里选哪个富文本编辑器?选了又怎么踩坑避坑?

terry 2小时前 阅读数 29 #Vue

先别急着搜“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前端网发表,如需转载,请注明页面地址。

热门