从零搭建 Vue3 富文本编辑器:基于 Quill 可扩展方案

145 阅读9分钟

在现代前端开发中,富文本编辑器是许多场景的核心组件 —— 无论是博客平台的内容创作、社交应用的评论系统,还是企业 CMS 的编辑模块,都离不开一款功能完善、体验流畅的富文本工具。但市面上现成的编辑器要么体积庞大、要么定制化能力弱,很难完美适配业务需求。

今天分享一个我基于 Vue3 生态开发的轻量级富文本编辑器方案,整合了 Quill 的强大编辑能力,支持按需扩展,可直接集成到现有项目中。

view20251028.png

功能特性

✅ 基础文本编辑:加粗、斜体、引用、下划线等
✅ 段落格式:对齐方式、缩进、列表(有序 / 无序)
✅ 媒体插入:图片上传、视频嵌入
✅ 链接处理:插入 / 编辑链接
✅ @用户:提及功能
✅ 自定义工具栏:支持按需配置工具栏按钮
✅ 移动端适配:基于 Vant 组件库的响应式设计
✅ 状态持久化:编辑内容自动保存(可选)
✅ 轻量级:按需加载,避免冗余依赖

一、组件结构设计:分层管理视图与交互

编辑器组件采用了「核心编辑区 + 工具栏 + 辅助功能区」的三层结构,通过 Vue3 的模板语法实现清晰的视图分层:

<template>
    <div class="write-editor-wrapper">
    <!-- 1. 核心编辑区(Quill 实例挂载点) -->
    <div ref="editorContainer" class="quill-editor"></div>
    <!-- 2. 标签与字数统计区 -->
    <div class="custom-tags">...</div>
    <!-- 3. 自定义工具栏(含格式控制与功能按钮) -->
    <div class="custom-toolbar">...</div>
    <!-- 4. 功能弹窗(如插入链接对话框) -->
    <InsertLinkDialog ... /> </div>
</template>

这种结构的优势在于:

  • 编辑区与控制区分离,避免样式冲突
  • 工具栏采用「基础按钮 + 展开面板」设计,节省空间(尤其适配移动端)
  • 辅助功能(标签、字数统计)作为独立区块,不干扰核心编辑流程

二、Quill 初始化:自定义配置与事件监听

<script setup> 中,通过 onMounted 钩子完成 Quill 实例的初始化

onMounted(() => {
  // 初始化 Quill 实例,禁用默认工具栏(使用自定义工具栏)
  quill = new Quill(editorContainer.value!, {
    theme: 'snow',
    modules: {
      toolbar: false, // 关闭默认工具栏
      history: { // 配置撤销/重做功能
        delay: 2000,
        maxStack: 500,
        userOnly: true
      }
    },
    placeholder: props.placeholder || '请输入内容...'
  })

  // 初始化内容(支持从外部传入)
  if (props.modelValue) {
    quill.clipboard.dangerouslyPasteHTML(props.modelValue, 'silent')
  }

  // 绑定核心事件
  quill.on('text-change', handleTextChange)      // 内容变化时更新绑定值
  quill.on('selection-change', handleSelectionChange) // 选区变化时更新格式状态
  quill.on('editor-change', handleEditorChange)  // 编辑器状态变化时更新历史记录
})

关键设计点:

  1. 禁用 Quill 默认工具栏,完全自定义工具栏样式与交互,实现多端适配
  2. 通过 dangerouslyPasteHTML 支持初始内容渲染,配合 v-model 实现双向绑定
  3. 监听三大核心事件,分别处理内容更新、格式状态同步和历史记录管理

三、核心功能实现:从基础编辑到高级交互

1. 格式控制:基于 Quill API 的样式切换

通过 Quill 的 format 方法实现文本格式控制,配合状态管理实现按钮激活状态同步:

// 切换基础格式(粗体/斜体等)
function toggleFormat(name: string, value: any = true) {
  quill.focus()
  const range = quill.getSelection()
  if (!range) return
  // 切换激活状态(当前激活则取消,否则激活)
  const isActive = !!currentFormats[name]
  quill.format(name, isActive ? false : value)
  updateCurrentFormats() // 同步按钮状态
}

// 切换标题格式(自动取消列表/引用等冲突格式)
function toggleHeader(level: number) {
  quill.focus()
  const isActive = currentFormats.header === level
  if (isActive) {
    quill.format('header', false)
  } else {
    // 先取消冲突格式
    quill.format('list', false)
    quill.format('blockquote', false)
    quill.format('header', level)
  }
}

这里的核心逻辑是「状态同步」:通过 quill.getFormat() 获取当前选区格式,存入 currentFormats 响应式对象,再通过 :class="{ active: ... }" 绑定到按钮,实现 UI 与实际格式的一致。

2. 媒体插入:图片 / 视频上传与嵌入

通过隐藏的 <input type="file"> 实现文件选择,配合 Quill 的 insertEmbed 方法插入媒体:

// 图片上传处理
function onImageChange(e: Event) {
  const files = (e.target as HTMLInputElement).files
  if (files && files[0]) {
    const file = files[0]
    const reader = new FileReader()
    reader.onload = function (evt) {
      const url = evt.target?.result as string
      quill.focus()
      const range = quill.getSelection()
      if (range) {
        // 插入图片到当前选区
        quill.insertEmbed(range.index, 'image', url, 'user')
        // 光标移动到图片后
        quill.setSelection(range.index + 1)
      }
    }
    reader.readAsDataURL(file)
    // 清空输入,允许重复选择同一文件
    imageInput.value!.value = ''
  }
}

实际项目中,这里通常需要扩展:

  • 替换为后端上传(通过 axios 发送文件,拿到 URL 后再插入)
  • 增加上传进度显示(结合 Pinia 管理进度状态)
  • 图片压缩与格式校验(通过 canvas 压缩大图片)
3. 自定义功能:@用户与链接嵌入

通过 Quill 的自定义 Blot 机制实现富文本中的特殊元素(如 @用户标签、链接卡片):

// @用户插入逻辑
function handleAtSelect(user: any) {
  if (!quill) return
  quill.focus()
  const range = quill.getSelection()
  if (range) {
    // 插入自定义的 'at-user' 类型内容
    quill.insertEmbed(range.index, 'at-user', { id: user.id, name: user.name })
    // 调整光标位置
    quill.setSelection(range.index + user.name.length + 1)
  }
}

// 链接嵌入逻辑
function handleLinkSubmit(data: { url: string; text: string }) {
  quill.focus()
  const range = quill.getSelection()
  if (range) {
    quill.insertEmbed(range.index, 'link-embed', { url: data.url, text: data.text })
    quill.setSelection(range.index + 1)
  }
}

注意:自定义 Blot 需要提前定义(对应代码中的 import './editor-blots'),通过继承 Quill 的 Embed 类实现自定义元素的渲染与交互。

4. 状态管理:结合 Pinia 处理跨组件数据

使用 Pinia 管理选中的话题标签,实现编辑器与话题选择页的数据共享:

// 引入 Pinia 仓库
import { useArticleStore } from '@/store/article'
const articleStore = useArticleStore()

// 移除话题标签
function removeTopic(topic: any) {
  articleStore.removeTopic(topic)
}

// 跳转到话题选择页
function onTopicClick() {
  router.push('/topic?from=article')
}

这种设计让编辑器组件更专注于编辑功能,而标签数据由全局状态管理,方便在多个组件间共享。

四、样式设计:适配多端的响应式布局

通过 SCSS 结合 CSS Modules 实现样式隔离与响应式设计:

// 核心编辑区样式
.quill-editor {
  flex: 1;
  min-height: 0;
  border: none;
  overflow-y: auto;
}

// 自定义工具栏样式(移动端优化)
.custom-toolbar .toolbar-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  border-top: 1px solid $border-color;
  border-bottom: 1px solid $border-color;
}

// 格式面板采用弹性布局,适配小屏设备
.custom-toolbar .set-style-row {
  display: flex;
  flex-wrap: wrap;
  gap: 12px;
  padding: 12px;
}

// 标签区域横向滚动,避免溢出
.selected-tags {
  display: flex;
  gap: 8px;
  overflow-x: auto;
  scrollbar-width: none; // 隐藏滚动条
  -ms-overflow-style: none;
  white-space: nowrap;
}

关键样式优化:

  • 编辑区使用 flex: 1 实现高度自适应,配合 min-height: 0 解决滚动问题
  • 工具栏按钮采用固定尺寸(44px),符合移动端触控体验
  • 标签区域使用横向滚动,避免标签过多时换行影响布局

五、完整富文本组件代码

<template>
  <div class="write-editor-wrapper">
    <div ref="editorContainer" class="quill-editor"></div>
    <div class="custom-tags">
      <van-button
        v-if="hasTopic"
        style="
          background-color: #f5f5f5;
          border: none;
          color: #666;
          font-size: 13px;
          padding: 0 8px;
        "
        icon="plus"
        round
        size="mini"
        type="default"
        @click="onTopicClick"
        >话题
        <span class="topic-count" v-if="articleStore.selectedTopics.length"
          >{{ articleStore.selectedTopics.length }}/{{ articleStore.maxTopics }}</span
        ></van-button
      >
      <div v-if="articleStore.selectedTopics.length" class="selected-tags">
        <span v-for="topic in articleStore.selectedTopics" :key="topic.id" class="tag">
          #{{ topic.name }} <van-icon name="cross" @click="removeTopic(topic)" size="12" />
        </span>
      </div>
      <div class="text-length">{{ textLength }} · 草稿</div>
    </div>
    <div class="custom-toolbar">
      <div class="toolbar-row">
        <button @click="toggleSetStyle" :disabled="!isEditorFocused">A</button>
        <button @click="triggerImageInput" :disabled="!isEditorFocused">
          <img :src="getAssetUrl('icon_image.png')" alt="上传图片" />
        </button>
        <input
          ref="imageInput"
          type="file"
          accept="image/*"
          style="display: none"
          @change="onImageChange"
        />
        <button @click="triggerVideoInput" :disabled="!isEditorFocused">
          <img :src="getAssetUrl('icon_video.png')" alt="上传视频" />
        </button>
        <input
          ref="videoInput"
          type="file"
          accept="video/*"
          style="display: none"
          @change="onVideoChange"
        />
        <button @click="undo" :disabled="!isEditorFocused">
          <img :src="getAssetUrl('icon_undo.png')" alt="撤销" />
        </button>
        <button @click="redo" :disabled="!isEditorFocused || !canRedo">
          <img :src="getAssetUrl('icon_redo.png')" alt="重做" />
        </button>
        <button @click="toggleMore" :disabled="!isEditorFocused">
          <img :src="getAssetUrl('icon_more.png')" alt="更多" />
        </button>
      </div>
      <transition name="fade">
        <div class="set-style-row" v-if="showSetStyle">
          <button :class="{ active: currentFormats.header === 3 }" @click="toggleHeader(3)">
            <i class="iconfont icon-title">H</i>标题
          </button>
          <button :class="{ active: currentFormats.bold }" @click="toggleFormat('bold')">
            <i class="iconfont icon-bold">B</i> 粗体
          </button>
          <button :class="{ active: currentFormats.blockquote }" @click="toggleBlockquote()">
            <svg-icon name="blockquote"></svg-icon> 引用
          </button>
          <button @click="insertDivider"><svg-icon name="divider"></svg-icon> 分割线</button>
          <button
            :class="{ active: currentFormats.list === 'ordered' }"
            @click="toggleList('ordered')"
          >
            <svg-icon name="orderedList"></svg-icon> 有序列表
          </button>
          <button
            :class="{ active: currentFormats.list === 'bullet' }"
            @click="toggleList('bullet')"
          >
            <svg-icon name="bulletList"></svg-icon> 无序列表
          </button>
        </div>
      </transition>
      <transition name="fade">
        <div class="more-row" v-if="showMore">
          <button @click="insertLink"><svg-icon name="link"></svg-icon> 添加链接</button>
          <button @click="insertAttachment">
            <svg-icon name="attachment"></svg-icon> 添加附件
          </button>
          <button @click="goToAt"><i class="iconfont icon-at">@</i> 提到</button>
          <button @click="saveDraft"><svg-icon name="draft"></svg-icon> 草稿备份</button>
        </div>
      </transition>
    </div>

    <!-- 插入链接弹框 -->
    <InsertLinkDialog
      :show="showLinkDialog"
      @update:show="value => (showLinkDialog = value)"
      @submit="handleLinkSubmit"
      @cancel="handleLinkCancel"
    />
  </div>
</template>

<script setup lang="ts">
  import { ref, onMounted, reactive } from 'vue'
  import { getAssetUrl } from '@/utils/index'
  import { useRouter } from 'vue-router'
  import Quill from 'quill'
  import 'quill/dist/quill.snow.css'
  import InsertLinkDialog from './InsertLinkDialog.vue'
  import './editor-blots' // 导入Blot
  import { useArticleStore } from '@/store/article'

  const router = useRouter()
  const articleStore = useArticleStore()

  const props = defineProps({
    modelValue: { type: String, default: '' },
    placeholder: { type: String, default: '' },
    hasTopic: { type: Boolean, default: true }
  })
  const emit = defineEmits(['update:modelValue'])

  const editorContainer = ref<HTMLDivElement | null>(null)
  let quill: Quill

  // 插入链接弹框相关
  const showLinkDialog = ref(false)

  // quill初始化与相关更新
  const textLength = ref(0)
  const currentFormats = reactive<{ [key: string]: any }>({})
  const isEditorFocused = ref(false)
  const canRedo = ref(false)

  // 更新文本长度
  function updateTextLength() {
    if (!quill) {
      textLength.value = 0
    } else {
      textLength.value = getFullTextLength(quill)
    }
  }
  function getFullTextLength(quill: Quill) {
    const delta = quill.getContents()
    let length = 0
    delta.ops.forEach((op: any) => {
      if (typeof op.insert === 'string') {
        length += op.insert.replace(/\s/g, '').length // 只统计非空白
      } else if (op.insert['at-user']) {
        length += ('@' + op.insert['at-user'].name).length
      } else if (op.insert['link-embed']) {
        length += (op.insert['link-embed'].text || '').length
      }
      // 其他自定义Blot可继续扩展
    })
    return length
  }
  // 更新当前格式
  function updateCurrentFormats() {
    if (!quill) return
    const range = quill.getSelection()
    if (range) {
      const formats = quill.getFormat(range)
      Object.keys(currentFormats).forEach(key => delete currentFormats[key])
      Object.assign(currentFormats, formats)
    } else {
      Object.keys(currentFormats).forEach(key => delete currentFormats[key])
    }
  }

  // 更新重做状态
  function updateRedoState() {
    if (quill) {
      canRedo.value = quill.history.stack.redo.length > 0
    }
  }

  onMounted(() => {
    quill = new Quill(editorContainer.value!, {
      theme: 'snow',
      modules: {
        toolbar: false,
        history: {
          delay: 2000,
          maxStack: 500,
          userOnly: true
        }
      },
      placeholder: props.placeholder || '请输入内容...'
    })
    if (props.modelValue) {
      quill.clipboard.dangerouslyPasteHTML(props.modelValue, 'silent')
    }
    updateTextLength()
    quill.on('text-change', () => {
      emit('update:modelValue', quill.root.innerHTML)
      updateCurrentFormats()
      updateTextLength()
    })
    quill.on('selection-change', (range: any) => {
      updateCurrentFormats()
      isEditorFocused.value = !!range
    })
    quill.on('editor-change', (eventName: any) => {
      if (eventName === 'undo' || eventName === 'redo' || eventName === 'text-change') {
        updateRedoState()
      }
    })
    updateCurrentFormats()
    updateRedoState()
    tryInsertAtUser()
  })

  // 暴露 setContent 方法
  function setContent(html: string) {
    if (quill) {
      // 清空编辑器内容
      quill.setText('')
      // 设置新内容
      quill.clipboard.dangerouslyPasteHTML(html || '', 'silent')
      // 触发内容更新
      emit('update:modelValue', quill.root.innerHTML)
      // 更新文本长度
      updateTextLength()
    }
  }
  defineExpose({ setContent })

  // 工具栏/格式相关
  const showSetStyle = ref(false)
  const showMore = ref(false)
  function toggleSetStyle() {
    showSetStyle.value = !showSetStyle.value
    showMore.value = false
  }
  function toggleMore() {
    showMore.value = !showMore.value
    showSetStyle.value = false
  }
  function toggleFormat(name: string, value: any = true) {
    quill.focus()
    const range = quill.getSelection()
    if (!range) return
    const isActive = !!currentFormats[name]
    quill.format(name, isActive ? false : value)
    updateCurrentFormats()
  }
  function toggleHeader(level: number) {
    quill.focus()
    const isActive = currentFormats.header === level
    if (isActive) {
      quill.format('header', false)
    } else {
      quill.format('list', false)
      quill.format('blockquote', false)
      quill.format('header', level)
    }
  }
  function toggleList(type: 'ordered' | 'bullet') {
    quill.focus()
    const isActive = currentFormats.list === type
    if (isActive) {
      quill.format('list', false)
    } else {
      quill.format('header', false)
      quill.format('blockquote', false)
      quill.format('list', type)
    }
  }
  function toggleBlockquote() {
    quill.focus()
    const isActive = !!currentFormats.blockquote
    if (isActive) {
      quill.format('blockquote', false)
    } else {
      quill.format('header', false)
      quill.format('list', false)
      quill.format('blockquote', true)
    }
  }
  function undo() {
    quill.history.undo()
  }
  function redo() {
    if (!isEditorFocused.value || !canRedo.value) return
    quill.history.redo()
  }

  //  媒体插入相关
  const imageInput = ref<HTMLInputElement | null>(null)
  const videoInput = ref<HTMLInputElement | null>(null)
  function triggerImageInput() {
    imageInput.value?.click()
  }
  function triggerVideoInput() {
    videoInput.value?.click()
  }
  function onImageChange(e: Event) {
    const files = (e.target as HTMLInputElement).files
    if (files && files[0]) {
      const file = files[0]
      const reader = new FileReader()
      reader.onload = function (evt) {
        const url = evt.target?.result as string
        quill.focus()
        const range = quill.getSelection()
        if (range) {
          quill.insertEmbed(range.index, 'image', url, 'user')
          quill.setSelection(range.index + 1)
        }
      }
      reader.readAsDataURL(file)
      imageInput.value!.value = ''
    }
  }
  function onVideoChange(e: Event) {
    const files = (e.target as HTMLInputElement).files
    if (files && files[0]) {
      const file = files[0]
      const url = URL.createObjectURL(file)
      quill.focus()
      const range = quill.getSelection()
      if (range) {
        quill.insertEmbed(range.index, 'video', url, 'user')
        quill.setSelection(range.index + 1)
      }
      videoInput.value!.value = ''
    }
  }
  function insertDivider() {
    quill.focus()
    const range = quill.getSelection()
    if (!range) return
    quill.insertEmbed(range.index, 'hr', true, 'user')
    //将光标定位到文档末尾
    const len = quill.getLength()
    quill.setSelection(len - 1, 0)
  }

  // 标签相关
  function removeTopic(topic: any) {
    articleStore.removeTopic(topic)
  }

  // 话题选择
  function onTopicClick() {
    router.push('/topic?from=article')
  }

  function tryInsertAtUser() {
    const atUserStr = sessionStorage.getItem('atUser')
    if (atUserStr) {
      const atUser = JSON.parse(atUserStr)
      handleAtSelect(atUser)
      sessionStorage.removeItem('atUser')
    }
  }
  function handleAtSelect(user: any) {
    if (!quill) return
    quill.focus()
    const range = quill.getSelection()
    if (range) {
      quill.insertEmbed(range.index, 'at-user', { id: user.id, name: user.name })
      quill.setSelection(range.index + user.name.length + 1)
    }
  }

  function insertLink() {
    showLinkDialog.value = true
  }

  function handleLinkSubmit(data: { url: string; text: string }) {
    quill.focus()
    const range = quill.getSelection()
    if (range) {
      quill.insertEmbed(range.index, 'link-embed', { url: data.url, text: data.text })
      quill.setSelection(range.index + 1)
    }
  }

  function handleLinkCancel() {
    // 取消插入链接,不做任何操作
  }

  // @用户功能
  function goToAt() {
    router.push('/at')
  }

  function insertAttachment() {
    quill.focus()
    alert('此处可集成附件上传逻辑')
  }
  function saveDraft() {
    quill.focus()
    alert('此处可集成草稿保存逻辑')
  }
</script>

<style lang="scss" scoped>
  .write-editor-wrapper {
    width: 100%;
    flex: 1;
    min-height: 0;
    display: flex;
    flex-direction: column;
  }

  .quill-editor {
    flex: 1;
    min-height: 0;
    border: none;
    overflow-y: auto;
  }

  :deep(.ql-editor) {
    line-height: 1.8;
  }

  :deep(.ql-editor.ql-blank::before) {
    color: #bfbfbf;
    font-size: 15px;
    font-style: normal;
  }

  .custom-toolbar .toolbar-row {
    display: flex;
    align-items: center;
    justify-content: space-between;
    border-top: 1px solid $border-color;
    border-bottom: 1px solid $border-color;
  }

  .custom-toolbar .toolbar-row button {
    display: flex;
    align-items: center;
    justify-content: center;
    border: none;
    background: transparent;
    font-size: 20px;
    width: 44px;
    height: 44px;
    line-height: 44px;
    box-sizing: border-box;
    cursor: pointer;
  }

  .custom-toolbar .toolbar-row button:disabled {
    opacity: 0.5;
    cursor: not-allowed;
  }

  .custom-toolbar button img {
    width: 20px;
    height: 20px;
  }

  .custom-toolbar button i {
    font-size: 20px;
    font-style: normal;
  }

  .custom-toolbar .set-style-row {
    display: flex;
    align-items: center;
    justify-content: space-between;
    flex-wrap: wrap;
    gap: 12px;
    padding: 12px;
  }

  .custom-toolbar .set-style-row button {
    width: calc(50% - 8px);
    height: 44px;
    line-height: 44px;
    background-color: #f5f5f5;
    border-radius: 4px;
    font-size: 14px;
    color: #333;
    cursor: pointer;
    padding: 0 24px;
    text-align: left;
    display: flex;
    align-items: center;
    gap: 8px;
  }

  .custom-toolbar .set-style-row button.active {
    color: $primary-color;
    border: 1px solid $primary-color;
    background-color: rgba($primary-color, 0.1);
  }

  .custom-toolbar .more-row {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 12px;
  }

  .custom-toolbar .more-row button {
    width: calc(25% - 8px);
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: 8px;
    font-size: 14px;
  }

  .custom-toolbar .more-row button i {
    font-size: 24px;
    font-style: normal;
    width: 50px;
    height: 50px;
    line-height: 50px;
    background-color: #f5f5f5;
    border-radius: 4px;
  }

  .custom-toolbar .more-row button svg {
    width: 50px;
    height: 50px;
    line-height: 50px;
    background-color: #f5f5f5;
    border-radius: 4px;
    padding: 13px;
    box-sizing: border-box;
  }

  .custom-tags {
    display: flex;
    align-items: center;
    padding: 0 12px 12px;
    gap: 8px;
  }
  .custom-tags button {
    flex-shrink: 0;
  }
  .selected-tags {
    display: flex;
    flex-wrap: nowrap;
    gap: 8px;
    overflow-x: auto;
    overflow-y: hidden;
    scrollbar-width: none;
    -ms-overflow-style: none;
    white-space: nowrap;
  }
  .selected-tags::-webkit-scrollbar {
    display: none;
  }
  .selected-tags .tag {
    display: flex;
    align-items: center;
    background: rgba($color: $primary-color, $alpha: 0.1);
    color: $primary-color;
    border-radius: 12px;
    height: 24px;
    padding: 0 10px;
    font-size: 13px;
    white-space: nowrap;
    flex-shrink: 0;
  }

  .selected-tags .tag i {
    cursor: pointer;
    margin-left: 4px;
  }

  .custom-tags .text-length {
    flex-shrink: 0;
    font-size: 13px;
    color: #666;
    margin-left: auto;
  }

  :deep(.at-user),
  :deep(.link-embed) {
    color: #1989fa;
    padding: 0 4px;
    cursor: pointer;
  }
</style>