Logo
“为 Next.js 静态导出实现图片优化”的封面

为 Next.js 静态导出实现图片优化

Avatar

Skyone

科技爱好者

如你所见,我的 Blog 是基于 Next.js 开发的,使用的是静态导出扔到 Cloudflare Pages,省心省事。

我不使用图床,习惯将图片和文章一起在同一个仓库里管理。然而 Next.js 的图片优化在静态导出模式下是不可用的。 后端是纯静态的,确实无法在运行时生成适合浏览器的图片。

但图片优化真的不能做吗?显然不是,下面我将分享我的做法。

优化思路

既然是静态构建,没有运行时,那就换一种思路:我们可用通过暴力的方法,在构建阶段将所有可能用到的图片类型全部构建好, 前端选择最合适的图片显示。

HTML 示例

输出文件示例

构建阶段

我们先梳理一下 next.js 官方的图片优化实现:next-image-loader

这是一个兼容 webpackturbopack 的插件。它做的工作非常简单:当产生 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 服务?

为 Next.js 静态导出实现图片优化

https://blog.skyone.dev/2026/nextjs-static-image-optimization/

本文作者

Skyone

发布于

2026年7月1日

许可协议

CC BY-NC-SA 4.0

转载或引用本文时请遵守许可协议,注明出处、不得用于商业用途!


隐私政策

Copyright © Skyone2026