Quill.js 自定义图片上传模块实现教程

作者:工具松鼠时间:2026-11-05 22:55:48浏览:120

本教程详细讲解如何在 Quill.js 中拦截默认的 Base64 图片插入行为,实现将本地图片异步上传至服务器,并将获得的远程 URL 正确回填至编辑器光标位置,包含模块封装、光标复位及拖拽粘贴处理。

类型:技术实践教程 目标:拦截默认图片行为、异步上传服务器、光标复位与模块封装
Quill.js 自定义图片上传模块实现教程
图 0:Quill.js 自定义图片上传与回填流程示意

Quill.js 是前端广泛使用的开源轻量富文本编辑器,但其开箱自带的图片处理机制存在一个显著缺陷:默认情况下,用户点击工具栏插入图片时,编辑器会直接通过浏览器的 FileReader 将本地图片转为巨大的 data:image/...;base64,... 编码字符串,并将其直接塞入文档内容中。本教程将直接剖析该机制的问题,并通过替换工具栏处理函数、异步调用服务端上传接口、使用 quill.insertEmbed 插入远程 URL 及管理光标选区的完整链路,实现一个工程化、可复用的自定义图片上传模块。

一、为什么必须替换默认的 Base64 行为

很多初学者在刚使用 Quill.js 时,会误以为“能直接选图并在页面显示”就说明图片功能已经可用。这种想法在本地演示阶段看似合理,因为无需后端接口即可渲染图片;但在任何真实的生产项目中,它都会迅速引发严重的工程问题。

当一张 3MB 的手机实拍图被编码为 Base64 格式时,体积会膨胀约 33%,生成超过 4MB 的纯文本字符串。将这样的长字符串保存在数据库的 TEXT 或 VARCHAR 字段中,不仅会导致单条记录体积急剧膨胀、数据库吞吐率骤降,还会使文章编辑与加载时的 JSON Delta 数据极度臃肿,直接造成移动端编辑器卡顿或浏览器内存溢出。

工业级富文本编辑的标准做法是:文本归文本,资源归对象存储。编辑器内部只存储图片的轻量级远程 URL(例如 https://cdn.example.com/images/a1b2c3.jpg),图片实体则通过接口异步存储于对象存储服务(OSS/S3)中。

Quill.js 自定义图片上传模块实现教程
图 1:Quill 默认 Base64 机制与服务端异步上传方案对比

二、核心实现机制与关键步骤

自定义上传的核心并非重写整个编辑器,而是利用 Quill 提供的扩展机制,用自定义逻辑精确替换其默认的 image 处理函数(Handler)。整个执行生命周期分为四个明确的步骤:

Quill.js 自定义图片上传模块实现教程
图 2:自定义图片上传执行全生命周期

步骤一:拦截工具栏的 image 按钮动作

Quill 实例初始化后,其工具栏由 toolbar 模块控制。我们需要获取该模块,并使用 toolbar.addHandler('image', customHandler) 注册自定义处理函数。

在自定义函数触发的瞬间,必须首先调用 this.quill.getSelection() 获取当前光标位置并将其持久化。这是因为一旦唤起系统原生的文件选择对话框,编辑器窗口就会立刻失去焦点(Blur)。如果不提前保存光标位置,等文件异步上传完毕返回时,quill.getSelection() 可能会返回 null,导致图片无法精准插入在用户期望的位置。

步骤二:动态创建隐藏的文件选择控件与校验

为了无缝触发文件浏览,通常使用 JavaScript 动态创建一个不可见的 <input type="file"> 元素:

const fileInput = document.createElement('input');
fileInput.setAttribute('type', 'file');
fileInput.setAttribute('accept', 'image/png, image/jpeg, image/gif, image/webp');
fileInput.style.display = 'none';
document.body.appendChild(fileInput);
fileInput.click();

当用户在系统对话框中选中文件后,监听其 change 事件。在上传前应执行严格的前端预校验,包括检查 MIME 类型白名单以及校验文件大小(例如限制在 5MB 以内)。预校验能够直接阻断不合法文件的无效网络传输,提升用户体验。

步骤三:构造 FormData 异步提交

通过标准的 FormData 将文件对象打包,并通过 fetch 或 XMLHttpRequest 发送给服务端的图片上传接口:

const formData = new FormData();
formData.append('file', file);

const response = await fetch('/api/upload/image', {
  method: 'POST',
  body: formData,
  headers: {
    'Authorization': 'Bearer ' + userToken // 根据业务按需携带认证凭证
  }
});
const data = await response.json();
const remoteUrl = data.url;

步骤四:使用 insertEmbed 插入图片并复位光标

拿到服务端返回的公网图片 URL 后,调用 Quill 的底层 API:quill.insertEmbed(index, 'image', remoteUrl)。随后立即调用 quill.setSelection(index + 1),将光标定位至图片后方,使用户能够继续顺畅地输入文字。

三、深层细节:光标状态管理与全链路覆盖

只实现工具栏点击是初级教程常见的漏洞。在真实的日常编辑场景中,用户除了点击工具栏,还非常习惯直接从本地拖拽图片到编辑器,或者通过截图工具截图后直接 Ctrl+V 粘贴。

必须纠正的常见误区:配置了 toolbar.addHandler('image', ...) 并不等于接管了编辑器的所有图片输入!当用户在编辑区域按下 Ctrl+V 粘贴剪贴板中的图片,或者将文件拖入文本区时,事件根本不走 toolbar 模块,而是会被 Quill 原生的 clipboard 模块和浏览器默认行为接管,依然会生成庞大的 Base64 字符串!

因此,一个健壮的自定义图片模块,必须将“工具栏点击”、“粘贴事件(paste)”以及“拖拽释放事件(drop)”统一汇聚到同一个上传管道中。

Quill.js 自定义图片上传模块实现教程
图 3:光标状态机控制与全输入渠道统一汇聚模型

四、完整工程化模块代码实现

为了使代码具有良好的可维护性与复用性,我们按照标准 Quill 模块规范将其封装为一个自定义 Class,并通过 Quill.register('modules/imageUploader', CustomImageUploader) 注册。

以下是具备完整事件拦截、粘贴监听、拖拽监听、异步上传与光标自动恢复的生产级实现代码:

/**
 * Quill 自定义图片上传与输入管理模块
 */
class CustomImageUploader {
  constructor(quill, options) {
    this.quill = quill;
    this.options = Object.assign({
      uploadUrl: '/api/upload/image',
      maxSizeMB: 5,
      allowedTypes: ['image/jpeg', 'image/png', 'image/gif', 'image/webp'],
      uploadFieldName: 'file'
    }, options);

    // 1. 替换工具栏 image 按钮处理逻辑
    const toolbar = this.quill.getModule('toolbar');
    if (toolbar) {
      toolbar.addHandler('image', this.handleToolbarImage.bind(this));
    }

    // 2. 监听剪贴板粘贴与拖拽放置
    this.quill.root.addEventListener('paste', this.handlePaste.bind(this));
    this.quill.root.addEventListener('drop', this.handleDrop.bind(this));
  }

  // 点击工具栏图片按钮
  handleToolbarImage() {
    const range = this.quill.getSelection(true);
    const targetIndex = range ? range.index : this.quill.getLength();

    const input = document.createElement('input');
    input.setAttribute('type', 'file');
    input.setAttribute('accept', this.options.allowedTypes.join(', '));
    input.style.display = 'none';

    input.onchange = () => {
      const file = input.files[0];
      if (file) {
        this.processFile(file, targetIndex);
      }
      document.body.removeChild(input);
    };

    document.body.appendChild(input);
    input.click();
  }

  // 监听粘贴事件 (如微信截图直接 Ctrl+V)
  handlePaste(event) {
    const clipboardData = event.clipboardData || window.clipboardData;
    if (!clipboardData || !clipboardData.items) return;

    for (let i = 0; i < clipboardData.items.length; i++) {
      const item = clipboardData.items[i];
      if (item.type.indexOf('image') !== -1) {
        event.preventDefault(); // 阻止默认的 Base64 粘贴
        const file = item.getAsFile();
        const range = this.quill.getSelection(true);
        const targetIndex = range ? range.index : this.quill.getLength();
        this.processFile(file, targetIndex);
        break;
      }
    }
  }

  // 监听拖拽放置事件
  handleDrop(event) {
    const dataTransfer = event.dataTransfer;
    if (!dataTransfer || !dataTransfer.files || dataTransfer.files.length === 0) return;

    const file = dataTransfer.files[0];
    if (this.options.allowedTypes.includes(file.type)) {
      event.preventDefault(); // 阻止浏览器原生直接打开或默认插入
      const range = this.quill.getSelection(true);
      const targetIndex = range ? range.index : this.quill.getLength();
      this.processFile(file, targetIndex);
    }
  }

  // 统一校验并执行上传
  async processFile(file, insertIndex) {
    // 校验类型
    if (!this.options.allowedTypes.includes(file.type)) {
      alert('不支持的图片格式,仅允许:' + this.options.allowedTypes.join(', '));
      return;
    }

    // 校验文件大小
    const maxBytes = this.options.maxSizeMB * 1024 * 1024;
    if (file.size > maxBytes) {
      alert(`图片大小不能超过 ${this.options.maxSizeMB}MB`);
      return;
    }

    try {
      // 可以在此处插入一个临时 loading 占位文本或动画
      const imageUrl = await this.uploadToServer(file);
      
      // 核心:回填远程 URL 并恢复光标位置
      this.quill.insertEmbed(insertIndex, 'image', imageUrl);
      this.quill.setSelection(insertIndex + 1);
    } catch (err) {
      console.error('图片上传失败:', err);
      alert('图片上传失败,请稍后重试');
    }
  }

  // 执行服务端 HTTP 请求
  async uploadToServer(file) {
    const formData = new FormData();
    formData.append(this.options.uploadFieldName, file);

    const response = await fetch(this.options.uploadUrl, {
      method: 'POST',
      body: formData
    });

    if (!response.ok) {
      throw new Error(`网络请求响应异常: ${response.status}`);
    }

    const resData = await response.json();
    if (!resData.url) {
      throw new Error('服务端返回数据缺少 url 字段');
    }
    return resData.url;
  }
}

// 注册为 Quill 的原生模块
Quill.register('modules/imageUploader', CustomImageUploader);

模块在前端页面中的初始化与配置

注册完成后,在初始化 Quill 实例时,只需在 modules 配置项中引入 imageUploader 即可:

const quill = new Quill('#editor-container', {
  theme: 'snow',
  modules: {
    toolbar: [
      ['bold', 'italic', 'underline'],
      ['image', 'link', 'code-block']
    ],
    // 启用并配置自定义图片上传模块
    imageUploader: {
      uploadUrl: '/api/v1/posts/upload-image',
      maxSizeMB: 10,
      uploadFieldName: 'file'
    }
  }
});

五、工程化避坑指南与最佳实践

在实际生产部署中,还有几处容易被忽视的隐蔽陷阱,需要开发者逐一防范:

  • CORS 跨域与鉴权头传递:如果你的图片上传服务部署在独立的文件服务器或云 OSS 直传网关,确保后端接口配置了正确的 Access-Control-Allow-Origin 请求头。若接口需要 Token 认证,需在 fetch 中添加 headers: { Authorization: '...' }。
  • 占位与防重复点击:大文件在较差网络环境下上传可能耗时数秒。若无视觉反馈,用户容易反复点击上传按钮。推荐在触发上传后,在对应位置插入一个专用的临时 Blot(如带旋转动画的 Loading 占位),或者在上传期间禁用工具栏按钮。
  • 光标越界兜底:若在编辑器为空时点击上传,getSelection() 返回对象的 index 可能为 0。务必通过 range ? range.index : quill.getLength() 做安全兜底,避免向负数或未定义索引插入节点。
  • 内存回收:动态创建的 <input type="file"> 在触发之后应及时从 DOM 树中移除,避免多次点击导致大量废弃的 Input 节点驻留内存。
Quill.js 自定义图片上传模块实现教程
图 4:Quill 自定义图片模块工程化设计与落地检查矩阵
作者最新文章
相关文章
更多