资讯详情

SpringBoot集成Quill富文本编辑器:自定义图片与视频上传实战

📅 2026/10/17 5:54:06 | 华诺云谱 👁 阅读
SpringBoot集成Quill富文本编辑器:自定义图片与视频上传实战
1. 从一次内容管理后台的改造说起为什么富文本编辑器总在图片上传上卡壳做过内容管理后台的朋友大概率都遇到过这样的场景运营同事跑过来跟你说编辑器里插图片怎么老是失败要么是图片太大传不上去要么是传上去之后过两天图片链接就失效了还有更离谱的——图片直接以base64的形式塞进了正文里导致一篇文章的HTML体积膨胀到几兆数据库字段都快撑爆了。这类问题十有八九都出在富文本编辑器的默认图片处理逻辑上。Quill作为一款轻量级、可定制性极强的富文本编辑器在前端圈子里口碑一直不错。它的核心设计思路是把编辑器内容抽象成Delta格式一种基于JSON的富文本描述结构而不是直接操作DOM。这个设计带来的好处是内容结构清晰、易于扩展但同时也意味着——默认情况下Quill对图片和视频的处理非常克制。它只提供了一个最基础的能力把图片以base64编码的形式嵌入到内容中。这在demo阶段看起来很方便但放到真实的生产环境里几乎不可用。所以这篇文章要聊的核心问题就是在SpringBoot项目中集成Quill编辑器时如何把图片上传和视频上传这两个高频需求做成自定义插件让文件真正上传到自己的服务器或对象存储而不是以base64的形式苟活在正文里。我会从Quill的扩展机制讲起一步步拆解自定义上传的完整实现路径包括前端插件的注册、后端接口的设计、文件校验策略、以及我在实际项目中踩过的几个印象深刻的坑。这篇文章适合正在做内容管理系统、博客平台、在线教育课件编辑、企业知识库等涉及富文本编辑场景的后端和全栈开发者。无论你是刚接触Quill的新手还是已经用过一段时间但被自定义上传卡住的老手相信都能从中找到可以直接复用的方案。2. Quill的扩展机制到底是怎么运转的2.1 从toolbar配置到handler绑定一条完整的调用链要理解怎么自定义图片上传首先得搞清楚Quill的工具栏按钮是怎么和具体行为绑定的。Quill初始化的时候你会给它一个toolbar配置比如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 }], [link, image, video], [clean] ], handlers: { image: imageHandler, video: videoHandler } } } });这里的关键在handlers字段。当你没有提供自定义handler的时候Quill对image按钮的默认行为是弹出一个文件选择框用户选完图片后它用FileReader把文件读成base64字符串然后调用quill.insertEmbed(range.index, image, base64String)把图片嵌入到编辑器中。整个过程完全在前端完成不经过任何后端接口。而当你提供了自定义handler之后Quill会把点击事件的控制权交给你。你可以在handler函数里做任何事情——弹自定义对话框、调用上传接口、插入返回的URL甚至插入一个自定义的Blot。这就是我们实现自定义上传的切入点。2.2 Delta模型与Blot理解Quill的内容抽象层Quill的内容不是HTML而是Delta。一个典型的Delta长这样{ ops: [ { insert: 这是一段文字 }, { insert: \n, attributes: { header: 1 } }, { insert: { image: https://example.com/photo.jpg } }, { insert: \n } ] }图片在Delta里就是一个insert对象key是imagevalue是图片地址。Quill内部会把Delta渲染成对应的DOM节点这个渲染逻辑由Blot负责。Quill内置了ImageBlot和VideoBlot分别对应img标签和iframe标签。理解这一点很重要因为它决定了你自定义上传后应该返回什么。如果你返回的是一个图片URL直接insertEmbed一个image类型的Blot就行。但如果你想要更精细的控制——比如给图片加上自定义属性宽度、对齐方式、alt文本或者想要在图片外面包一层自定义容器——那就需要注册自定义Blot。2.3 为什么默认的base64方案在生产环境行不通很多人可能会想base64就base64呗能用就行。但实际上base64方案在生产环境会引发一连串连锁问题内容体积膨胀base64编码后的数据比原始二进制大约33%一张2MB的图片编码后接近2.7MB。如果一篇文章插了10张图正文HTML轻松超过20MB。数据库压力这些base64字符串最终要存到数据库的text字段里查询、备份、迁移都会变得极其缓慢。浏览器渲染性能浏览器解析超大HTML字符串时会出现明显卡顿编辑器输入延迟肉眼可见。无法使用CDNbase64图片没法走CDN加速每次加载都要随HTML一起传输。无法做图片处理你没法对base64图片做压缩、裁剪、水印等后处理。所以自定义上传不是一个锦上添花的功能而是Quill投入生产环境的必要条件。3. 图片上传的完整实现链路从前端插件到后端接口3.1 前端handler的编写要点与文件选择策略先来看图片上传handler的完整写法。我习惯把上传逻辑封装成一个独立的函数保持handler本身的简洁function imageHandler() { const input document.createElement(input); input.setAttribute(type, file); input.setAttribute(accept, image/png, image/jpeg, image/gif, image/webp); input.click(); input.onchange async () { const file input.files[0]; if (!file) return; // 前端预校验 const maxSize 5 * 1024 * 1024; if (file.size maxSize) { alert(图片大小不能超过5MB); return; } const formData new FormData(); formData.append(file, file); try { const res await fetch(/api/upload/image, { method: POST, body: formData, headers: { X-Requested-With: XMLHttpRequest } }); const result await res.json(); if (result.code 200) { const range quill.getSelection(true); quill.insertEmbed(range.index, image, result.data.url); quill.setSelection(range.index 1); } else { alert(result.message || 上传失败); } } catch (err) { console.error(图片上传异常, err); alert(网络异常请稍后重试); } }; }这段代码里有几个细节值得展开说。第一accept属性虽然只是给浏览器的建议但能显著提升用户体验避免用户选到不支持的文件类型。第二前端预校验大小是必要的但绝不能作为唯一防线——后端必须再校验一次因为前端校验可以被绕过。第三quill.getSelection(true)传入true表示如果编辑器没有焦点就自动获取焦点这在用户点击工具栏按钮后编辑器失焦的场景下很关键。第四插入图片后要把光标移到图片后面否则用户继续输入时文字会跑到图片前面。3.2 后端SpringBoot接口设计不只是存个文件那么简单后端的图片上传接口看起来简单但要做得健壮需要考虑不少东西。先看一个基础版本的实现RestController RequestMapping(/api/upload) public class UploadController { Value(${upload.image.path}) private String imageUploadPath; Value(${upload.image.url-prefix}) private String imageUrlPrefix; private static final ListString ALLOWED_EXTENSIONS Arrays.asList(jpg, jpeg, png, gif, webp); private static final long MAX_FILE_SIZE 5 * 1024 * 1024L; PostMapping(/image) public ResultMapString, String uploadImage(RequestParam(file) MultipartFile file) { if (file.isEmpty()) { return Result.error(文件为空); } if (file.getSize() MAX_FILE_SIZE) { return Result.error(文件大小超过限制); } String originalFilename file.getOriginalFilename(); String extension getExtension(originalFilename); if (!ALLOWED_EXTENSIONS.contains(extension.toLowerCase())) { return Result.error(不支持的文件类型); } // 按日期分目录避免单目录文件过多 String datePath LocalDate.now().format(DateTimeFormatter.ofPattern(yyyy/MM/dd)); String relativePath datePath / UUID.randomUUID() . extension; File dest new File(imageUploadPath, relativePath); if (!dest.getParentFile().exists()) { dest.getParentFile().mkdirs(); } try { file.transferTo(dest); } catch (IOException e) { return Result.error(文件保存失败); } MapString, String data new HashMap(); data.put(url, imageUrlPrefix / relativePath); data.put(name, originalFilename); return Result.success(data); } private String getExtension(String filename) { if (filename null || !filename.contains(.)) return ; return filename.substring(filename.lastIndexOf(.) 1); } }这段代码里有几个设计决策需要解释。按日期分目录是为了避免所有文件堆在一个目录下当文件数量达到几万甚至几十万时单目录的文件检索性能会急剧下降而且用ls命令查看目录内容都会卡住。用UUID重命名是为了防止文件名冲突和路径穿越攻击——如果直接用原始文件名用户上传一个名为../../etc/passwd的文件就可能造成安全问题。URL前缀配置化是为了方便切换存储位置开发环境可能存本地磁盘生产环境可能存对象存储通过配置切换而不改代码。3.3 文件校验的三道防线扩展名、MIME类型与魔数上面代码里的扩展名校验只是第一道防线而且是最弱的一道——攻击者可以把一个可执行文件改名为.jpg就绕过了。更严格的做法是同时校验MIME类型和文件魔数。MIME类型可以通过file.getContentType()获取但这个是浏览器上报的同样可以伪造。真正可靠的是读取文件头部的魔数Magic Number。比如JPEG文件的前两个字节是0xFF 0xD8PNG文件的前八个字节是0x89 0x50 0x4E 0x47 0x0D 0x0A 0x1A 0x0A。private boolean isRealImage(MultipartFile file) { try (InputStream is file.getInputStream()) { byte[] header new byte[8]; int read is.read(header); if (read 4) return false; // JPEG if ((header[0] 0xFF) 0xFF (header[1] 0xFF) 0xD8) return true; // PNG if ((header[0] 0xFF) 0x89 header[1] 0x50 header[2] 0x4E header[3] 0x47) return true; // GIF if (header[0] 0x47 header[1] 0x49 header[2] 0x46) return true; // WEBP (RIFF....WEBP) if (read 8 header[0] 0x52 header[1] 0x49 header[2] 0x46 header[3] 0x46) return true; return false; } catch (IOException e) { return false; } }三道防线叠加使用才能把风险降到可接受的水平。当然如果你的系统对安全性要求极高还可以引入图片解码验证——用ImageIO.read()尝试解码解码失败说明不是合法图片。但这个操作比较耗CPU一般场景下魔数校验就够了。3.4 上传成功后的回显insertEmbed的正确姿势前端拿到后端返回的URL后调用quill.insertEmbed插入图片。这里有个容易被忽略的问题如果用户上传的图片尺寸特别大比如4000x3000插入编辑器后会撑破编辑区域导致横向滚动条出现。解决办法是在插入时给图片加上样式约束quill.insertEmbed(range.index, image, result.data.url, user); // 插入后立即获取该图片的DOM节点并设置样式 const imgNode quill.root.querySelector(img[src${result.data.url}]); if (imgNode) { imgNode.style.maxWidth 100%; imgNode.style.height auto; }更优雅的做法是注册一个自定义Image Blot在Blot的create方法里统一设置样式。这样所有插入的图片都会自动带上约束不需要每次手动处理。4. 视频上传的差异化处理比图片复杂在哪4.1 视频文件的体量问题与分片上传的必要性视频上传和图片上传最大的区别在于文件体积。一个几分钟的短视频动辄几十兆甚至上百兆如果还用普通的表单上传会面临几个问题上传时间长导致用户以为卡死、网络波动导致整个上传失败需要重来、后端一次性接收大文件占用大量内存。所以视频上传通常需要分片上传方案。核心思路是把大文件切成若干个小块比如每块2MB逐块上传后端接收完所有分片后再合并成完整文件。这样做的好处是单个分片失败只需重传该分片不用从头再来可以并发上传多个分片提升速度后端每次只处理一个小分片内存压力小。前端分片逻辑大致如下const CHUNK_SIZE 2 * 1024 * 1024; async function uploadVideoInChunks(file) { const totalChunks Math.ceil(file.size / CHUNK_SIZE); const fileId generateFileId(file); for (let i 0; i totalChunks; i) { const start i * CHUNK_SIZE; const end Math.min(start CHUNK_SIZE, file.size); const chunk file.slice(start, end); const formData new FormData(); formData.append(file, chunk); formData.append(fileId, fileId); formData.append(chunkIndex, i); formData.append(totalChunks, totalChunks); formData.append(fileName, file.name); await fetch(/api/upload/video/chunk, { method: POST, body: formData }); } // 通知后端合并 const res await fetch(/api/upload/video/merge, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ fileId, totalChunks, fileName: file.name }) }); return res.json(); }后端需要维护一个分片接收目录每个分片以fileId_chunkIndex命名存储。合并时按顺序读取所有分片写入最终文件然后清理临时分片。4.2 视频格式兼容性与转码策略视频上传还有一个绕不开的问题格式兼容性。用户上传的视频可能是MP4、AVI、MOV、MKV等各种格式但浏览器原生video标签只稳定支持MP4H.264编码和WebM。如果直接把AVI文件塞进video标签大部分浏览器根本播不了。解决方案有两种。第一种是在上传时限制格式只允许MP4和WebM其他格式直接拒绝。这种做法简单粗暴但有效适合对用户体验要求不极致的场景。第二种是上传后异步转码用FFmpeg把各种格式统一转成MP4。这种做法用户体验好但需要额外的转码服务器资源和任务队列。如果选择转码方案SpringBoot这边通常的做法是上传完成后把转码任务丢进消息队列比如RabbitMQ或Redis队列由独立的转码worker消费任务、调用FFmpeg命令行执行转码、转码完成后更新数据库中的视频状态。前端在插入视频后先显示一个转码中的占位轮询接口直到转码完成再替换成真正的视频地址。4.3 Quill中视频Blot的定制从iframe到video标签Quill默认的Video Blot渲染出来的是iframe标签主要用于嵌入外部视频平台的播放器。但如果你上传的是自己的视频文件用iframe就不合适了应该用HTML5的video标签。这就需要注册一个自定义的Video Blotconst BlockEmbed Quill.import(blots/block/embed); class CustomVideoBlot extends BlockEmbed { static create(value) { const node super.create(); node.setAttribute(src, value.url); node.setAttribute(controls, controls); node.setAttribute(preload, metadata); node.setAttribute(width, 100%); if (value.poster) { node.setAttribute(poster, value.poster); } return node; } static value(node) { return { url: node.getAttribute(src), poster: node.getAttribute(poster) }; } } CustomVideoBlot.blotName customVideo; CustomVideoBlot.tagName video; Quill.register(CustomVideoBlot);注册好之后插入视频时用quill.insertEmbed(index, customVideo, { url: videoUrl })即可。这样渲染出来的就是原生的video标签支持浏览器自带的播放控制条。4.4 视频封面图的生成与上传一个体验好的视频上传流程应该在上传完成后自动截取视频的第一帧作为封面图。这个可以在前端用Canvas实现function captureVideoFrame(file) { return new Promise((resolve) { const video document.createElement(video); video.preload metadata; video.src URL.createObjectURL(file); video.currentTime 1; // 跳到第1秒避免黑帧 video.onseeked () { const canvas document.createElement(canvas); canvas.width video.videoWidth; canvas.height video.videoHeight; canvas.getContext(2d).drawImage(video, 0, 0); canvas.toBlob((blob) { URL.revokeObjectURL(video.src); resolve(blob); }, image/jpeg, 0.8); }; }); }截取到的封面图作为一个普通图片上传拿到URL后作为poster属性传给视频Blot。这样视频在加载前就能显示封面而不是一片空白。5. 那些文档里不会写的踩坑记录5.1 跨域配置与Cookie携带的隐藏陷阱前后端分离的项目里上传接口的跨域配置是第一个坑。很多人以为在Controller上加个CrossOrigin就完事了结果发现上传请求还是被浏览器拦截。问题往往出在预检请求OPTIONS上——浏览器在发送带自定义头的POST请求前会先发一个OPTIONS请求探路如果后端没有正确处理OPTIONS整个上传就失败了。更隐蔽的坑是Cookie携带。如果你的上传接口需要登录态比如从Session或Cookie里取用户信息前端fetch必须加上credentials: include后端CORS配置必须设置allowCredentials(true)而且allowedOrigins不能是*必须是具体的域名。这三个条件缺一不可少一个都会导致登录态丢失上传接口返回401。Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/api/**) .allowedOrigins(https://your-domain.com) .allowedMethods(GET, POST, OPTIONS) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); } }5.2 编辑器内容回显时图片路径失效的排查过程这个坑我印象特别深。当时的情况是新建文章时图片上传正常编辑器里也能看到图片但保存后重新打开编辑图片全部变成了裂图。排查了一圈才发现后端返回的图片URL是相对路径比如/upload/2024/01/15/xxx.jpg而编辑器回显时页面的base路径和上传时不一样导致相对路径解析错误。解决办法很简单——后端返回的URL统一用完整路径带域名或带协议头或者前端在回显时统一拼接base路径。但排查这个问题的过程很折腾因为浏览器控制台只显示图片404不会告诉你为什么404。后来我养成了一个习惯所有涉及资源URL的地方一律用绝对路径宁可多存几个字符也不要留相对路径的隐患。5.3 大文件上传超时Nginx与SpringBoot的双重限制视频上传到生产环境后运营反馈说超过50MB的视频就传不上去进度条走到一半就报错。查了日志发现请求根本没到SpringBoot被Nginx拦截了。Nginx默认的client_max_body_size是1MB超过这个大小的请求体直接返回413。改完Nginx配置后发现还是不行这次请求到了SpringBoot但报了MaxUploadSizeExceededException。原来SpringBoot的MultipartFile也有大小限制需要在配置文件里显式设置spring: servlet: multipart: max-file-size: 500MB max-request-size: 500MB这两个限制是叠加的任何一个没配对都会导致大文件上传失败。而且Nginx的配置改完需要reload才生效很多人改完配置文件忘了reload白白排查半天。5.4 并发上传时的文件名冲突与目录竞争分片上传场景下如果多个用户同时上传视频临时分片目录可能会发生竞争。我遇到过一种情况两个用户上传的文件恰好生成了相同的fileId因为fileId生成逻辑用了时间戳精度不够导致分片互相覆盖最终合并出来的视频是损坏的。修复方案是把fileId的生成逻辑改成用户ID 时间戳 随机数的组合确保全局唯一。另外分片目录的创建要用mkdirs()并检查返回值避免多线程同时创建目录时的竞态条件。合并完成后要及时清理临时分片否则磁盘空间会被慢慢吃光。6. 让上传体验更稳的几个工程化建议6.1 上传进度反馈与失败重试机制用户上传大文件时最怕的就是不知道进行到哪了。给上传过程加上进度反馈能极大缓解焦虑。用XMLHttpRequest的upload.onprogress事件可以拿到上传进度const xhr new XMLHttpRequest(); xhr.upload.onprogress (e) { if (e.lengthComputable) { const percent Math.round((e.loaded / e.total) * 100); updateProgressBar(percent); } };分片上传的话进度就是已完成分片数 / 总分片数。失败重试方面建议对每个分片的上传做最多3次重试重试间隔采用指数退避1秒、2秒、4秒避免网络抖动导致整个上传失败。6.2 图片压缩在上传前就把体积降下来很多用户上传的图片是手机直出的动辄5MB以上。与其在后端做压缩不如在前端上传前就压缩好既节省带宽又加快上传速度。用Canvas做前端压缩是个成熟方案function compressImage(file, maxWidth 1920, quality 0.8) { return new Promise((resolve) { const img new Image(); img.src URL.createObjectURL(file); img.onload () { let { width, height } img; if (width maxWidth) { height Math.round((height * maxWidth) / width); width maxWidth; } const canvas document.createElement(canvas); canvas.width width; canvas.height height; canvas.getContext(2d).drawImage(img, 0, 0, width, height); canvas.toBlob((blob) { URL.revokeObjectURL(img.src); resolve(new File([blob], file.name, { type: image/jpeg })); }, image/jpeg, quality); }; }); }实测下来一张5MB的手机照片压缩后通常在300KB到800KB之间画质在网页上展示完全够用。这个操作在上传前完成用户几乎无感知。6.3 存储方案选型本地磁盘还是对象存储开发阶段用本地磁盘存文件是最省事的但生产环境建议尽早切换到对象存储。原因有三本地磁盘在多实例部署时无法共享用户上传到A实例从B实例访问就404本地磁盘的备份和扩容麻烦对象存储天然支持CDN加速和图片处理。如果暂时不想引入对象存储至少要把文件存储路径做成可配置的并且把存储服务抽象成一个接口本地磁盘实现和对象存储实现都实现这个接口。这样将来切换存储方案时只需要换一个实现类不用改业务代码。public interface FileStorageService { String upload(MultipartFile file, String directory); void delete(String fileUrl); String getUrl(String relativePath); }6.4 安全加固防止上传接口被滥用上传接口是很容易被滥用的——攻击者可以写脚本疯狂上传文件把你的磁盘塞满也可以上传包含恶意代码的文件。几个必要的防护措施限制单IP的上传频率用Redis做计数器、校验文件魔数前面讲过、把上传目录配置为不可执行Nginx里设置location /upload { add_header Content-Disposition attachment; }、定期清理无引用的孤儿文件。孤儿文件的清理可以写一个定时任务扫描数据库中所有文章内容提取出所有图片和视频URL然后和存储目录里的文件做比对超过7天没有被引用的文件就删除。这个任务建议在低峰期执行避免影响正常请求。7. 写在最后的一点个人体会这套Quill自定义上传方案我在几个内容管理项目里都落地过整体跑下来比较稳。回顾整个过程我觉得最关键的不是某个具体的技术点而是对上传这件事的整体认知——它不是一个孤立的接口而是一条从前端文件选择、格式校验、压缩处理、分片传输、后端接收、存储落盘、URL回显、内容持久化的完整链路。链路上任何一环出问题用户看到的就是上传失败四个字。另外一点体会是富文本编辑器的自定义扩展一定要尽早做。很多项目一开始图省事用了默认的base64方案等到内容积累了几千篇再想改就要面对历史数据的迁移问题——你得把数据库里所有base64图片提取出来、上传到存储、替换URL这个工作量远比一开始就做好自定义上传要大得多。所以如果你正在启动一个涉及富文本编辑的新项目建议在项目初期就把自定义上传方案搭好后面会省很多事。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑