node.js

关注公众号 jb51net

关闭
首页 > 网络编程 > JavaScript > node.js > Node sharp图片处理服务器

基于Node.js + sharp搭一个图片处理服务器全流程

作者:谙忆

做后端多多少少都会碰到图片:用户上传的头像要生成缩略图,商品图要打水印,前端要 WebP/AVIF 省流量,运营丢过来一张 4000×3000 的原图要你压到 200KB 以内,Node 生态里做这件事最顺手的就是 sharp,这篇把我在项目里用 sharp 搭图片处理服务的完整链路写清楚

引言

做后端多多少少都会碰到图片:用户上传的头像要生成缩略图,商品图要打水印,前端要 WebP/AVIF 省流量,运营丢过来一张 4000×3000 的原图要你压到 200KB 以内。这些活儿如果每次都手动开 Photoshop 显然不现实,得有个能跑在服务端、能批量、能接进接口的方案。

Node 生态里做这件事最顺手的就是 sharp。这篇把我在项目里用 sharp 搭图片处理服务的完整链路写清楚:从 resize 到缩略图裁剪模式、水印合成、格式转换、EXIF 方向修正,再到怎么封成 Express 接口,最后重点聊聊工程边界——libvips 的内存、并发、大图和「要不要放主进程」这些坑,是决定这套东西能不能扛住线上流量的关键。

为什么是 sharp,而不是 ImageMagick/gm

sharp 底层是 libvips,不是 ImageMagick。这个差别很实在:

代价是: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');
}

几条实战经验:

水印: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 };
}

工程上的关键判断:

旋转与 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'));

几个关键点:

工程边界:这才是决定能不能上线的部分

前面的代码谁都能跑通,但直接丢到线上多半会出事。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. 要不要放主进程 / 何时上队列。 我的经验分界线:

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图片处理服务器的资料请关注脚本之家其它相关文章!

您可能感兴趣的文章:
阅读全文