怎么在 Vue 3 和 React 项目中封装使用 Quill.js 编辑器
详细讲解在 Vue 3 与 React 项目中规范封装使用 Quill.js 富文本编辑器的完整方案。解析声明式框架与命令式 DOM 的交互冲突,提供防止光标跳动、React 18 严格模式双挂载防御、自定义工具栏与异步图片上传的完整代码实现与注意事项。
一、Quill.js 的运行机制与组件化封装的核心诉求
在进入具体框架代码前,首先需要明确 Quill.js 是如何管理内容与 DOM 的。Quill 并不依赖虚拟 DOM,而是在传入的容器节点中动态生成两部分结构:包含富文本按钮的工具栏 .ql-toolbar,以及带有 contenteditable="true" 属性的实际编辑区 .ql-editor。
Quill 使用 Delta(一种简洁的 JSON 格式)来描述文档内容及其增量变更。每当用户在键盘上敲下一个字符,Quill 内部会更新其选区(Selection API)与 Delta 树,然后触发 text-change 事件。要将这种传统的命令式库无缝适配进 Vue 3 与 React 的声明式生命周期中,必须妥善处理以下三项核心诉求:
- DOM 容器隔离与单例生命周期:避免重复执行
new Quill(),在组件挂载完成之后再获取真实 DOM 节点进行初始化,在组件卸载时及时解绑事件并释放内存引用。 - 双向数据同步中的死循环阻断:用户输入会触发
text-change并更新外部状态,而外部状态的变化又会通过props流回组件。若缺乏防环判定,重新赋值会导致编辑器再次触发变更事件,引发无限死循环。 - 输入焦点与光标保护:调用
quill.setContents()或直接重写innerHTML会导致编辑器内部 DOM 树被整体替换,此时浏览器会丢失当前选区,把光标强制扔到文档最前面。因此在用户处于聚焦输入状态时,绝不能从外部覆写编辑器内容。
二、在 Vue 3 项目中封装 Quill.js 编辑器
在 Vue 3 中,推荐使用 <script setup> 语法糖进行封装。我们定义一个专用的 QuillEditor.vue 组件,对外支持 v-model 双向绑定、自定义配置传入以及事件派发。
1. 依赖安装与样式引入
在项目根目录下通过包管理器安装 Quill 核心包。Quill 包含一套经典的 Snow 主题样式(也可选择 Bubble 气泡主题),必须在入口或组件中引入对应的 CSS 文件:
npm install quill
2. Vue 3 封装组件完整实现
在 src/components/QuillEditor.vue 中编写如下代码。注意内部使用普通变量(而非 ref() 或 reactive())来持有 Quill 实例,这是因为 Vue 的深度响应式代理(Proxy)可能会干扰 Quill 内部的 DOM 原型链引用,甚至带来不必要的性能损耗。
<template>
<div class="quill-editor-wrapper">
<div ref="editorContainer" class="quill-container"></div>
</div>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount, watch } from 'vue';
import Quill from 'quill';
import 'quill/dist/quill.snow.css';
const props = defineProps({
modelValue: {
type: String,
default: ''
},
placeholder: {
type: String,
default: '请输入内容...'
},
readOnly: {
type: Boolean,
default: false
},
toolbar: {
type: [Array, Object],
default: () => [
['bold', 'italic', 'underline', 'strike'],
[{ header: 1 }, { header: 2 }],
[{ list: 'ordered' }, { list: 'bullet' }],
['clean']
]
}
});
const emit = defineEmits(['update:modelValue', 'change', 'ready']);
const editorContainer = ref(null);
let quill = null;
onMounted(() => {
if (!editorContainer.value) return;
// 1. 实例化 Quill
quill = new Quill(editorContainer.value, {
theme: 'snow',
placeholder: props.placeholder,
readOnly: props.readOnly,
modules: {
toolbar: props.toolbar
}
});
// 2. 初始值注入
if (props.modelValue) {
quill.clipboard.dangerouslyPasteHTML(props.modelValue);
}
// 3. 监听内部内容输入,仅处理来自用户交互的改动
quill.on('text-change', (delta, oldDelta, source) => {
if (source === 'user') {
const html = quill.root.innerHTML;
// 当内容为空时,Quill 默认会有 <p><br></p>,做空文本归一化
const normalizedHtml = html === '<p><br></p>' ? '' : html;
emit('update:modelValue', normalizedHtml);
emit('change', { html: normalizedHtml, delta, quill });
}
});
emit('ready', quill);
});
// 4. 监听外部 props.modelValue 变更,落实防环与光标保护
watch(
() => props.modelValue,
(newVal) => {
if (!quill) return;
const currentHtml = quill.root.innerHTML === '<p><br></p>' ? '' : quill.root.innerHTML;
// 关键防环守卫:如果新值与当前富文本相同,或当前编辑器正在聚焦输入,则跳过外部覆写
if (newVal === currentHtml || quill.hasFocus()) {
return;
}
quill.clipboard.dangerouslyPasteHTML(newVal || '');
}
);
// 5. 监听只读状态动态变更
watch(
() => props.readOnly,
(val) => {
if (quill) {
quill.enable(!val);
}
}
);
// 6. 组件卸载时释放监听与引用
onBeforeUnmount(() => {
if (quill) {
quill.off('text-change');
quill = null;
}
});
</script>
<style scoped>
.quill-editor-wrapper {
display: flex;
flex-direction: column;
}
.quill-container {
min-height: 200px;
}
</style>
watch(modelValue) 中加入 !quill.hasFocus() 是解决光标跳跃的命门所在。当用户在编辑器里快速打字时,emit('update:modelValue') 会导致父组件更新传入的 modelValue。如果不做聚焦判定,watch 每次都会调用 pasteHTML 重新解析整个 HTML,导致浏览器的光标选区瞬间坍塌回第 0 位。
三、在 React 项目中封装 Quill.js 编辑器
在 React 中封装富文本编辑器时,面临的挑战更为显著,尤其是 React 18 的 StrictMode(严格模式)会在开发环境下模拟立即卸载并重新挂载组件。如果直接在 useEffect 里调用 new Quill(),页面上会瞬间出现两个重复堆叠的工具栏。
1. React 18 双挂载防御方案
Quill 会将 .ql-toolbar 插入到目标 DOM 容器的前面或内部。如果在 StrictMode 第二次挂载时旧的 DOM 未被清空,就会造成工具栏残留。最稳健的防御手段是:外层保留一个由 React 管理的静态容器,在 useEffect 内先主动清空容器内容,再在内部动态创建一个子 <div> 作为真正的 Quill 挂载点。
2. React 封装组件完整实现
在 src/components/QuillEditor.jsx 中实现如下组件。我们采用 forwardRef 结合 useImperativeHandle,既支持 value / onChange 数据流动,也允许父组件直接调用底层的 quill 实例:
import React, { useEffect, useRef, useImperativeHandle, forwardRef } from 'react';
import Quill from 'quill';
import 'quill/dist/quill.snow.css';
const defaultToolbar = [
['bold', 'italic', 'underline', 'strike'],
[{ header: [1, 2, false] }],
[{ list: 'ordered' }, { list: 'bullet' }],
['clean']
];
const QuillEditor = forwardRef((props, ref) => {
const {
value = '',
defaultValue = '',
placeholder = '请输入内容...',
readOnly = false,
toolbar = defaultToolbar,
onChange
} = props;
const containerRef = useRef(null);
const quillRef = useRef(null);
const onChangeRef = useRef(onChange);
// 始终保持最新 onChange 引用,避免 useEffect 依赖闭包过期
useEffect(() => {
onChangeRef.current = onChange;
}, [onChange]);
// 1. 初始化挂载与 StrictMode 双挂载防护
useEffect(() => {
const container = containerRef.current;
if (!container) return;
// 清空容器内所有遗留 DOM 节点(防御 React 18 StrictMode 双挂载导致多重工具栏)
container.innerHTML = '';
// 动态创建真正的编辑器容器
const editorEl = document.createElement('div');
container.appendChild(editorEl);
// 实例化 Quill
const quill = new Quill(editorEl, {
theme: 'snow',
placeholder,
readOnly,
modules: {
toolbar
}
});
quillRef.current = quill;
// 注入初始内容
const initialContent = value || defaultValue;
if (initialContent) {
quill.clipboard.dangerouslyPasteHTML(initialContent);
}
// 监听输入事件
quill.on('text-change', (delta, oldDelta, source) => {
if (source === 'user' && onChangeRef.current) {
const html = quill.root.innerHTML;
const normalizedHtml = html === '<p><br></p>' ? '' : html;
onChangeRef.current(normalizedHtml, delta, quill);
}
});
// 组件卸载清理函数
return () => {
quill.off('text-change');
quillRef.current = null;
container.innerHTML = '';
};
}, []); // 仅在挂载时运行一次
// 2. 响应受控 value 的变更(受控模式保护)
useEffect(() => {
const quill = quillRef.current;
if (!quill) return;
const currentHtml = quill.root.innerHTML === '<p><br></p>' ? '' : quill.root.innerHTML;
// 当外部传入值不同,且当前编辑器未处于输入聚焦状态时,执行同步
if (value !== undefined && value !== currentHtml && !quill.hasFocus()) {
quill.clipboard.dangerouslyPasteHTML(value || '');
}
}, [value]);
// 3. 响应 readOnly 属性变更
useEffect(() => {
if (quillRef.current) {
quillRef.current.enable(!readOnly);
}
}, [readOnly]);
// 4. 对外暴露底层实例与常用控制方法
useImperativeHandle(ref, () => ({
getQuill: () => quillRef.current,
focus: () => quillRef.current?.focus(),
blur: () => quillRef.current?.blur(),
clear: () => {
if (quillRef.current) {
quillRef.current.setText('');
}
}
}), []);
return <div ref={containerRef} className="quill-wrapper" style={{ minHeight: '200px' }} />;
});
QuillEditor.displayName = 'QuillEditor';
export default QuillEditor;
defaultValue,通过 ref.current.getQuill().root.innerHTML 或 onChange 收集数据)。这样能够将频繁的击键事件完全留在 Quill DOM 内部消化,避免 React 父组件在每一次按键时都触发重渲染,大幅提升长文档的编辑性能。
四、进阶实战:自定义图片上传与工具栏扩展
Quill 默认的图片按钮在用户选择文件后,会将其直接转换成巨大的 data:image/png;base64,... 编码字符串塞进 HTML 中。这不仅会导致页面在输入大图片时卡死,还会让后端数据库的字段急剧膨胀。生产级封装中,必须重写默认的 image 处理程序。
自定义 Image Handler 拦截逻辑
通过在模块配置中重写 toolbar.handlers.image,我们可以弹起原生文件选择框,将选中的图片通过 Ajax 或 Fetch 上传至对象存储(OSS),最后使用 quill.insertEmbed 将服务器返回的 CDN 链接插入到当前光标所在位置:
function customImageHandler() {
const quill = this.quill; // 当前 Quill 上下文
// 动态创建文件 input
const input = document.createElement('input');
input.setAttribute('type', 'file');
input.setAttribute('accept', 'image/png, image/jpeg, image/webp');
input.click();
input.onchange = async () => {
const file = input.files?.[0];
if (!file) return;
// 获取当前光标位置
const range = quill.getSelection(true);
try {
// 1. 模拟上传至服务器 (实际项目中替换为 axios.post 上传文件)
const formData = new FormData();
formData.append('image', file);
// 假设上传成功返回 CDN 图片地址
// const res = await uploadApi(formData);
const imageUrl = URL.createObjectURL(file); // 示例展示
// 2. 将图片 URL 插入到光标所在处
quill.insertEmbed(range.index, 'image', imageUrl);
// 3. 将光标后移一位,方便用户继续输入文字
quill.setSelection(range.index + 1);
} catch (error) {
console.error('图片上传失败:', error);
}
};
}
// 在初始化 Quill 选项中配置
const modules = {
toolbar: {
container: [
['bold', 'italic'],
['image', 'link']
],
handlers: {
image: customImageHandler
}
}
};
五、常见问题排查与验证清单
在完成封装并投入项目使用时,可对照以下常见问题进行针对性验证:
1. 工具栏样式错乱,图标不显示
原因:未正确引入 Quill 对应的 CSS 文件,或者构建工具未对样式文件进行打包。
排查:检查项目中是否执行了 import 'quill/dist/quill.snow.css';。若使用微前端或 Shadow DOM 隔离方案,请确保 Quill 样式表被挂载在正确的根作用域内。
2. 中文输入法选词过程中字符反复回退或提前闭合
原因:在中文拼音输入未确认前,浏览器处于合成事件(compositionstart / compositionend)状态。若外部状态更新强制调用了 pasteHTML,合成阶段会被强行中断。
排查:确保前文提到的 quill.hasFocus() 防护生效;若需极致体验,可额外监听容器的 compositionstart 和 compositionend,在输入法合成期间完全锁定外部同步。
3. 编辑器内容在提交前获取为空字符串
原因:Quill 在没有输入文字时,编辑区内部默认会填充 <p><br></p> 占位节点。
排查:在组件派发数据前进行空值规范化比对,即 html === '<p><br></p>' ? '' : html,保证表单校验时能够准确识别空输入。
Vue 和 React 项目中集成 qrcode.js 的注意事项
2026-11-02 10:28:19
Vue3实现多内容滑动轮播组件完整代码示例
2026-10-31 20:20:59
Element Plus 中 mt-x 类的使用与卡片间距调整
2026-10-31 16:50:52
前端qrcode生成二维码安装及使用示例详解
2026-10-31 15:22:14
Vue动态渲染Input数据不更新及动态表格列解决方案
2026-10-31 12:34:15








