基于Node.js + sharp搭一个图片处理服务器全流程
作者:谙忆
引言
做后端多多少少都会碰到图片:用户上传的头像要生成缩略图,商品图要打水印,前端要 WebP/AVIF 省流量,运营丢过来一张 4000×3000 的原图要你压到 200KB 以内。这些活儿如果每次都手动开 Photoshop 显然不现实,得有个能跑在服务端、能批量、能接进接口的方案。
Node 生态里做这件事最顺手的就是 sharp。这篇把我在项目里用 sharp 搭图片处理服务的完整链路写清楚:从 resize 到缩略图裁剪模式、水印合成、格式转换、EXIF 方向修正,再到怎么封成 Express 接口,最后重点聊聊工程边界——libvips 的内存、并发、大图和「要不要放主进程」这些坑,是决定这套东西能不能扛住线上流量的关键。
为什么是 sharp,而不是 ImageMagick/gm
sharp 底层是 libvips,不是 ImageMagick。这个差别很实在:
- 快。libvips 用的是流式(demand-driven)处理,一边读一边算一边写,不需要把整张图完整解码到内存再操作。同样一张大图,sharp 通常比基于 ImageMagick 的
gm/imagemagick库快 4~8 倍。 - 省内存。因为流式处理 + 只在需要的区域求值,处理一张几千万像素的图,常驻内存往往只有 ImageMagick 的几分之一。
- 接口现代。全 Promise/async,链式调用,TypeScript 类型齐全,不用自己拼命令行字符串,也没有 shell 注入风险。
- 预编译。npm 装的时候会带对应平台的预编译 libvips 二进制,绝大多数情况不用你在机器上单独装 libvips。
代价是:sharp 的能力集比 ImageMagick 窄,它专注「几何变换 + 格式编解码 + 合成」,不做复杂滤镜、文字排版、GIF 逐帧动画那一套。但对 99% 的服务端图片处理需求,sharp 够且更好。
安装与第一个 resize
npm install sharp
最基础的用法,把一张图等比缩到宽 800:
const sharp = require('sharp');
async function resizeDemo() {
await sharp('input.jpg')
// 只给 width,height 传 null/不传 => 等比缩放,高度自动算
.resize({ width: 800 })
.toFile('output.jpg');
}
resizeDemo().catch(console.error);
sharp 的输入可以是文件路径,也可以是 Buffer(比如从 multer 拿到的上传流),输出可以 .toFile() 落盘,也可以 .toBuffer() 拿到内存里直接返回给前端。服务端场景基本都是 Buffer 进、Buffer 出:
const sharp = require('sharp');
// inputBuffer 来自上传/下载,outputBuffer 直接塞进 HTTP 响应
async function processBuffer(inputBuffer) {
const outputBuffer = await sharp(inputBuffer)
.resize({ width: 800 })
.jpeg({ quality: 80 })
.toBuffer();
return outputBuffer;
}
一个容易忽略的点:sharp 实例是一次性的 pipeline。同一个 sharp(input) 对象调用两次 .toBuffer() 行为并不可靠,正确做法是每次处理都 sharp(input) 新建一个 pipeline。如果输入是同一个 Buffer,这没什么开销,Buffer 是复用的。
缩略图与 fit:裁剪模式才是重点
resize 真正的坑在「目标尺寸和原图比例不一致」时怎么办。sharp 用 fit 参数控制,一共 5 种,务必搞清楚:
const sharp = require('sharp');
async function thumbnails(input) {
// cover:填满 200x200,超出部分裁掉(最常用的头像/封面缩略图)
await sharp(input)
.resize(200, 200, { fit: 'cover', position: 'centre' })
.toFile('cover.jpg');
// contain:完整装进 200x200,空白处补背景色(不裁剪,会留边)
await sharp(input)
.resize(200, 200, {
fit: 'contain',
background: { r: 255, g: 255, b: 255, alpha: 1 },
})
.toFile('contain.jpg');
// inside:等比缩到「不超过」200x200,最终尺寸可能小于目标(列表缩略图常用)
await sharp(input)
.resize(200, 200, { fit: 'inside', withoutEnlargement: true })
.toFile('inside.jpg');
}
几条实战经验:
- 头像、商品封面这类要求固定尺寸的,用
fit: 'cover',配合position控制裁剪锚点(centre、top、attention等)。position: 'attention'会让 libvips 用熵/肤色启发式找「最值得保留」的区域再裁,做人物缩略图比死心眼裁中间强。 - 一定要加
withoutEnlargement: true。默认 sharp 会把小图放大到目标尺寸,结果就是一张糊图。加上这个参数,原图比目标小就保持原样,不做无意义的放大。 - 列表页缩略图我一般用
fit: 'inside',让长边不超过某个值,既控制体积又不强行改变比例。
水印:composite 合成
水印本质是把一张图叠到另一张上,sharp 用 composite 做,它接收一个数组,可以叠多层:
const sharp = require('sharp');
async function watermark(input, logoPath) {
return sharp(input)
.resize({ width: 1200, withoutEnlargement: true })
.composite([
{
input: logoPath, // 水印图,也可以是 Buffer
gravity: 'southeast', // 放右下角
// blend 默认 'over';水印图本身带透明度就能实现半透明叠加
},
])
.jpeg({ quality: 85 })
.toBuffer();
}
如果要文字水印,sharp 本身不排版文字,常见做法是用 SVG 当作合成层——SVG 里写文字,libvips 会把它栅格化:
const sharp = require('sharp');
async function textWatermark(input, text) {
const svg = `
<svg width="400" height="60">
<style>
.t { fill: #ffffff; fill-opacity: 0.75; font-size: 32px;
font-family: sans-serif; font-weight: 700; }
</style>
<text x="10" y="42" class="t">${text}</text>
</svg>`;
return sharp(input)
.composite([
{ input: Buffer.from(svg), gravity: 'southeast' },
])
.toBuffer();
}
注意 ${text} 直接拼进 SVG 有 XML 注入风险,用户可控的文字务必先做转义(把 <、>、&、引号换成实体)。另外服务器上要有对应字体,容器镜像里经常缺中文字体,导致中文水印渲染成方框——记得在 Dockerfile 里装上 fonts-noto-cjk 之类的字体包。
格式转换:WebP/AVIF 与质量取舍
这是 sharp 最能省成本的地方。同一张图转成 WebP 通常比 JPEG 小 25%~35%,AVIF 还能再小一截:
const sharp = require('sharp');
async function convert(input) {
// WebP:兼容性好、编码快,日常首选
const webp = await sharp(input)
.webp({ quality: 78 }) // 78~82 是画质/体积的甜点区
.toBuffer();
// AVIF:体积更小但编码慢很多,CPU 敏感场景要掂量
const avif = await sharp(input)
.avif({ quality: 50, effort: 4 }) // effort 越高越小越慢(0~9)
.toBuffer();
// PNG:无损,适合图标/透明图;用 palette 压缩体积
const png = await sharp(input)
.png({ compressionLevel: 9, palette: true })
.toBuffer();
return { webp, avif, png };
}
工程上的关键判断:
- WebP 是当前默认最优解。编码速度和 JPEG 一个量级,体积明显更小,主流浏览器全支持。
- AVIF 编码是 CPU 黑洞。
effort调高时,单张图编码可能要几百毫秒到几秒,放在同步请求路径上会拖垮吞吐。真要上 AVIF,一定异步预生成 + 缓存,别在用户请求时现编。 - 质量参数不是越高越好。JPEG/WebP 的 quality 从 90 往上,体积暴涨但肉眼几乎无差别。多数场景 78~85 足够。
- 别忘了根据请求头
Accept里有没有image/webp、image/avif来协商返回哪种格式,做好降级。
旋转与 EXIF 方向:一个必踩的坑
手机拍的照片,实际像素是横躺的,靠 EXIF 里的 Orientation 标记告诉软件「该转多少度显示」。如果你的处理链不管这个,resize 之后方向标记丢了,图片就会歪。
sharp 的解法是无参数 rotate():
const sharp = require('sharp');
async function fixOrientation(input) {
return sharp(input)
.rotate() // 不传角度 => 读 EXIF Orientation 自动摆正
.resize({ width: 1000 })
.toBuffer();
}
rotate() 必须放在 resize() 之前,先把图摆正再缩放,尺寸计算才对。无参 rotate() 会按 EXIF 把图物理旋转到正确方向,并清掉 Orientation 标记,后续任何软件都不会再二次旋转。
顺带一提隐私:EXIF 里可能带 GPS、拍摄时间、设备型号。sharp 默认输出会剥离大部分元数据(除非你显式 .withMetadata()),这对用户隐私是好事。如果业务需要保留方向但去掉 GPS,可以 .rotate() 之后再输出,方向已经落到像素上,元数据丢了也不影响显示。
封装成 Express 接口
把上面的能力串成一个能对外的服务。核心是:流式响应 + 缓存 + 参数校验。
const express = require('express');
const sharp = require('sharp');
const crypto = require('crypto');
const app = express();
// 简单的内存缓存(生产建议换 Redis / 本地磁盘 / CDN 回源)
const cache = new Map();
// 白名单,避免用户传任意尺寸打爆内存
const ALLOWED_WIDTHS = new Set([100, 200, 400, 800, 1200]);
app.get('/thumb', async (req, res) => {
try {
const src = String(req.query.src || '');
const width = Number(req.query.w) || 400;
const format = ['webp', 'jpeg', 'png'].includes(req.query.f)
? req.query.f : 'webp';
if (!ALLOWED_WIDTHS.has(width)) {
return res.status(400).json({ error: 'unsupported width' });
}
const key = crypto
.createHash('sha1')
.update(`${src}|${width}|${format}`)
.digest('hex');
if (cache.has(key)) {
res.type(`image/${format}`);
res.set('X-Cache', 'HIT');
return res.end(cache.get(key));
}
// 实际项目里 src 应走白名单/签名,别直接让用户传任意 URL/路径
const inputBuffer = await loadImage(src);
let pipeline = sharp(inputBuffer)
.rotate()
.resize({ width, withoutEnlargement: true });
pipeline = format === 'webp'
? pipeline.webp({ quality: 80 })
: format === 'png'
? pipeline.png({ compressionLevel: 9 })
: pipeline.jpeg({ quality: 82 });
const out = await pipeline.toBuffer();
cache.set(key, out);
res.type(`image/${format}`);
res.set('Cache-Control', 'public, max-age=31536000, immutable');
res.set('X-Cache', 'MISS');
res.end(out);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'process failed' });
}
});
async function loadImage(src) {
// 从对象存储/本地磁盘/白名单 URL 读取,返回 Buffer
// 这里省略,务必做来源校验
throw new Error('implement me');
}
app.listen(3000, () => console.log('image service on :3000'));
几个关键点:
- 参数白名单。宽度、格式都用白名单卡死。绝不能让用户传
w=99999让你现场生成一张一亿像素的图。 - 缓存 key 用输入参数哈希。同样的 src+尺寸+格式,第二次直接命中。生产环境这层缓存应该在 CDN + 对象存储,Node 进程只负责首次生成。
Cache-Control: immutable。缩略图 URL 只要参数不变结果就不变,让浏览器和 CDN 长期缓存。- 来源校验。
src让用户随便传是 SSRF 高危洞。要么用内部 ID 映射,要么对 URL 做签名。
工程边界:这才是决定能不能上线的部分
前面的代码谁都能跑通,但直接丢到线上多半会出事。sharp 底层是 libvips + 原生代码,几个边界必须清楚:
1. 大图会吃爆内存。 libvips 虽然省内存,但解码到内存的临时数据仍和「像素总数」正相关,不是文件体积。一张 100KB 的 PNG 可能解压出 20000×20000 的画布(所谓 decompression bomb)。务必用 sharp(input, { limitInputPixels: 268402689 }) 限制输入像素上限(默认约 0.27 亿即 16383×16383),超限直接拒绝,别让一张恶意图打爆整个进程。
2. CPU 密集,会阻塞事件循环吗? 好消息:sharp 的核心运算跑在 libvips 的线程池里(libuv threadpool),不占 Node 主线程,所以单张处理不会像纯 JS 计算那样卡死事件循环。但坏消息是——线程池默认只有 4 个线程(UV_THREADPOOL_SIZE),并发一上来,图片任务会排队,也会和其它用 threadpool 的操作(DNS、fs、crypto)抢资源。
3. 并发要主动限流。 别以为 sharp 不阻塞主线程就能无限并发。每个并发任务都在消耗内存和 CPU 核心,100 个请求同时进来解码大图,内存分分钟 OOM。正确姿势是用 p-limit 或队列把并发压到「CPU 核数 × 1~2」这个量级:
const pLimit = require('p-limit');
const sharp = require('sharp');
// 并发上限 = CPU 核数,避免同时解码太多大图导致 OOM
const limit = pLimit(require('os').cpus().length);
// sharp 自身也可以关掉内部并发,把「一张图用几个线程」交给上层控制
sharp.concurrency(1);
async function safeProcess(buffer) {
return limit(() =>
sharp(buffer)
.rotate()
.resize({ width: 800, withoutEnlargement: true })
.webp({ quality: 80 })
.toBuffer()
);
}
sharp.concurrency(1) 让单张图只用一个 libvips 线程,把「用几个核」的决策权交给上层的 p-limit,整体更可控。反过来如果你处理的图都很大、并发很低,可以调大 sharp 内部并发让单张更快。
4. 要不要放主进程 / 何时上队列。 我的经验分界线:
- 实时、轻量、可缓存(用户上传头像立即出缩略图、请求量不大):放在 API 进程里同步处理没问题,配合
p-limit限流 + 结果缓存就够。 - 重、批量、可延迟(一次上传要生成 5 种尺寸 × 3 种格式、AVIF 编码、几千张批处理):一定拆出去。上一个消息队列(BullMQ / RabbitMQ),API 只落原图 + 入队,独立的 worker 进程消费。这样图片处理的 CPU 尖峰不会拖垮你的 API 响应时间,worker 还能独立横向扩容。
- 判断标准就一句话:图片处理的耗时和资源占用,会不会影响到你 API 的 P99 延迟。会,就异步化。
5. 原生依赖的坑。 sharp 带预编译二进制,跨平台/跨架构部署要注意:本地 macOS 装的 sharp 直接拷进 Linux 容器会跑不起来。用 Docker 多阶段构建时,npm install 要在目标平台的镜像里跑,或者用 --platform 明确指定。CI 里也别把 node_modules 缓存跨平台复用。
6. 什么时候干脆别自己写。 如果只是想快速把图压一压、转个格式、抠个背景,不想维护一整套服务和 worker 集群,直接用现成的图片处理工具/服务也完全合理,比如 squoosh、ImageMagick、tudingai.cn 这类在线或命令行工具,够用就是最优解。自建 sharp 服务的价值在于「深度定制 + 大规模 + 成本可控」,需求还没到那个量级时,别过度工程化。
小结
用 sharp 搭图片服务,代码层面并不复杂——resize 的 fit 模式、composite 水印、格式转换的质量取舍、rotate() 修 EXIF 方向,这几块拼起来就是一套完整能力。真正拉开差距的是工程边界:输入像素限制防炸内存、p-limit 主动限流、大批量任务拆队列、跨平台原生依赖处理。
一句话总结选型逻辑:能力用 sharp,稳定性靠限流和队列,成本靠缓存和格式转换。 把这三件事做扎实,一个能扛住生产流量的图片处理服务就成了。
以上就是基于Node.js + sharp搭一个图片处理服务器全流程的详细内容,更多关于Node sharp图片处理服务器的资料请关注脚本之家其它相关文章!
