在现代前端开发中,富文本编辑器是许多场景的核心组件 ------ 无论是博客平台的内容创作、社交应用的评论系统,还是企业 CMS 的编辑模块,都离不开一款功能完善、体验流畅的富文本工具。但市面上现成的编辑器要么体积庞大、要么定制化能力弱,很难完美适配业务需求。
今天分享一个我基于 Vue3 生态开发的轻量级富文本编辑器方案,整合了 Quill 的强大编辑能力,支持按需扩展,可直接集成到现有项目中。

一、组件结构设计:分层管理视图与交互
编辑器组件采用了「核心编辑区 + 工具栏 + 辅助功能区」的三层结构,通过 Vue3 的模板语法实现清晰的视图分层:
ts
<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 实例的初始化:
ts
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()
})
关键设计点:
- 禁用 Quill 默认工具栏,完全自定义工具栏样式与交互,实现多端适配
- 通过
dangerouslyPasteHTML支持初始内容渲染,配合v-model实现双向绑定 - 监听三大核心事件,分别处理内容更新、格式状态同步和历史记录管理
三、核心功能实现:从基础编辑到高级交互
1. 格式控制:基于 Quill API 的样式切换
通过 Quill 的 format 方法实现文本格式控制,配合状态管理实现按钮激活状态同步:
ts
// 粗体
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)
}
}
这里的核心逻辑是「状态同步」:通过 quill.getFormat() 获取当前选区格式,存入 currentFormats 响应式对象,再通过 :class="{ active: ... }" 绑定到按钮,实现 UI 与实际格式的一致。
2. 媒体插入:图片 / 视频上传与嵌入
通过隐藏的 <input type="file"> 实现文件选择,配合 Quill 的 insertEmbed 方法插入媒体:
ts
// 图片上传
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 = ''
}
}
实际项目中,这里通常需要扩展替换为后端上传(通过 axios 发送文件,拿到 URL 后再插入)
3. 自定义功能:@用户与链接嵌入
通过 Quill 的自定义 Blot 机制实现富文本中的特殊元素(如 @用户、链接)
ts
// @用户插入逻辑
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 管理选中的话题标签,实现编辑器与话题选择页的数据共享:
ts
// 引入 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 实现样式隔离与响应式设计:
scss
// 核心编辑区样式
.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解决滚动问题 - 标签区域使用横向滚动,避免标签过多时换行影响布局
富文本编辑组件代码如下:
vue
<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>