Quill 2.x 从 0 到 1 实战 - 为 AI+Quill 深度结合铺路

引言

在AIGC浪潮席卷各行各业的今天,为应用注入AI能力已从"锦上添花"变为"核心竞争力"。打造一个智能写作助手,深度融合AI与富文本编辑器,无疑是抢占下一代内容创作高地的关键一步。

而一切智能编辑的基石,在于一个稳定、强大且高度可定制的基础编辑器。本文将深度解析 ‌Quill 2.x------这个在现代Web开发中备受青睐的富文本编辑器解决方案。快来开始Quill2.x的教程吧!

本文将从概念解析到实战落地,补充核心原理、汉化方案和避坑指南,帮你真正吃透 Quill 2.x,看完就能直接应用到项目中。

一、Quill 核心概念:它到底是什么?

在动手之前,先搞懂 Quill 的核心定位,避免用错场景:

Quill 是一款「API 驱动的富文本编辑器」,核心设计理念是「让开发者能精准控制编辑行为」。它不同于传统编辑器(如 TinyMCE、CKEditor)的「配置式黑盒」,而是通过暴露清晰的 API 和内部状态,让开发者像操作 DOM 一样操作编辑器内容。

几个关键概念需要明确:

  • 容器(Container) :用于承载编辑器的DOM元素,Quill会接管该元素并渲染编辑区域
  • 模块(Modules) :编辑器的功能单元(如工具栏、代码块),2.x 中模块需显式注册。
  • 主题(Themes) :编辑器外观,官方提供 snow(带固定工具栏)和 bubble(悬浮工具栏)两种,支持自定义样式。
  • Delta:Quill 独创的内容描述格式(类似 JSON),用于表示内容本身和内容变化,是实现协同编辑、版本控制的核心。
  • 格式(Formats) :描述内容的样式属性(如加粗、颜色、链接),可通过 API 或工具栏触发,支持自定义扩展。

二、原理解析:Quill 是如何工作的?

理解底层原理,能帮你更灵活地解决问题。Quill 的核心工作流程可分为三部分:

1. 内容表示:Delta 格式

传统编辑器用 HTML 字符串描述内容,但 HTML 存在「同内容多表示」(如 <b><strong> 都表示加粗)、「难以 diff 对比」等问题。而 Delta 用极简的结构解决了这些问题:

Delta 本质是一个包含 ops 数组的对象,每个 opinsert(内容)和 attributes(样式)组成。例如:

css 复制代码
// 表示「Hello 加粗文本」的 Delta
{
  ops: [
    { insert: '这是一段 ' },
    { insert: '加粗文本', attributes: { bold: true } }
  ]
}
  • 优势 1:唯一性 ------ 同一内容只有一种 Delta 表示,避免歧义。
  • 优势 2:可合并 ------ 两个 Delta 可通过算法合并(如用户 A 和用户 B 同时编辑的内容),是协同编辑的基础。
  • 优势 3:轻量性 ------ 比 HTML 更简洁,传输和存储成本更低。

2. 渲染机制:2.x 版本的性能飞跃

Quill 1.x 直接操作 DOM 渲染内容,当内容量大时容易卡顿。2.x 重构了渲染逻辑,采用「虚拟 DOM 思想」优化:

  • 内部维护一份「文档模型(Document Model)」,作为内容的单一数据源。
  • 当内容变化,先更新文档模型,再通过「差异计算」只更新需要变化的 DOM 节点。
  • 减少 30% 以上的 DOM 操作,大幅提升大数据量场景(如万字长文)的流畅度。

3. 模块架构:功能的解耦与扩展

Quill 的所有功能都通过「模块」实现,核心模块包括:

  • toolbar:工具栏,控制格式按钮的显示和交互。
  • history:记录操作历史,支持撤销 / 重做。
  • table:2.x 原生支持的表格模块(1.x 需第三方扩展)。
  • clipboard:处理复制粘贴,自动过滤危险内容。

模块之间相互独立,开发者可按需注册,也能通过 Quill.register() 自定义模块,实现功能的灵活扩展。

三、快速入门:5 分钟搭建基础编辑器

安装依赖 -> 基础初始化 -> 核心API -> 预告

1. 安装依赖

bash

运行

sql 复制代码
# 核心包(2.x 版本)
pnpm add quill@2.x

# 表格模块(2.x 需单独安装,原生支持)
pnpm add @quilljs/table

2. 基础初始化

Step 1:HTML 容器

html 复制代码
<div id="editor" style="height: 300px;"></div>

Step 2:引入并注册模块

javascript 复制代码
import Quill from 'quill';
import 'quill/dist/quill.snow.css'; // 引入 snow 主题样式
import TableModule from '@quilljs/table'; // 表格模块

// 显式注册模块 
Quill.register('modules/table', TableModule);

Step 3:初始化配置 - 方案一

arduino 复制代码
const quill = new Quill('#editor', {
  theme: 'snow', // 选择主题
  modules: {
    toolbar: { 
        container: [
            // 每个数组是一个分组,里边每个项是一个工具栏最小配置单元
            ['bold', 'italic', 'underline', 'strike'], // 基本格式
            ['blockquote', 'code-block'], // 块引用和代码块 
            [{ 'header': 1 }, { 'header': 2 }], // 标题级别
            [{ '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': [] }], // 对齐方式
            ['link', 'image', 'video'], // 链接和媒体 
            ['clean'] // 清除格式 ], 
             // 方式2:使用选择器配置 // container: '#toolbar',
             // 方式3:使用自定义工具栏HTML 
             // container: document.getElementById('custom-toolbar') }
  },
  placeholder: '请输入内容...'
});

Step 3:初始化配置 - 方案2

arduino 复制代码
const quill = new Quill('#editor', {
  theme: 'snow', // 选择主题
  modules: {
    toolbar: { 
       // 使用选择器配置(或者document.getElementById('custom-toolbar'))
        container: '#toolbar',
       }
  },
  placeholder: '请输入内容...'
});
xml 复制代码
.custom-toolbar {
    display: flex;
    flex-wrap: wrap;
    gap: 5px;
    align-items: center;
}
.custom-toolbar .ql-formats {
    margin-right: 15px;
    display: flex;
    align-items: center;
}
.custom-toolbar button {
    border: 1px solid #ddd;
    border-radius: 5px;
    padding: 5px 10px;
    background: white;
    cursor: pointer;
    transition: all 0.3s ease;
}
.custom-toolbar button:hover {
    background: #e9ecef;
    border-color: #adb5bd;
}
.custom-toolbar select {
    border: 1px solid #ddd;
    border-radius: 5px;
    padding: 5px;
    background: white;
}
        
<div id="custom-toolbar" class="toolbar-container">
    <div class="custom-toolbar">
        <!-- 字体和大小 -->
        <span class="ql-formats">
            <select class="ql-font"></select>
            <select class="ql-size"></select>
        </span>

        <!-- 文本格式 -->
        <span class="ql-formats">
            <button class="ql-bold" title="粗体"></button>
            <button class="ql-italic" title="斜体"></button>
            <button class="ql-underline" title="下划线"></button>
            <button class="ql-strike" title="删除线"></button>
        </span>

        <!-- 颜色 -->
        <span class="ql-formats">
            <select class="ql-color" title="文字颜色"></select>
            <select class="ql-background" title="背景颜色"></select>
        </span>

        ....
    </div>
</div>

3. 核心 API:内容操作

go 复制代码
// 获取 Delta 内容(推荐存储)
const delta = quill.getContents();

// 获取 HTML 内容(用于展示)
const html = quill.root.innerHTML;

// 设置内容(支持 Delta 或纯文本)
quill.setContents([{ insert: 'Hello Quill\n', attributes: { bold: true } }]);

// 插入内容(在光标位置)
const range = quill.getSelection(); // 获取光标位置
quill.insertEmbed(range.index, 'image', 'https://example.com/img.png');

// 标记文案为黄色 -- 预告:下一篇文章我们会通过AI查找文档错误,然后用这个API标记错误内容
quill.formatText(
    startIndex, // 索引
    endIndex, // 索引
    {
      background: "yellow"
    },
    Quill.sources.SILENT
);

// 获取选区格式
quill.getFormat(index, 1)

// 指定位置追加内容 -- 需要保持格式  (预告:下一篇我们会用这个功能将AI扩写的内容追加到指定位置)
const formats = instance.value.getFormat(
  range.index + range.length - 1,
  1
);
quill.insertText(index, '追加内容', formats, Quill.sources.USER);

预告

  1. 下一篇文章我们会通过AI查找文档错误,然后用formatText标记错误内容
  2. 下一篇我们会用insertText将AI扩写的内容追加到指定位置
  3. 更多内容见下一篇文章

四、核心功能实战:从汉化到媒体处理

汉化 -> 增加工具栏-图片上传 -> 自定义quill格式 -> 自定义quill属性格式

1. 汉化:让编辑器「说中文」

Quill 默认提示为英文(如工具栏按钮的 tooltip),需手动汉化:

scss为例

标题汉化

标题汉化 复制代码
.editor-wrapper {
  :deep(.ql-toolbar) {
    .ql-picker.ql-header {
      width: 70px;

      .ql-picker-label::before,
      .ql-picker-item::before {
        content: "正文";
      }

      @for $i from 1 through 6 {
        .ql-picker-label[data-value="#{$i}"]::before,
        .ql-picker-item[data-value="#{$i}"]::before {
          content: "标题#{$i}";
        }
      }
    }
  }
}

字体汉化

css 复制代码
```字体汉化
.editor-wrapper {
  :deep(.ql-toolbar) {
    .ql-picker.ql-font {
      .ql-picker-item,
      .ql-picker-label {
        &[data-value="SimSun"]::before {
          content: "宋体";
          font-family: "SimSun" !important;
        }

        &[data-value="SimHei"]::before {
          content: "黑体";
          font-family: "SimHei" !important;
        }

        &[data-value="KaiTi"]::before {
          content: "楷体";
          font-family: "KaiTi" !important;
        }
 

        &[data-value="FangSong_GB2312"]::before {
          content: "仿宋_GB2312";
          font-family: "FangSong_GB2312", FangSong !important;
          width: 80px;
          overflow: hidden;
          white-space: nowrap;
          text-overflow: ellipsis;
          line-height: 24px;
        }

        
      }
    }
  }

  :deep(.ql-editor) {
    font-family: "SimSun", "SimHei", "KaiTi", "FangSong", "Times New Roman",
      sans-serif !important;
  }
}

汉化思路一致,不一一列出,有需要可随时私我

2. 图片上传:从本地到服务器

默认图片按钮只能输入 URL,需重写逻辑实现本地上传:

ini 复制代码
const toolbarOptions = {
  container: ['image'],
  handlers: {
    image: function() {
      const input = document.createElement('input');
      input.type = 'file';
      input.accept = 'image/*';
      
      input.onchange = (e) => {
        const file = e.target.files[0];
        if (!file) return;
        
        // 上传到服务器(替换为你的接口)
        const formData = new FormData();
        formData.append('file', file);
        
        fetch('/api/upload', { method: 'POST', body: formData })
          .then(res => res.json())
          .then(data => {
            // 插入图片到编辑器
            const range = quill.getSelection();
            quill.insertEmbed(range.index, 'image', data.url);
          });
      };
      
      input.click(); // 触发文件选择
    }
  }
};

3. 自定义规则:字体规则

注册字体 -> 工具栏配置 -> css适配

注册字体

typescript 复制代码
import Quill from "quill";

export const useFontHook = () => {
  // // 注册自定义字体
  const Font: Record<string, any> = Quill.import("attributors/style/font");
  Font.whitelist = [
    "FangSong_GB2312",
    "KaiTi_GB2312",
    "FZXBSJW-GB1-0",
    "FangSong",
    "SimSun",
    "SimHei",
    "KaiTi",
    "Times New Roman"
  ]; // 字体名称需与 CSS 定义一致
  Quill.register(Font, true);

  return {
    Font
  };
};

工具栏配置

实例化Quill时候配置font内容 复制代码
const { Font } = useFontHook();
... 
toolbar: {
    container: [
        [
            { size: SizeStyle.whitelist }, // 这里是自定义size
            {
              font: Font.whitelist
            }
          ], // custom dropdown
        ]
}

css适配

同汉化部分

css 复制代码
.editor-wrapper {
  :deep(.ql-toolbar) {
    .ql-picker.ql-font {
      .ql-picker-item,
      .ql-picker-label {
        &[data-value="SimSun"]::before {
          content: "宋体";
          font-family: "SimSun" !important;
        }

        &[data-value="SimHei"]::before {
          content: "黑体";
          font-family: "SimHei" !important;
        }

        &[data-value="KaiTi"]::before {
          content: "楷体";
          font-family: "KaiTi" !important;
        }
        ...
      }
    }
  }

  :deep(.ql-editor) {
    font-family: "SimSun", "SimHei", "KaiTi", "FangSong", "Times New Roman",
      sans-serif !important;
  }
}

4. 自定义属性格式 -- 以margin,值为em为例

Quill工具栏是没有边距效果的(有text-indent,场景不一样),需要自行写格式

javascript 复制代码
import Quill from "quill";
const Parchment = Quill.import("parchment");

const whitelist = ["2em", "4em", "6em", "8em"];

export function useMarginHook() {
  class MarginAttributor extends Parchment.StyleAttributor {
    constructor(styleName, key) {
      super(styleName, key, {
        scope: Parchment.Scope.BLOCK,
        whitelist
      });
    }

    add(node, value) {
      // 直接验证传递的字符串是否在白名单中
      if (!this.whitelist.includes(value)) return false;
      return super.add(node, value);
    }
  }

  Quill.register(
    {
      "formats/custom-margin-left": new MarginAttributor(
        "custom-margin-left",
        "margin-left"
      ),
      "formats/custom-margin-right": new MarginAttributor(
        "custom-margin-right",
        "margin-right"
      )
    },
    true
  );
}


// 工具栏配置
toolbar: [
  [{ 'custom-margin-left': ['2em', '4em', '6em', '8em'] }], 
  [{ 'custom-margin-right': ['2em', '4em', '6em', '8em'] }] 
]

五、事件与扩展:深度控制编辑器

1. 事件监听:响应编辑行为

javascript 复制代码
// 内容变化时触发(用于自动保存 或者 统计字数等)
quill.on('text-change', (delta, oldDelta, source) => {
  if (source === 'user') { // 仅处理用户操作
    console.log('内容变化:', delta);
  }
});

// 光标/选择范围变化时触发(用于显示格式提示)
quill.on('selection-change', (range, oldRange, source) => {
  if (range && range.length > 0) {
    const text = quill.getText(range.index, range.length);
    console.log('选中文本:', text);
  }
});

2. 自定义格式:添加「高亮」功能

javascript 复制代码
// 注册自定义格式
Quill.register({
  'formats/highlight': class Highlight {
    // 从 DOM 中读取格式
    static formats(domNode) {
      return domNode.style.backgroundColor === 'yellow' ? 'yellow' : false;
    }
    
    // 应用格式到 DOM
    apply(domNode, value) {
      domNode.style.backgroundColor = value === 'yellow' ? 'yellow' : '';
    }
  }
});

// 工具栏添加高亮按钮
const toolbarOptions = [
  [{ 'highlight': 'yellow' }]
];

// 初始化编辑器
const quill = new Quill('#editor', {
  modules: { toolbar: toolbarOptions },
  // ...其他配置
});

3. 自定义 module - 导出文件

增加工具栏、激活配置、module配置

css 复制代码
 toolbar: {
    container: [
        'exportFile'
    ],
    // 激活handlers -- 必须手动激活 - 重要!!!
    handlers: {
      exportFile: true
    }
 },
 // exportFile插件的配置
  exportFile: {
      apiMethod: ({ htmlContent }) => {
          const html = getFileTemplate(htmlContent);
          downloadDocx({
              html
          });
      }
  }

模块注册与实现

typescript 复制代码
useExportFilePlugin()

import Quill from "quill";

interface QuillIcons {
  [key: string]: string;
  exportFile?: string;
}

// 修改icon
const icons = Quill.import("ui/icons") as QuillIcons;
const uploadSVG =
  '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024"><path fill="currentColor" d="M160 832h704a32 32 0 1 1 0 64H160a32 32 0 1 1 0-64m384-253.696 236.288-236.352 45.248 45.248L508.8 704 192 387.2l45.248-45.248L480 584.704V128h64z"></path></svg>';
icons.exportFile = uploadSVG;

interface IApiMethodParams {
  htmlContent: string;
}

// 定义类型
interface ExportFilePluginOptions {
  apiMethod: (params: IApiMethodParams) => Promise<Blob>;
}

 

export const useExportFilePlugin = () => {
 

  class ExportFilePlugin {
    private quill: any;
    private toolbar: any;
    private apiMethod: (params: IApiMethodParams) => Promise<Blob>;

    constructor(quill: any, options: ExportFilePluginOptions) {
      this.quill = quill;
      this.toolbar = quill.getModule("toolbar");

      if (!options?.apiMethod) {
        throw new Error("导出module必须传入apiMethod");
      }

      this.apiMethod = options.apiMethod;

      // 添加工具栏 
      this.toolbar.addHandler("exportFile", this.handleExportClick.bind(this));
    }

    private async handleExportClick() {
      try {
        const htmlContent = this.quill.root.innerHTML;

        if (htmlContent.trim?.() === "<p><br></p>") {
          console.log("内容不能为空");
          return;
        }

        // 使用配置的API方法
        return this.apiMethod({ htmlContent });
      } catch (error) {
        console.error("导出失败:", error);
        return Promise.reject({
          error
        });
      }
    }
  }

  Quill.register("modules/exportFile", ExportFilePlugin);
};

自定义module或规则原理类似,很多,不一一列出,有需要可随时私我

六、避坑指南:这些问题要注意

1. 样式冲突:编辑器样式被全局 CSS 覆盖

问题 :项目中的全局样式(如 p { margin: 20px })会影响编辑器内部的段落样式,导致排版错乱。

解决:用 CSS 隔离编辑器样式,通过父级类名限制作用域:

css

css 复制代码
/* 给编辑器容器添加类名 quill-container */
.quill-container .ql-editor p {
  margin: 8px 0; /* 覆盖全局样式 */
}
.quill-container .ql-editor ul {
  padding-left: 20px;
}

2. 图片上传:跨域问题导致插入失败

问题 :上传图片到第三方服务器时,因跨域限制导致 fetch 请求失败。

解决

  • 后端接口添加 CORS 头(Access-Control-Allow-Origin: *)。
  • 若无法修改后端,通过本地服务端代理转发请求:
php 复制代码
// 前端请求本地代理接口
fetch('/proxy/upload', { method: 'POST', body: formData })
// 本地服务端将 /proxy/upload 转发到第三方服务器

3. 自定义模块:配置后不生效

问题:如"导出模块"配置后,工具栏按钮无响应。

核心原因:2.x版本中,自定义工具栏按钮需在handlers中手动激活。

解决方案 :在toolbar配置中添加handlers激活项: 解决

实例化quill传入激活配置 复制代码
modules: {
  toolbar: {
    container: ['exportFile'], // 自定义按钮
    // 必须手动激活,否则按钮点击无响应
    handlers: { exportFile: true } 
  },
  exportFile: { /* 模块配置 */ }
}

4. 获取选中文本 得到的结果多样性

代码 instance.value.getSelection(true)

问题 调用getText()时,返回结果可能为null、空对象或空字符串,导致后续操作报错

原因 光标未在编辑器内、用户未选中内容等场景会返回不同结果。

解决方案 封装工具函数处理边界情况:

vbnet 复制代码
/**
 * 获取选中文本 -- 只在真正有选中内容时候返回,否则返回''
 * @param focus是否聚焦 - true则能获取选中内容;false则代表光标不在富文本,会返回'' (非用户触发行为除外)
 * @returns obj code:-1代表没有选中  -2代表不在编辑器里 其他情况是有选中文本
 */
function getSelectionText(focus = true) {
  const range = instance.value.getSelection(focus);
  if (range) {
    if (range.length == 0) {
      console.log("用户没有选中任何内容");
      return {
        code: -1,
        text: "",
        range: {}
      };
    } else {
      const text = instance.value.getText(range.index, range.length);
      return {
        code: 1,
        text,
        range
      };
    }
  } else {
    console.log("用户光标不在富文本编辑器里");
    return {
      code: -2,
      text: "",
      range: {}
    };
  }
}

5. vue、react报错 Cannot read properties of null (reading 'offsetTop')

问题 在Vue3/React项目中,初始化Quill后控制台报上述错误 原因 框架响应式系统干扰Quill内部DOM计算逻辑 解决方案

  1. 用非响应式变量存储
  2. markRaw包裹quill实例 instance.value = markRaw(new Quill('#editor'))

七 汉化效果

工具栏和下拉内容均为中文

总结与后续预告

Quill 2.x 凭借「API 驱动」「Delta 格式」「模块化设计」三大特性,成为富文本编辑器的优质选择。本文从概念解析(是什么)、原理剖析(怎么工作)到实战落地(如何使用),再到避坑指南(常见问题),覆盖了 90% 的实用场景,掌握这些内容后,你可以轻松实现博客编辑器、在线文档、评论系统等功能

下一篇预告 :《AI智能写作实战:让Quill编辑器"听话"起来》

我们将深度融合AIQuill2,实现三大核心功能:

  1. AI自动生成文档,填充到富文本编辑器
  2. AI自动检测内容错误并标记(formatText API)
  3. AI根据上下文扩写内容(insertText API)
  4. ...

资源获取

本文涉及的完整代码(含Vue3、汉化、自定义格式、自定义模块)已整理完毕,点赞+收藏+评论@我,即可私发资源包!

相关推荐
FinClip2 小时前
京东外卖App独立上线,超级App如何集成海量小程序?
前端
一颗苹果OMG2 小时前
随着AI的发展,测试跟prompt会不会成为每个程序员的必修课
前端·程序员·全栈
起这个名字2 小时前
Webpack——插件实现的理解
前端·javascript·node.js
Mapmost2 小时前
让 AI 真正看懂世界—构建具备空间理解力的智能体
前端
橙 子_2 小时前
我本以为代码是逻辑,直到遇见了HTML的“形”与“意”【一】
前端·html
Kisang.3 小时前
【HarmonyOS】ArkWeb——从入门到入土
前端·华为·typescript·harmonyos·鸿蒙
沉默璇年3 小时前
tgz包批量下载脚本
前端
a***13143 小时前
python的sql解析库-sqlparse
android·前端·后端
0***R5153 小时前
前端构建工具缓存,node_modules
前端·缓存