简介:这份PDF资料面向使用Summernote富文本编辑器的前端开发者,聚焦于解决编辑器默认不支持图片上传到服务器的问题。内容围绕onImageUpload回调与insertImage API展开,讲解如何挂接文件上传事件、借助FormData与jQuery.ajax将图片POST到后端接口,并在上传成功后把返回的图片URL插入编辑框,同时提及低版本IE兼容性与错误处理的注意事项。资源包共1个PDF文件,约50KB,篇幅精炼,适合作为快速查阅的参考手册。目前已有689人学习下载,说明该方案在实际项目中具有一定参考价值。读者可从中获得一套可直接套用的图片上传实现思路与核心代码片段,理解Summernote图片上传的关键接口用法,并据此结合自身后端服务进行调整与优化,减少反复搜索官方文档与试错的时间成本。
1. Summernote 图片上传:为什么你的 base64 方案迟早要翻车
很多人在后台富文本编辑器里集成 Summernote,第一反应是直接用它的默认图片插入功能——选一张图,编辑器自动把图片转成 base64 塞进 HTML。本地测试一切正常,提交保存也看不出问题。等到文章发了十几篇,数据库字段开始膨胀,列表页加载越来越慢,接口返回的 JSON 动辄几 MB,你才意识到这个方案从一开始就不该上生产。
Summernote 实现图片上传功能的简单方法,核心就一件事:把默认的 base64 内嵌改成「上传到服务端、返回 URL、编辑器只存链接」。这件事听起来简单,但真正落地时会碰到 CSRF 拦截、跨域、图片尺寸限制、回调格式对不上等一堆细节。这篇笔记面向的是正在用 Summernote 做后台编辑器的前后端开发者,不管你用的是 Spring Boot、Express 还是 Django,思路一致,代码按你熟悉的栈替换即可。接下来我会把选型理由、最小可跑通实现、参数配置和踩过的坑按顺序讲清楚。
2. 从 base64 到服务端 URL:Summernote 图片上传的选型与最小实现
2.1 为什么必须放弃默认的 base64 内嵌
Summernote 默认的图片处理逻辑是监听粘贴或选择图片事件,用 FileReader 把图片读成 data URL,然后直接插入编辑区。这个设计在 demo 阶段很友好,零后端依赖,但它有三个致命问题。
第一是存储成本。一张 200KB 的截图转成 base64 后大约 270KB,因为编码膨胀约 33%。如果一篇文章插 10 张图,单条记录的 HTML 字段就接近 3MB。数据库的 TEXT 或 LONGTEXT 字段虽然能存,但查询、备份、迁移都会变得极其痛苦。
第二是传输成本。列表接口如果返回了 content 字段,每个请求都要拖着这几 MB 的字符串走。前端渲染时浏览器还要解析 base64 并解码,首屏时间肉眼可见地变长。
第三是无法复用。base64 图片和文章内容绑死,你想在另一篇文章里引用同一张图,只能重新上传一份。CDN 缓存、图片压缩、懒加载这些优化手段全都用不上。
所以正确的做法是:Summernote 只负责触发上传动作和展示图片,真正的文件存储交给服务端,编辑器里存的是图片 URL。这也是绝大多数生产环境后台编辑器的标准做法。
2.2 前端配置:callbacks.onImageUpload 的正确写法
Summernote 提供了onImageUpload回调,这是接管图片上传的入口。当用户选择或粘贴图片时,这个回调会被触发,参数是文件列表。你需要在回调里把文件通过 FormData 发到自己的上传接口,拿到 URL 后再用editor.insertImage插回编辑器。
// 初始化 Summernote 并接管图片上传 $('#editor').summernote({ height: 400, lang: 'zh-CN', toolbar: [ ['insert', ['picture', 'link', 'video']], ['style', ['bold', 'italic', 'underline', 'clear']], ['para', ['ul', 'ol', 'paragraph']] ], callbacks: { onImageUpload: function (files) { // files 是 FileList,可能一次多张,逐张上传 for (let i = 0; i < files.length; i++) { uploadImage(files[i], this); } } } }); function uploadImage(file, editor) { // 前端先做一次大小和类型校验,减少无效请求 const MAX_SIZE = 5 * 1024 * 1024; // 5MB const ALLOW = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']; if (file.size > MAX_SIZE) { alert('图片不能超过 5MB'); return; } if (!ALLOW.includes(file.type)) { alert('只支持 JPG/PNG/GIF/WebP 格式'); return; } const formData = new FormData(); formData.append('file', file); // 字段名要和后端约定一致 $.ajax({ url: '/api/upload/image', type: 'POST', data: formData, processData: false, // 必须 false,否则 jQuery 会序列化 FormData contentType: false, // 必须 false,让浏览器自动设置 multipart 边界 headers: { // 如果后端用 token 鉴权,这里带上;用 Cookie 的话可省略 'X-CSRF-Token': getCsrfToken() }, success: function (res) { if (res.code === 0 && res.data && res.data.url) { // 关键:把服务端返回的 URL 插回编辑器 editor.insertImage(editor, res.data.url); } else { alert(res.msg || '上传失败'); } }, error: function (xhr) { console.error('upload error', xhr.status, xhr.responseText); alert('上传失败,请重试'); } }); }这段代码有几个参数必须注意。processData: false和contentType: false是 FormData 上传的固定搭配,漏掉任何一个都会导致后端收不到文件。formData.append('file', file)里的字段名要和后端@RequestParam("file")或req.file的约定完全一致,这是最常见的 400 错误来源。editor.insertImage(editor, url)的第一个参数是编辑器实例,在回调里用this拿到,如果抽成独立函数就要显式传进去,否则会报insertImage is not a function。
2.3 后端接口:接收文件、落盘、返回 URL
后端要做的事情很明确:接收 multipart 请求,校验文件类型和大小,生成不冲突的文件名,存到指定目录或对象存储,返回可访问的 URL。下面用 Node.js + Express + multer 举例,其他栈逻辑一致。
// Express + multer 实现图片上传接口 const express = require('express'); const multer = require('multer'); const path = require('path'); const crypto = require('crypto'); const router = express.Router(); // 磁盘存储配置 const storage = multer.diskStorage({ destination: function (req, file, cb) { // 按日期分目录,避免单目录文件过多 const day = new Date().toISOString().slice(0, 10); const dir = path.join(__dirname, '../public/uploads', day); require('fs').mkdirSync(dir, { recursive: true }); cb(null, dir); }, filename: function (req, file, cb) { // 用随机串 + 原扩展名,避免中文名和重名问题 const ext = path.extname(file.originalname).toLowerCase(); const name = crypto.randomBytes(16).toString('hex') + ext; cb(null, name); } }); // 文件类型白名单校验 const fileFilter = function (req, file, cb) { const allow = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']; if (allow.includes(file.mimetype)) { cb(null, true); } else { cb(new Error('UNSUPPORTED_TYPE')); } }; const upload = multer({ storage: storage, fileFilter: fileFilter, limits: { fileSize: 5 * 1024 * 1024 } // 5MB,和前端保持一致 }); router.post('/api/upload/image', upload.single('file'), function (req, res) { if (!req.file) { return res.json({ code: 1, msg: '未接收到文件' }); } // 拼接可访问 URL,注意和静态资源目录对应 const day = new Date().toISOString().slice(0, 10); const url = `/uploads/${day}/${req.file.filename}`; res.json({ code: 0, msg: 'ok', data: { url: url } }); }); // 统一错误处理,把 multer 的错误转成友好提示 router.use(function (err, req, res, next) { if (err.message === 'UNSUPPORTED_TYPE') { return res.json({ code: 1, msg: '不支持的图片格式' }); } if (err.code === 'LIMIT_FILE_SIZE') { return res.json({ code: 1, msg: '图片超过大小限制' }); } res.json({ code: 1, msg: '上传失败' }); }); module.exports = router;后端这段代码的关键点在于:destination按日期分目录,防止单目录下文件数量爆炸导致ls都卡;filename用随机串而不是原始文件名,规避中文乱码、路径穿越和重名覆盖;fileFilter校验 MIME 类型,但要注意 MIME 可以被伪造,更严格的做法是读取文件头魔数校验。limits.fileSize是服务端最后一道防线,前端校验可以被绕过,服务端必须自己兜底。
返回格式{ code: 0, data: { url: '...' } }是自定义约定,前端success回调里按这个结构解析。如果你用的框架有统一响应包装,确保url字段能被前端取到,这是前后端联调时最容易对不上的地方。
2.4 静态资源目录与 URL 前缀的对应关系
上传成功后图片能不能显示,取决于 URL 能不能被正确访问。上面代码把文件存到public/uploads/日期/文件名,URL 返回/uploads/日期/文件名,那么 Express 必须把public目录挂成静态资源:
// 静态资源映射,必须放在路由注册之前或之后但路径不冲突 app.use('/uploads', express.static(path.join(__dirname, 'public/uploads')));如果你用的是 Nginx 反代,常见做法是让 Nginx 直接处理/uploads/路径,不走应用服务器:
location /uploads/ { alias /var/www/app/public/uploads/; expires 30d; add_header Cache-Control "public"; }这里有个血泪经验:开发环境用相对路径/uploads/xxx没问题,但生产环境如果前端和后端不同域,相对路径会指向前端域名,图片 404。解决办法是后端返回完整 URL(带协议和域名),或者前端配置一个VITE_API_BASE之类的变量拼接。我一般倾向后者,因为域名可能随环境变化,写死在数据库里以后迁移很麻烦。
3. 参数调优与边界处理:让上传功能真正可用
3.1 图片尺寸限制:前端、后端、Nginx 三层都要设
图片上传失败最常见的原因不是代码写错,而是某一层的大小限制没对齐。请求体从浏览器到后端要经过多个环节,任何一层超限都会返回 413。
| 层级 | 配置项 | 常见默认值 | 建议值 |
|---|---|---|---|
| 前端 | 自定义校验 | 无 | 5MB |
| Nginx | client_max_body_size | 1MB | 10MB |
| 应用 | multer limits.fileSize | 无限制 | 5MB |
| 框架 | Spring max-file-size | 1MB | 5MB |
Nginx 的client_max_body_size默认只有 1MB,这是最隐蔽的坑。前端选了张 2MB 的图,请求还没到应用就被 Nginx 拒了,返回 413,前端error回调里只能看到状态码,根本不知道是 Nginx 拦的。排查时先看 Nginx 错误日志,再逐层往上查。
3.2 粘贴上传和拖拽上传的处理差异
Summernote 的onImageUpload回调对「点击工具栏选图」「直接粘贴截图」「拖拽图片进编辑区」三种操作都会触发,但行为有细微差别。粘贴截图时files里的 File 对象name字段可能是image.png这种通用名,没有扩展名信息,后端如果依赖原始文件名判断类型就会失败。所以后端校验必须基于 MIME 或文件魔数,不能信originalname。
拖拽上传时,如果用户拖的是网页上的图片(不是本地文件),Summernote 默认会插入图片的远程 URL,不走onImageUpload。这种情况一般不用管,但如果你的业务要求所有图片必须落到自己服务器,就需要额外监听onPaste或onDrop事件做拦截,把远程 URL 下载后再上传。这个需求比较少见,大多数后台场景允许外链图片存在。
3.3 上传进度与多图并发
一次选多张图时,上面的代码是for循环里连续发多个 AJAX 请求,浏览器对同域并发请求有数量限制(通常 6 个),超过的会排队。如果用户一次选 20 张图,后面的请求要等前面的完成,体验上像是卡住了。更好的做法是用Promise.all控制并发数,或者加一个上传进度提示。
// 限制并发数为 3 的上传队列 async function uploadBatch(files, editor, concurrency = 3) { const queue = Array.from(files); const workers = []; for (let i = 0; i < concurrency; i++) { workers.push((async () => { while (queue.length) { const file = queue.shift(); try { const url = await uploadOne(file); // 返回 Promise<string> editor.insertImage(editor, url); } catch (e) { console.error('上传失败', file.name, e); } } })()); } await Promise.all(workers); }并发数设 3 是个折中值,既能利用带宽又不会把服务端打满。如果你的上传接口做了限流,并发数要相应调低,否则会触发 429。
4. 避坑指南:Summernote 图片上传的五个高频翻车现场
4.1 现象:上传成功但编辑器里图片不显示,控制台 404
原因通常是后端返回的 URL 和静态资源映射路径对不上。比如文件存在public/uploads/2024-01-01/abc.png,但返回的 URL 是/upload/2024-01-01/abc.png(少了个 s),或者 Nginx 的alias路径写错。还有一种情况是返回了相对路径,但前端页面在子路由下,相对路径解析到了错误的基准地址。
解决:打开浏览器 Network 面板,看图片请求的实际 URL,再对照服务器上文件的真实路径,逐段核对。最稳妥的做法是后端返回完整 URL,或者前端统一拼接 base 地址。
4.2 现象:后端收到文件但req.file为 undefined
原因多半是 FormData 的字段名和后端期望的不一致。前端formData.append('file', file),后端upload.single('file'),两边必须完全一样。另一个常见原因是contentType没设成false,jQuery 自动加了application/x-www-form-urlencoded头,导致 multipart 边界丢失,后端解析不出文件。
解决:检查字段名拼写,确认processData: false和contentType: false都写了。用浏览器 Network 面板看请求头是不是multipart/form-data; boundary=...,如果不是,就是这两个参数的问题。
4.3 现象:CSRF 校验失败,返回 403
如果后端开了 CSRF 保护(Spring Security、Django 默认都开),AJAX 上传请求需要带上 CSRF token。Summernote 的onImageUpload走的是普通 AJAX,不会自动带 token。
解决:在请求头里加X-CSRF-Token,值从页面 meta 标签或 Cookie 里取。Django 的话可以用getCookie('csrftoken'),Spring Security 可以从<meta name="_csrf" content="...">读。注意 token 要放在 header 而不是 FormData 里,因为 multipart 请求的 body 解析顺序可能影响 token 读取。
4.4 现象:图片上传后文件名乱码或变成一串数字
原因是后端直接用了originalname作为存储文件名,中文文件名在不同操作系统和编码下表现不一致,而且存在路径穿越风险(用户可以把文件名改成../../etc/passwd)。
解决:永远不要用原始文件名做存储名。用 UUID 或随机串 + 原扩展名,原始文件名只存数据库做展示用。扩展名也要做白名单校验,不能直接取originalname的后缀,因为攻击者可以构造evil.php.jpg这种双扩展名。
4.5 现象:大图上传后页面卡顿,编辑器响应变慢
原因是插入的图片没有做尺寸限制,一张 4000x3000 的原图直接显示在编辑器里,浏览器渲染和内存占用都很高。Summernote 本身不会压缩图片,它只是把 URL 插进去。
解决:后端在上传时做一次压缩,或者生成缩略图。用sharp(Node.js)或Thumbnails(Java)把图片最长边限制在 1920px,质量压到 80%,通常能减少 70% 以上的体积。如果不想动原图,至少在前端展示时用 CSS 限制max-width: 100%,但这只解决显示问题,不解决传输和存储问题。
5. 进阶技巧:把上传做成可配置、可观测的模块
5.1 用配置项管理上传策略
把上传相关的参数抽成配置,不同环境用不同值,避免改代码。下面是一个配置示例:
// config/upload.js module.exports = { maxSize: process.env.UPLOAD_MAX_SIZE || 5 * 1024 * 1024, allowTypes: ['image/jpeg', 'image/png', 'image/gif', 'image/webp'], storage: process.env.UPLOAD_STORAGE || 'local', // local | oss | s3 localDir: process.env.UPLOAD_LOCAL_DIR || 'public/uploads', urlPrefix: process.env.UPLOAD_URL_PREFIX || '/uploads', compress: { enabled: true, maxWidth: 1920, quality: 80 } };这样切换存储后端时,只需要改storage的值,上传接口里根据配置走不同分支。本地开发用local,生产用对象存储,代码不用动。
5.2 上传日志:出问题时能快速定位
每次上传记录一条日志,包含时间、文件名、大小、类型、来源 IP、结果。不用很复杂,一个简单的日志中间件就够:
function uploadLogger(req, res, next) { const start = Date.now(); res.on('finish', function () { const log = { time: new Date().toISOString(), ip: req.ip, size: req.file ? req.file.size : 0, name: req.file ? req.file.originalname : '', status: res.statusCode, cost: Date.now() - start }; console.log('[UPLOAD]', JSON.stringify(log)); }); next(); }有了这个日志,用户反馈「上传失败」时你能快速判断是文件太大、类型不对还是服务端异常,不用靠猜。
5.3 验证上传功能是否真的可用
写完代码别急着交付,按这个清单过一遍:选一张正常 JPG 能上传并显示;选一张超过限制的图,前端有提示且不发请求;把限制调小,确认后端也拦截;粘贴一张截图,确认走的是上传而不是 base64;上传后刷新页面,图片还在;用另一个浏览器打开同一篇文章,图片能显示;把图片 URL 复制到新标签页,能直接访问。这七步走完,基本能覆盖 90% 的线上问题。
我自己踩过最深的一个坑是:本地开发用localhost上传,图片存的是相对路径,一切正常;部署到测试环境后前端域名和后端域名不同,图片全部 404,排查了半天才发现是 URL 拼接的问题。从那以后我养成了一个习惯:上传接口返回的 URL 一定是完整可访问的绝对路径,或者前端统一用环境变量拼接,绝不在数据库里存相对路径。这个习惯帮我省了很多后悔药。希望帮到你。
本文还有配套的精品资源,点击获取