
为 Next.js 静态导出实现图片优化
Skyone
科技爱好者
如你所见,我的 Blog 是基于 Next.js 开发的,使用的是静态导出扔到 Cloudflare Pages,省心省事。
我不使用图床,习惯将图片和文章一起在同一个仓库里管理。然而 Next.js 的图片优化在静态导出模式下是不可用的。 后端是纯静态的,确实无法在运行时生成适合浏览器的图片。
但图片优化真的不能做吗?显然不是,下面我将分享我的做法。
优化思路
既然是静态构建,没有运行时,那就换一种思路:我们可用通过暴力的方法,在构建阶段将所有可能用到的图片类型全部构建好, 前端选择最合适的图片显示。


构建阶段
我们先梳理一下 next.js 官方的图片优化实现:next-image-loader。
这是一个兼容 webpack 和 turbopack 的插件。它做的工作非常简单:当产生 import "xxx.png" 导入图片时,
告诉 webpack 如何将二进制的图片映射为合法的 JS 代码。
具体来说,next-image-loader 并不直接处理图片,而是根据图片的路径,生成形如 /_next/image?url= 的链接,
运行时处理 /_next/image api,动态优化和缓存图片。
那么思路打开,我们自行实现自己的 next-image-loader,在构建阶段真正的、优化后的图片,并生成形如 /_next/image/xxxx.avif
(也就是前面构建的图片)。
/**
* @param buffer {Buffer}
* @returns {Promise<string>}
*/
async function loader(buffer) {
const callback = this.async();
const {compilerType, isDev, assetPrefix} = this.getOptions();
const resourcePath = this.resourcePath;
}
webpack 的 loader 其实非常简单,接受源文件的内容,输出 JS 代码。现在我们有了原始图片的 Buffer,实现一个图片优化器
const optimizer = async (format, buffer, option = undefined) => {
const optimized = await sharp(buffer, option)[format]().toBuffer();
if (compilerType === "client") {
const output = `${interpolatedName}.${format}`;
this.emitFile(output, optimized, null);
} else {
const output = path.join(
"..",
isDev || compilerType === "edge-server" ? "" : "..",
`${interpolatedName}.${format}`,
);
this.emitFile(output, optimized, null);
}
return `${outputPath}.${format}`;
};
就是简单的使用 sharp.js 转换图片格式,然后生成对应格式的输出文件的路径。
可以看到,上面的代码里有一串计算最终图片位置的判断。老实说我也看不懂这是什么玩意,我只是根据原版
next-image-loader 抄来的。
最后组装一下代码,动图生成 webp + gif,静态图片提供 avif + webp + png。
至于文件名称,考虑到后续更改文件方便,我采用了文件路径 hash + 文件内容 hash 作为命名。
async function interpolateName(filepath, buffer) {
const pathDigest = crypto.createHash("sha256")
.update(Buffer.from(filepath, "utf-8"))
.digest("hex")
.slice(0, 8);
const digest = crypto.createHash("sha256")
.update(buffer)
.digest("hex")
.slice(0, 8);
return `/static/media/${pathDigest}_${digest}`;
}
点击查看完整代码
import crypto from "crypto"; import isAnimated from "is-animated"; import path from "path"; import sharp from "sharp"; async function interpolateName(filepath, buffer) { const pathDigest = crypto.createHash("sha256") .update(Buffer.from(filepath, "utf-8")) .digest("hex") .slice(0, 8); const digest = crypto.createHash("sha256") .update(buffer) .digest("hex") .slice(0, 8); return `/static/media/${pathDigest}_${digest}`; } const BLUR_IMG_SIZE = 8; async function getBlurImage(image, metadata) { let blurWidth; let blurHeight; if (metadata.width >= metadata.height) { blurWidth = BLUR_IMG_SIZE; blurHeight = Math.max(Math.round(metadata.height / metadata.width * BLUR_IMG_SIZE), 1); } else { blurWidth = Math.max(Math.round(metadata.width / metadata.height * BLUR_IMG_SIZE), 1); blurHeight = BLUR_IMG_SIZE; } const blur = await image .resize({ width: blurWidth, height: blurHeight, }) .blur() .png() .toBuffer(); const dataURL = `data:image/png;base64,${blur.toString("base64")}`; return { dataURL, width: blurWidth, height: blurHeight, }; } async function loader(buffer) { const callback = this.async(); const {compilerType, isDev, assetPrefix} = this.getOptions(); const resourcePath = this.resourcePath; const interpolatedName = await interpolateName(resourcePath, buffer); const outputPath = assetPrefix + "/_next" + interpolatedName; const metadata = await sharp(buffer).metadata(); const blur = await getBlurImage(sharp(buffer), metadata); const optimizer = async (format, buffer, option = undefined) => { const optimized = await sharp(buffer, option)[format]().toBuffer(); if (compilerType === "client") { const output = `${interpolatedName}.${format}`; this.emitFile(output, optimized, null); } else { const output = path.join( "..", isDev || compilerType === "edge-server" ? "" : "..", `${interpolatedName}.${format}`, ); this.emitFile(output, optimized, null); } return `${outputPath}.${format}`; }; const srcset = []; if (isAnimated(buffer)) { for (const format of ["webp", "gif"]) { srcset.push({ minetype: `image/${format}`, src: await optimizer(format, buffer, {animated: true}), }); } } else { for (const format of ["avif", "webp", "png"]) { srcset.push({ minetype: `image/${format}`, src: await optimizer(format, buffer), }); } } const exportData = JSON.stringify({ src: `${outputPath}.webp`, height: metadata.height, width: metadata.width, blurDataURL: blur.dataURL, blurWidth: blur.width, blurHeight: blur.height, srcset: srcset, }); return callback(null, `export default ${exportData}`); } export const raw = true; export default loader;
然后配置 next.js 以使用我们自定义的 loader :
const nextConfig: NextConfig = {
// ... others
webpack(config, context) {
const imageLoader = config.module.rules.find((r: any) => r.loader === "next-image-loader");
if (imageLoader) {
imageLoader.loader = require.resolve("./plugins/image/loader.mjs");
}
return config;
},
};
类型定义
仅仅配置好 webpack 可不够,TypeScript 并不知道我们改写了导入图片时的行为,我们需要覆盖 next.js 的类型定义,以 png 为例:
declare module "*.png" {
export type SrcSetRecord = {
mimetype: string;
src: string;
}
export type SrcSet = Array<SrcSetRecord>
export type LoaderImage = {
src: string;
width: number;
height: number;
blurDataURL: string;
blurWidth: number;
blurHeight: number;
srcset: SrcSet;
}
const image: LoaderImage;
export default image;
}
前端部分
现代浏览器给了很方便的 <picture> 和 <source> 标签。
<picture>
<source type="image/avif" src="/img/example.avif">
<source type="image/webp" src="/img/example.webp">
<img alt="example" src="/img/example.png">
</picture>
简单来说,浏览器会从上往下尝试 source[@type],找到第一个支持的格式,使用该格式的图片替换 img 标签的 src。
这样就实现了自动使用合适的图片格式。
此外,source 标签还支持根据媒体查询进行匹配,找到最适合屏幕的图片尺寸,但实际上收益不大,我没有用这个功能。
interface PictureProps extends DetailedHTMLProps<ImgHTMLAttributes<HTMLImageElement>, HTMLImageElement> {
srcset: SrcSet;
}
function Picture({srcset, ...other}: PictureProps) {
const optimized = [...srcset] as SrcSet;
const origin = optimized.pop()!;
return (
<picture>
{optimized.map(({mimetype, src}) => (
<source key={mimetype} type={mimetype} srcSet={src}/>
))}
<img
{...other}
src={origin.src}/>
</picture>
);
}
结束
非常简单的一个实现,不清楚为什么 next.js 不不支持,也许是为了推 Vercel 的 edge function 服务?