Vue3 + vue-quill全栈实战:从零构建企业级富文本编辑器

最近在重构公司CMS系统时,我遇到了一个典型需求:需要一个支持图片上传、样式可控且易于集成的富文本编辑器。经过技术选型,最终选择了Vue3生态下的vue-quill方案。本文将分享从安装配置到生产环境优化的完整实践路径,特别针对新手容易踩坑的环节提供解决方案。

1. 环境准备与基础配置

1.1 依赖安装与版本选择

首先需要明确的是,Vue3环境下需要使用专门适配的@vueup/vue-quill包。以下是推荐安装命令(根据你的包管理器选择):

# 使用npm
npm install @vueup/vue-quill quill-image-uploader --save

# 使用yarn
yarn add @vueup/vue-quill quill-image-uploader

# 使用pnpm
pnpm add @vueup/vue-quill quill-image-uploader

版本兼容性特别重要,我推荐以下组合:

  • @vueup/vue-quill@1.0.0+
  • quill@2.0.0+
  • quill-image-uploader@1.0.3+

提示:安装完成后建议锁定版本,避免后续更新导致API变更

1.2 基础组件封装

创建一个可复用的编辑器组件 RichEditor.vue

<template>
  <div class="rich-editor-container">
    <quill-editor
      ref="editor"
      v-model:content="content"
      content-type="html"
      :options="editorOption"
      @ready="onEditorReady"
    />
  </div>
</template>

<script setup>
import { QuillEditor } from '@vueup/vue-quill'
import { ref } from 'vue'
import ImageUploader from 'quill-image-uploader'
import '@vueup/vue-quill/dist/vue-quill.snow.css'

const content = ref('')
const editorOption = ref({
  modules: {
    toolbar: [
      // 工具栏配置将在后续章节展开
    ]
  }
})

const onEditorReady = (quill) => {
  console.log('Editor实例:', quill)
}
</script>

2. 图片上传功能深度实现

2.1 配置图片上传模块

默认情况下,vue-quill会将图片转为base64编码,这会导致数据库字段过大。我们需要通过quill-image-uploader扩展实现服务器上传:

// 在setup中添加
import { useUpload } from '@/composables/useUpload'

const { uploadImage } = useUpload()

const editorOption = ref({
  modules: {
    imageUploader: {
      upload: async (file) => {
        try {
          const { url } = await uploadImage(file)
          return url
        } catch (error) {
          console.error('上传失败:', error)
          throw new Error('图片上传失败,请重试')
        }
      }
    }
  }
})

对应的上传hook( useUpload.js ):

import { ref } from 'vue'
import axios from 'axios'

export function useUpload() {
  const loading = ref(false)
  const error = ref(null)

  const uploadImage = async (file) => {
    loading.value = true
    try {
      const formData = new FormData()
      formData.append('file', file)
      
      const res = await axios.post('/api/upload', formData, {
        headers: {
          'Content-Type': 'multipart/form-data'
        }
      })
      
      return res.data
    } catch (err) {
      error.value = err.message
      throw err
    } finally {
      loading.value = false
    }
  }

  return { uploadImage, loading, error }
}

2.2 上传状态反馈优化

为了提升用户体验,我们需要处理上传过程中的各种状态:

<template>
  <div class="editor-wrapper">
    <div v-if="uploading" class="upload-mask">
      <div class="upload-progress">
        <el-progress :percentage="progress" />
        <p>图片上传中,请稍候...</p>
      </div>
    </div>
    <quill-editor ... />
  </div>
</template>

<script setup>
const uploading = ref(false)
const progress = ref(0)

const editorOption = ref({
  modules: {
    imageUploader: {
      upload: async (file) => {
        uploading.value = true
        progress.value = 30
        
        try {
          const { url } = await uploadImage(file)
          progress.value = 100
          await new Promise(resolve => setTimeout(resolve, 500))
          return url
        } finally {
          uploading.value = false
        }
      }
    }
  }
})
</script>

<style scoped>
.upload-mask {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background: rgba(255,255,255,0.7);
  z-index: 10;
  display: flex;
  align-items: center;
  justify-content: center;
}
</style>

3. 工具栏定制与汉化方案

3.1 完整工具栏配置

以下是电商后台常用的工具栏配置方案:

const editorOption = ref({
  modules: {
    toolbar: [
      ['bold', 'italic', 'underline', 'strike'],        // 粗体、斜体、下划线、删除线
      ['blockquote', 'code-block'],                     // 引用、代码块
      [{ 'header': [1, 2, 3, 4, 5, 6, false] }],       // 标题
      [{ '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': [] }],                                // 对齐方式
      ['clean'],                                        // 清除格式
      ['link', 'image', 'video']                        // 链接、图片、视频
    ]
  }
})

3.2 Vue3下的汉化方案

在Vue3中,由于废弃了 /deep/ 语法,我们需要使用 :deep() 伪类来实现样式穿透:

<style scoped>
/* 全局汉化样式 */
:deep(.ql-snow .ql-tooltip[data-mode=link]::before) {
  content: "请输入链接地址:";
}

:deep(.ql-snow .ql-tooltip.ql-editing a.ql-action::after) {
  content: '保存';
}

:deep(.ql-snow .ql-picker.ql-header .ql-picker-label::before),
:deep(.ql-snow .ql-picker.ql-header .ql-picker-item::before) {
  content: '正文';
}

/* 标题级别汉化 */
:deep(.ql-snow .ql-picker.ql-header .ql-picker-label[data-value="1"]::before) {
  content: '标题1';
}

/* 字体大小汉化 */
:deep(.ql-snow .ql-picker.ql-size .ql-picker-label::before) {
  content: '标准';
}

/* 对齐方式汉化 */
:deep(.ql-align-center) {
  text-align: center;
}
</style>

4. 高级功能与性能优化

4.1 自定义图片大小限制

在图片上传前添加验证逻辑:

const editorOption = ref({
  modules: {
    imageUploader: {
      upload: async (file) => {
        // 限制图片大小不超过5MB
        if (file.size > 5 * 1024 * 1024) {
          throw new Error('图片大小不能超过5MB')
        }
        
        // 限制图片类型
        const allowedTypes = ['image/jpeg', 'image/png', 'image/gif']
        if (!allowedTypes.includes(file.type)) {
          throw new Error('仅支持JPEG、PNG和GIF格式')
        }
        
        return await uploadImage(file)
      }
    }
  }
})

4.2 图片压缩方案

对于需要处理大图的场景,可以集成compressorjs进行前端压缩:

import Compressor from 'compressorjs'

const compressImage = (file) => {
  return new Promise((resolve, reject) => {
    new Compressor(file, {
      quality: 0.6,
      maxWidth: 1920,
      maxHeight: 1080,
      success(result) {
        resolve(result)
      },
      error(err) {
        reject(err)
      }
    })
  })
}

// 在upload方法中使用
const upload = async (file) => {
  const compressedFile = await compressImage(file)
  return await uploadImage(compressedFile)
}

4.3 与UI框架集成技巧

以Element Plus为例,实现表单验证集成:

<template>
  <el-form :model="form" :rules="rules">
    <el-form-item label="文章内容" prop="content">
      <rich-editor v-model="form.content" />
    </el-form-item>
  </el-form>
</template>

<script setup>
const form = ref({
  content: ''
})

const rules = {
  content: [
    { 
      validator: (rule, value, callback) => {
        const text = value.replace(/<[^>]+>/g, '').trim()
        if (!text) {
          return callback(new Error('内容不能为空'))
        }
        if (text.length < 30) {
          return callback(new Error('内容至少30个字符'))
        }
        callback()
      }
    }
  ]
}
</script>

5. 生产环境最佳实践

5.1 错误处理与重试机制

完善的上传错误处理流程:

const uploadWithRetry = async (file, maxRetries = 3) => {
  let lastError = null
  
  for (let i = 0; i < maxRetries; i++) {
    try {
      return await uploadImage(file)
    } catch (error) {
      lastError = error
      await new Promise(resolve => setTimeout(resolve, 1000 * (i + 1)))
    }
  }
  
  throw lastError || new Error('上传失败')
}

// 在编辑器配置中使用
const editorOption = ref({
  modules: {
    imageUploader: {
      upload: uploadWithRetry
    }
  }
})

5.2 内容安全处理

防止XSS攻击的内容过滤方案:

import DOMPurify from 'dompurify'

const sanitizeContent = (html) => {
  return DOMPurify.sanitize(html, {
    ALLOWED_TAGS: ['p', 'h1', 'h2', 'h3', 'strong', 'em', 'img', 'a', 'ul', 'ol', 'li'],
    ALLOWED_ATTR: ['href', 'src', 'alt', 'class', 'style']
  })
}

// 在提交前处理
const handleSubmit = () => {
  const cleanContent = sanitizeContent(form.value.content)
  // 提交到服务器...
}

5.3 性能监控与优化

添加编辑器性能监控:

import { onMounted } from 'vue'

onMounted(() => {
  const editor = editorRef.value
  const startTime = performance.now()
  
  editor.on('text-change', () => {
    const now = performance.now()
    console.log(`输入延迟: ${now - lastInputTime}ms`)
    lastInputTime = now
  })
  
  const loadTime = performance.now() - startTime
  console.log(`编辑器初始化耗时: ${loadTime}ms`)
  
  // 上报性能数据
  reportPerformance({
    loadTime,
    type: 'rich-editor'
  })
})

在实际项目中,我发现将编辑器封装为独立组件后,配合Vue的keep-alive可以显著提升二次打开的性能。同时,对于内容较多的场景,建议实现懒加载策略,只在用户需要编辑时才初始化编辑器实例。

更多推荐