返回博客

优化网页浏览的照片压缩方法与实践

照片压缩网页优化前端开发图像处理

背景

最近做了个产品 - flic.cc,一个允许用户上传照片并创建个人照片站点的 SaaS 平台。示例站点 chuangyu.flic.cc

开发它的过程中,照片处理是其中重要的一环。涉及读取 EXIF 信息、生成缩略图和高清图两种压缩版本,更新用户数据库以及上传至 Cloudflare R2 做 CDN 分发。用户提供的未压缩的原始照片文件通常比较大,根据拍摄设备、分辨率设置、文件格式等等不同,照片文件一般在 5MB 以上,RAW 格式可达 20-100MB。在压缩图片方面尝试了三个方案:

  1. 在 Next.js 的服务端处理
  2. 在 Cloudflare Worker 中处理
  3. 在用户本地浏览器端处理

最终经过比较,选择了在用户端处理,主要原因是:

  • Next.js 服务端(方案一)在生产环境下无法支持大文件上传 - 值得一提,由于这个行为在本地与生产环境不一致,使得我在开发完成后才发现这个问题,Next.js 的 bodyParser 在本地开发中对于上传的文件大小没有限制,而在生产环境中默认为 1MB。官方提供了 bodyParser.sizeLimit 可以调整,但最大限制依然只有 4.5MB,远远不能满足我的需要。
  • Cloudflare Worker(方案二)内存受限(128MB),无法高效压缩大文件,并且这个限制不能通过付费解决 - 原本期待的流程是:用户端从 api 请求签名 -> 在本地签名照片后直接发送到 R2 -> R2 注册的 hook 被触发,放入队列中 -> worker 从队列中接收到消息,进而从 R2 中读取原图,压缩处理后再写回 R2。然而,我完成了 worker 的开发后才在测试中发现内存不足的问题,且无法解决。(但由此得到的收获是,128MB 足够处理大量的文本和小图片,这套方案可以应用于博客的图床,以后另开一篇文章细说)
  • 在考虑用户端处理之前,我大致评估了第三方服务(如 Cloudinary、Imgix)的可行性。这些服务提供不错的图像处理和 CDN 集成,唯一的问题是初期成本较高,不如 Cloudflare 经济实惠。

用户端处理不仅规避了上述问题,还有几点优势:

  • 节省带宽和加快上传速度:图片在本地压缩后再上传,极大减少传输的数据量,对移动端和弱网环境友好。
  • 用户体验:用户可以即时看到压缩后的图片预览,上传等待时间更短。
  • 隐私性:原始照片无需上传到服务器,用户的隐私数据(如 EXIF 信息)可以在本地处理和去除。
  • 灵活性高:可以根据用户设备性能、网络状况动态调整压缩策略,例如利用 Web Worker 同时处理多张照片。

之后,我尝试了多种图片处理库,不同的压缩比例和文件格式,以获得理想的文件体积。

我所选择的两个图片处理库对比

JavaScript 生态开源的图像处理库有不少,一番调研过后,只有以下是其中两个比较适合我的需求。

  1. https://github.com/lovell/sharp
  2. https://github.com/jimp-dev/jimp

它们的特点都是支持的图片格式广泛。

性能方面,这篇 5 年前的文章比较过两者的性能:https://www.peterbe.com/plog/sharp-vs-jimp - 在作者的测试下,当时的 Jimp 比 Sharp 慢了 20 多倍,未比较内存占用。

结合该文章的代码,我做了一些改进,因为我同时也好奇它们两的内存占用的差距,以下是我测试两者性能的代码(基于目前各自最新的版本):

import { writeFile } from "fs/promises";
import path from "path";
import sharp from "sharp";
import { Jimp } from "jimp";
import { readdirSync as readdir, statSync } from "fs";
 
async function f1(sourcePath: string, destination: string) {
  const buf = await sharp(sourcePath).resize(100).toBuffer();
  const destPath = path.join(destination, path.basename(sourcePath));
  await writeFile(destPath, buf);
  return destPath;
}
 
async function f2(sourcePath: string, destination: string) {
  const image = await Jimp.read(sourcePath);
  image.resize({ w: 100 });
  const destPath = path.join(destination, path.basename(sourcePath));
  await image.write(destPath as "${string}.${string}");
  return destPath;
}
 
const files = readdir("./images")
  .filter((file) => [".jpg", ".png"].includes(path.extname(file).toLowerCase()))
  .map((file) => path.join("./images", file));
 
async function measure(
  func: (file: string, dest: string) => Promise<string>,
  files: string[],
  destDir: string
) {
  const startMemory = process.memoryUsage().heapUsed;
  console.time("Took");
  const destPaths = await Promise.all(files.map((file) => func(file, destDir)));
  console.timeEnd("Took");
  const endMemory = process.memoryUsage().heapUsed;
  console.log(`Memory used: ${(endMemory - startMemory) / 1024 / 1024} MB`);
 
  const totalSize = (
    await Promise.all(destPaths.map((path) => statSync(path).size))
  ).reduce((sum, size) => sum + size, 0);
  console.log(`Total output size: ${totalSize / 1024} KB`);
}
 
await measure(f1, files, "./output1/");
await measure(f2, files, "./output2/");

我在 images 文件夹中放了 20 张 20Mb 左右的 JPEG 照片。

使用 bun run test.ts,在我的 MacBook Air (M1 Chip) 上我得到以下结果:

[318.45ms] Took
Memory used: 2.0748586654663086 MB
Total output size: 30.99609375 KB
[14.12s] Took
Memory used: 633.2822828292847 MB
Total output size: 146.103515625 KB

第一次的输出是 Sharp 的,第二次是 Jimp。从中看出,最新版的 Sharp 在运行效率依然是大幅领先 Jimp,速度是 44x,而占用内存仅 1/305。不过,Sharp 运行中涉及进程外的调用,这里的内存占用仅表示的是 JS 的堆内存使用,它一定是不准确的。我将 process.memoryUsage().heapUsed 换成 process.memoryUsage().rss,后者除了统计堆内存,还包含了非堆内存和调用系统程序的内存。再次运行得到以下结果:

[307.51ms] Took
Memory used: 124.96875 MB
Total output size: 30.99609375 KB
[14.77s] Took
Memory used: 1115.5 MB
Total output size: 146.103515625 KB

从内存差距看出,Sharp 需要的内存也仅仅是 Jimp 的 1/10 左右,更方面都更优。

而兼容性方面,Sharp 因为依赖 libvips 的关系,只能在 Node.js 环境中使用。Jimp 不仅支持 Node.js 环境,也可以在浏览器中使用,因为这个原因,我的最终方案选择了 Jimp,虽然它的性能比较差。

这里同时也解释了为什么方案二中内存不够用,原因是 Cloudflare Worker 的运行环境不支持 Sharp,或者说不支持 libvips,因此我在方案二中尝试使用的是 Jimp。

照片格式的选择

选定了工具,接下来是看什么样的输出格式适合我的站点。其实在做 flic.cc 之前,我的站点就已经运行了一段时间,之前选定的是 WebP。原因很简单,它的压缩比更高,相同文件体积下它比 JPEG 存储了更多图像信息,换句话说,图像质量更好。在推友的提醒下,我还了解到有更体积更小的选择,AVIF 格式。

目前我还没有要替换 WebP 的打算,但是在写这篇文章时我再次对比了这三种格式,顺便浏览了一些热门照片站点的选择。

JPEG

使用最广泛的照片格式,有损压缩,体积小,兼容性好,是目前大多数网页照片展示的首选格式。例如 500px 和 pexels。

WebP

谷歌推出的图片格式,支持有损和无损压缩,相比与 JPEG 体积更小(相同画质下比 JPEG 节省 25%-30% 的空间),画质优秀,虽然兼容性不如 JPEG(支持Chrome(v32+)、Firefox(v65+)、Safari(v16.0+)、Edge(v18+)、Opera(v19+)。移动端如iOS 14+、Android现代浏览器也支持。),但 95% 的浏览器都已经支持。

兼容性数据来源:https://caniuse.com/webp

AVIF

基于 AV1 编解码的图片格式,压缩率高于 WebP 和 JPEG,画质优秀,文件体积更小。兼容性在三者中最差(支持Chrome(v85+)、Firefox(v93+)、Safari(v16.4+)、Edge(v121+)、Opera(v71+)。

兼容性数据来源:https://caniuse.com/avif

在比较热门的照片网站中 Unsplash 和 Flickr 已经在使用。

浏览器端照片压缩实践

在网页中展示照片有三个因素需要考虑。照片文件的大小,图像质量,和文件尺寸。这三个因素分别影响着页面的加载速度(尤其是 LCP - Largest Contentful Paint 指标)和 UI 美观,因而需要权衡。

过往的经验告诉我,使用 JPEG 格式时,如果每张图片的尺寸在 100-200px 左右,10-50Kb 是比较合适的大小,这不仅是考虑到首屏大约容纳 50 张缩略图,也考虑了判断照片质量的一个标准 - PSNR

在我的场景中(chuangyu.flic.cc/),首屏最多需要加载 10-20 张照片,在手机端首屏甚至只需加载 1-3 张,随着用户滚动页面才会加载更多,照片的尺寸(缩略图)有 300-370px 左右,因此照片的大小可以适当放宽到最大 150Kb。稍后我会验证这个值是否确实符合我的需求。

以上,我做了个简单的二维表格作为参考,横轴是首屏需要渲染的缩略图照片数量,纵轴是每张照片的尺寸,表格内是建议的最大文件大小。根据文件格式的不同会有一些出入。我的目的是不牺牲太多照片质量的前提下让首屏的加载速度尽可能快。

Thumbnail Dimension (px) \ Number of Photos102050
10010Kb10Kb10Kb
20050Kb50Kb50Kb
300100Kb100Kb100Kb
370150Kb150Kb150Kb

表格说明:建议文件大小基于以下考量:1) 首屏加载速度(LCP 指标)需控制在大约 2.5 秒内;2) 假设网络带宽为 4G(约 2-5Mbps),计算单张照片加载时间;3) WebP 格式在 10-150Kb 范围内可保持较高质量(PSNR>30)。具体值需根据实际场景微调。

接下来,使用 Jimp 实现压缩照片的代码。为了支持 WebP,还需要引入 @jimp/wasm-webp 插件。

import { defaultFormats, defaultPlugins } from "jimp";
import webp from "@jimp/wasm-webp";
 
const Jimp = createJimp({
  formats: [...defaultFormats, webp],
  plugins: defaultPlugins,
});
 
const processImage = async (file: File, index: number) => {
  try {
    const buffer = await file.arrayBuffer();
    const image = await Jimp.read(buffer);
 
    // Extract EXIF data
    //...skipped
 
    // Generate thumbnail
    const thumbnail = await image
      .clone()
      .autocrop()
      .rotate(0)
      .scaleToFit({
        w: thumbnailSize,
        h: thumbnailSize,
      })
      .getBuffer(thumbnailFormat, { quality: thumbnailQuality });
 
    // Generate standard image
    const standard = await image
      .clone()
      .autocrop()
      .rotate(0)
      .scaleToFit({
        w: standardImageSize,
        h: standardImageSize,
      })
      .getBuffer(standardImageFormat, { quality: standardImageQuality });
 
    // Other steps
    //...
  } catch (error) {}
};

因为我选择的是 WebP,压缩率比 JPEG 更好,我其实可以在 <150Kb 的限制内尝试获得更好的照片质量,或者将限制改到 <100Kb 或更小,我做了若干测试,做了如下选择:

export const thumbnailSize = 768;
export const thumbnailQuality = 90;
export const thumbnailFormat = "image/webp";
export const standardImageSize = 1920;
export const standardImageQuality = 85;
export const standardImageFormat = "image/webp";

PS:高清图的尺寸和质量的选择是基于 <500Kb 这个限制做出的。

注意:这里选择的照片尺寸并非最终渲染到屏幕上的尺寸。我预期要渲染到 300-370px 的大小,更大的图片显然清晰度会更好。

在以上配置下,大多数照片只有不到 80Kb 的大小,chuangyu.flic.cc 站点的 LCP 值为 2.10s(4G 网络下),符合预期。

测试压缩图片的 PSNR 值

除了 LCP 之外,我还需要确保照片的质量足够好,尽量肉眼看上去“不错”,但我还需要更客观的数据,前面提到的 PSNR 就是检测照片质量的标准之一。

在这一步,我用 ai 写了一段代码帮助我检测:

const sharp = require("sharp");
 
async function calculatePSNR(originalPath, compressedPath) {
  try {
    // 读取原图和压缩图
    const original = await sharp(originalPath).metadata();
    const compressed = await sharp(compressedPath).metadata();
 
    // 确定目标尺寸(取较小尺寸)
    const targetWidth = Math.min(original.width, compressed.width);
    const targetHeight = Math.min(original.height, compressed.height);
 
    // 调整尺寸并转换为原始像素数据
    const origResized = await sharp(originalPath)
      .resize(targetWidth, targetHeight, { fit: "fill" })
      .raw()
      .toBuffer({ resolveWithObject: true });
    const compResized = await sharp(compressedPath)
      .resize(targetWidth, targetHeight, { fit: "fill" })
      .raw()
      .toBuffer({ resolveWithObject: true });
 
    // 确保通道数一致
    if (origResized.info.channels !== compResized.info.channels) {
      throw new Error("图像通道数不匹配");
    }
 
    const width = origResized.info.width;
    const height = origResized.info.height;
    const channels = origResized.info.channels;
    const origData = origResized.data;
    const compData = compResized.data;
    let sumSquaredError = 0;
 
    // 逐像素比较 RGB
    for (let i = 0; i < origData.length; i += channels) {
      for (let c = 0; c < channels; c++) {
        const diff = origData[i + c] - compData[i + c];
        sumSquaredError += diff * diff;
      }
    }
 
    // 计算均方误差(MSE)
    const mse = sumSquaredError / (width * height * channels);
 
    // 计算 PSNR
    const maxPixelValue = 255; // 8-bit 图像
    const psnr =
      mse === 0 ? Infinity : 10 * Math.log10(maxPixelValue ** 2 / mse);
 
    console.log(`MSE: ${mse.toFixed(2)}`);
    console.log(`PSNR: ${psnr === Infinity ? "Infinity" : psnr.toFixed(2)} dB`);
    return psnr;
  } catch (error) {
    console.error("错误:", error.message);
  }
}
 
// 示例用法
calculatePSNR("./original.jpg", "./compressed.webp");

运行以上代码的输出结果是:

MSE: 21.63
PSNR: 34.78 dB

这个结果在预期内,一般来说,PSNR 高于 40 是极好,高于 30 可以接受(有失真,但人眼很难察觉),20-30 则比较差,低于 20 不可接受。

不过,由于我不仅对照片做了压缩处理,我还调整了照片的尺寸。换句话说,以上代码测试的是我将原始照片调整到和缩略图一样的尺寸之后,再比较的两者的压缩质量。这里得到的值有一定的参考价值,虽然它无法 100% 反映原始的压缩质量,但我不打算再深究更完美的测量方法。

不足和总结

当前方案受限于用户设备性能和网络条件,仅生成两种固定尺寸的照片,灵活性不够。例如,不能根据网络状况动态调整压缩质量,或为高分辨率设备提供更大尺寸的图片。

使用 Jimp 其实是妥协而不是选择,之后需要探索是否有更适合的浏览器压缩办法,例如当前出现了一些使用 WebGPU 的方案,我觉得值得尝试。其次,我相信它在同时处理多个照片时会遇到瓶颈,前文的测试是在 Node.js 环境中(未到瓶颈但已经花费了大量内存),而在浏览器中我还未测试这个数值。

如果改为服务端处理,我们可以动态调整照片尺寸和质量、加入权限控制和防盗链(如果有必要),结合 CDN 和中间件(如 imgproxy、Thumbor)优化性能,实现为网络条件较好的用户提供更高质量的照片,而为网络条件不好的用户优化加载速度。虽然 flic.cc 只是个小产品,但是以小见大,可以窥见很多大规模的产品会遇到的问题,这些问题值得思考。