怎么在 Vue 3 和 React 项目中封装使用 Quill.js 编辑器

作者:模板散步

时间:2026-11-02

浏览:667

详细讲解在 Vue 3 与 React 项目中规范封装使用 Quill.js 富文本编辑器的完整方案。解析声明式框架与命令式 DOM 的交互冲突,提供防止光标跳动、React 18 严格模式双挂载防御、自定义工具栏与异步图片上传的完整代码实现与注意事项。

文章目录
在 Vue 3 和 React 这类现代前端框架中集成 Quill.js,核心难点在于“声明式状态驱动”与“命令式 DOM 操作”之间的行为冲突。Quill 是一个自成体系的富文本编辑库,由它直接接管 DOM 树的编辑、选区与光标状态;若直接套用框架常规的双向绑定思维强行注入新内容,极易导致光标跳回行首、中文拼音输入法异常中断、状态回流死循环以及组件重挂载产生多个工具栏。本文将梳理 Quill.js 的交互模型,并分别给出在 Vue 3(组合式 API)与 React(Hooks 模式)中稳定封装生产级编辑器的规范实现与避坑方案。
怎么在 Vue 3 和 React 项目中封装使用 Quill.js 编辑器
技术架构全景:声明式框架、拦截保护层与 Quill.js 编辑核心的协作拓扑

一、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 和 React 项目中封装使用 Quill.js 编辑器
图 1:声明式框架状态流与 Quill 命令式 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>
Vue 3 封装要点分析
在 watch(modelValue) 中加入 !quill.hasFocus() 是解决光标跳跃的命门所在。当用户在编辑器里快速打字时,emit('update:modelValue') 会导致父组件更新传入的 modelValue。如果不做聚焦判定,watch 每次都会调用 pasteHTML 重新解析整个 HTML,导致浏览器的光标选区瞬间坍塌回第 0 位。
怎么在 Vue 3 和 React 项目中封装使用 Quill.js 编辑器
图 2:Vue 3 组合式 API (script setup) 封装执行流程与时序

三、在 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 父组件在每一次按键时都触发重渲染,大幅提升长文档的编辑性能。
怎么在 Vue 3 和 React 项目中封装使用 Quill.js 编辑器
图 3:React Hooks 封装时序与 React 18 严格模式双挂载防御

四、进阶实战:自定义图片上传与工具栏扩展

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
    }
  }
};
怎么在 Vue 3 和 React 项目中封装使用 Quill.js 编辑器
图 4:Quill 组件封装核心痛点根因分析与工程解决对策矩阵

五、常见问题排查与验证清单

在完成封装并投入项目使用时,可对照以下常见问题进行针对性验证:

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,保证表单校验时能够准确识别空输入。

总结
在现代前端框架中封装 Quill.js,关键在于建立清晰的职责边界:让 Quill 专注负责内部 DOM 与选区的高性能渲染,让框架专注于外部数据的安全流转,通过“单例初始化”、“输入聚焦守卫”和“清理回收钩子”构筑稳固的拦截层,即可同时兼顾良好的开发体验与生产环境的稳定性。

实用工具推荐

电子包浆

绿化、压缩、噪点和虚拟水印,自由调节做旧程度。

使用 ›
图片压缩

选择画质和输出格式,预览压缩前后的大小。

使用 ›
相关文章