用 Docker + Squoosh 打造图片压缩 API 服务
使用 Squoosh 的 Node.js 组件构建轻量图片压缩 API 服务,轻松集成到任意项目中,支持 JPG、PNG、WebP 等多种格式,压缩率高达 80%。
现在拍照设备拍的照片那是真的越来越大,网站如果涉及到很多图片,不去压缩的话存储空间和带宽是真吃不消。
于是就开始找解决办法,怎么使用程序压缩图片。试过很多方法,比如Pillow、OpenCV、Thumbnailator等,没有一款特别满意的,要么压缩后失真严重,要么压缩率不理想。
市面上在线的图片压缩工具压缩效果普遍不错,比如 TinyPNG、Squoosh、Optimizilla、Compress JPEG 等。我常用的是 TinyPNG,压缩率高、画质清晰,还提供 API 接口可以集成到自己的应用中。但问题是它收费太高,免费额度只有500张/月。
有一次无意间发现 Squoosh 有开源的 Node.js 组件,于是就动手写了一个 Node.js 服务,提供一个通用的图片压缩 API 给其他服务调用。
项目介绍
这个服务使用 Node.js 和 Squoosh CLI 构建,并通过 Docker 容器运行,方便快速部署到任意环境。
API 支持多种常见图片格式,包括JPG、PNG、WebP等。大部分图片压缩率能达到 80% 以上,而且几乎无损。
项目结构
├── Dockerfile ├── package.json ├── app.ts └── src ├── config.ts ├── routers.ts └── imagecompress.ts
核心依赖
- express- 提供 HTTP 服务
- @squoosh/lib- 图片压缩核心组件
- multer- 文件上传处理
Dockerfile 示例
FROM node:16.20.0-bullseye-slim # 执行命令,创建文件夹 RUN mkdir -p /home/compress # 设置工作目录 WORKDIR /home/compress COPY . /home/compress RUN npm install && \ npm run build && \ rm -rf /home/compress/src && \ rm -rf /home/compress/*.ts && \ rm -rf /home/compress/*.json && \ rm -rf /home/compress/Dockerfile # 配置环境变量 ENV HOST 0.0.0.0 ENV PORT 8866 # 容器对外暴露的端口号 EXPOSE 8866 #CMD ["npm", "run", "dev"] CMD ["node", "/home/compress/dist/app.js"]
imagecompress.ts示例
import { ImagePool, encoders, preprocessors } from "@squoosh/lib"; import { cpus } from "os"; import { CompressOptions } from "@/types/config"; const imagePool = new ImagePool(cpus().length); export async function compress(buffer:Buffer, ext: string, options: CompressOptions): Promise<Buffer> { let startTime = new Date().getTime(); console.log("Start compressing pictures...", options); let image = imagePool.ingestImage(buffer); let preprocessOptions: any = { }; let targetWidth = options.width && options.width > 0 ? options.width : undefined; let targetHeight = options.height && options.height > 0 ? options.height : undefined; if (targetWidth || targetHeight) { preprocessOptions.resize = { width: targetWidth, height: targetHeight } } let encodeOptions: any = { }; await image.preprocess(preprocessOptions); await image.encode(encodeOptions); let targetFormat = options.target ? options.target.toLocaleLowerCase() : ext ? ext.replace(".", "").toLocaleLowerCase() : undefined; let result; if (targetFormat === "png") { encodeOptions.oxipng = { level: options.quality ? options.quality : 75 }; await image.encode(encodeOptions); result = await image.encodedWith.oxipng; } else if (targetFormat === "jxl") { encodeOptions.jxl = { quality: options.quality ? options.quality : 75 }; await image.encode(encodeOptions); result = await image.encodedWith.jxl; } else if (targetFormat === "avif") { await image.encode(encodeOptions); result = await image.encodedWith.avif; } else if (targetFormat === "webp") { encodeOptions.webp = { quality: options.quality ? options.quality : 75 }; await image.encode(encodeOptions); result = await image.encodedWith.webp; } else { encodeOptions.mozjpeg = { quality: options.quality ? options.quality : 75 }; await image.encode(encodeOptions); result = await image.encodedWith.mozjpeg; } if (!result) { return Buffer.alloc(0); } //const {extension, binary } = await image.encodedWith.mozjpeg; let array = new Uint8Array(result.binary.buffer); let buf: Buffer = Buffer.alloc(array.length); for (let nIndex = 0; nIndex < buf.length; nIndex++) { buf[nIndex] = array[nIndex]; } console.log("Image compression completed! time consuming: " + (new Date().getTime() - startTime) + " ms."); console.log("Original image size: " + buffer.length + " Bytes, Compressed image size: " + buf.length + " Bytes, Compression ratio: " + Math.round(((buffer.length - buf.length) * 100 / buffer.length) * 100) / 100 + "%."); return buf; }
routers.ts对外接口示例
router.post('/compress', upload.single('file'), function(req: Request, res: Response) { if (!req.file) { return res.status(400).send("No file uploaded!"); } else if (req.file.size > 10485760) { return res.status(400).send("The maximum image size cannot exceed 10M!"); } let options: CompressOptions = { quality: req.body.quality ? parseInt(req.body.quality) : undefined, width: req.body.width ? parseInt(req.body.width) : undefined, height: req.body.height ? parseInt(req.body.height) : undefined, target: req.body.target, extra: req.body.options }; if (req.body.ct && req.body.ct === "attachment") { //download attachment res.setHeader("Content-Type", "application/octet-stream; charset=utf-8"); res.setHeader("Content-Disposition", "attachment; filename=" + encodeURIComponent(req.file.originalname)); } else { //Picture Preview //res.setHeader("Content-Type", req.file?.mimetype); } compress(req.file.buffer, path.extname(req.file.originalname).toLocaleLowerCase(), options).then((buffer: Buffer) => { res.end(buffer); }); });
使用方式
-
构建镜像:
docker build -t imagecompress .
-
启动服务:
docker run -d -p 8866:8866 imagecompress
-
上传并压缩图片:
curl --location 'http://localhost:8867/compress' \ --form 'file=@"/C:/Users/luoxudong/Pictures/1.png"' \ --form 'quality="75"' \ --form 'width="1000"' \ --form 'ct="attachment"' \ --form 'target="jpg"'
参数说明:
file: (必传) 文件内容,文件的key值为”file”。
quality: (选填) 图片压缩质量,不传时默认为75。
width: (选填) 设置压缩后输出图片的宽度,高度不设置时按等比例压缩。
height: (选填) 设置压缩后输出图片的高度,宽度不设置时按等比例压缩。
ct: (选填) 设置输出图片的Content-Type,“attachment”为以附件方式输出,其他为以预览图片方式输出。
target: (选填) 压缩后的图片格式,默认跟附件后缀一致,支持的值:”jpg” | “png” | “webp” | “avif” | “jxl。
压缩效果
实际测试中,使用 mozjpeg 或 WebP 编码方式时,图片压缩率普遍在 70%-85% 之间,肉眼几乎看不出差别。docker镜像已发布到dockerhub上,欢迎大家体验!
总结
相比第三方收费服务,自建的 Docker + Node.js + Squoosh 压缩服务更灵活、更可控,能轻松集成到自己的网站、后台管理或内容处理系统中。
后续可以进一步优化支持异步任务、批量压缩、分布式处理等功能,让图片资源管理更加高效。
本文首发于 东哥小栈(EastStack) · 书写不止是记录,更是思考的延伸。