Quill.js 自定义图片上传模块实现教程
本教程详细讲解如何在 Quill.js 中拦截默认的 Base64 图片插入行为,实现将本地图片异步上传至服务器,并将获得的远程 URL 正确回填至编辑器光标位置,包含模块封装、光标复位及拖拽粘贴处理。
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 提供的扩展机制,用自定义逻辑精确替换其默认的 image 处理函数(Handler)。整个执行生命周期分为四个明确的步骤:
步骤一:拦截工具栏的 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 模块规范将其封装为一个自定义 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 与 Slate.js、Draft.js 选型对比:优缺点及适用场景分析
2026-11-05 19:13:46
JS表单序列化出现中文乱码怎么解决
2026-11-05 17:21:05
JS代码在线格式化工具推荐:无需安装快速美化
2026-11-05 17:17:49
WebStorm内置JS代码格式化功能怎么用
2026-11-05 10:10:47
在线JS代码格式化与混淆压缩工具推荐及使用指南
2026-11-03 20:31:53








