Next.js と ThumbHash で、画像のファイル名に placeholder を埋め込む
next-thumbhash
Next.js で ThumbHash を利用して、画像の読み込み体験を向上させるサンプルです。ハッシュ値はファイル名に埋め込むため、リモートの画像でも事前に placeholder 用画像をよういすることが出来ます。
リポジトリはこちらです
https://github.com/SoraKumo001/next-thumbhash
概要
このプロジェクトは、Next.js アプリケーションで ThumbHash を使用する方法を示しています。ThumbHash は、非常に小さなプレースホルダー画像(ハッシュ)を生成し、実際の画像が読み込まれるまでの間に表示することで、ユーザー体験を向上させます。これにより、画像の遅延読み込み時に発生するレイアウトシフトを軽減し、コンテンツの表示をスムーズにします。
デモはこちらで確認できます: https://next-thumbhash.vercel.app/
![]()
効果の確認方法:
開発モードでブラウザのキャッシュを無効化し、ネットワークの通信速度を意図的に遅くすることで、ThumbHash の効果をより明確に確認できます。
セットアップと実行方法
このプロジェクトをローカルでセットアップし、実行する手順は以下の通りです。
リポジトリのクローン:
git clone https://github.com/SoraKumo001/next-thumbhash.git cd next-thumbhash依存関係のインストール:
pnpm を使用して依存関係をインストールします。pnpm installThumbHash の生成とファイル名への埋め込み:
bin/convert.ts スクリプトを実行して、images ディレクトリ内の画像から ThumbHash を生成し、そのハッシュ値をファイル名に埋め込んだ新しい画像を public ディレクトリに保存します。pnpm tsx bin/convert.tsこのコマンドを実行すると、以下のような出力が表示され、public フォルダにハッシュ値が埋め込まれたファイルが作成されます。
image02.jpg -> image02-[YTgKFwb2eHiLeGd5Z0d3h5eHVlAGB3MD].jpg image01.jpg -> image01-[1NYFDwQga3p4h5aheDaoKGl4xKCIT4kM].jpg image04.jpg -> image04-[l1cKJwoVaWhgmXWgWWiLNod4lwOGeXAH].jpg image03.jpg -> image03-[abcFPwqD7WSvi2VwhiVzxamSmQRnOXEA].jpg開発サーバーの起動:
Next.js 開発サーバーを起動します。pnpm devブラウザで確認:
ブラウザで http://localhost:3000 にアクセスし、アプリケーションを確認します。
技術的な詳細
ファイル名に ThumbHash を埋め込む (bin/convert.ts)
wasm-image-optimization ライブラリを使用して、画像の ThumbHash 値を計算します。計算されたハッシュ値はバイナリデータであるため、ファイル名に含めるために Base64 エンコードし、さらにファイル名で安全に使える文字列に変換しています(+ を - に、/ を _ に置換し、末尾の = を除去)。
このスクリプトは CLI から実行されますが、実際のプロダクション環境では、画像をアップロードする際にサーバーサイドやブラウザで同様の処理を行い、ハッシュ値をファイル名に埋め込むのが一般的です。
ちなみにwasm-image-optimizationではなくthumbhashのパッケージでもハッシュ値の計算は可能ですが、画像ファイルのデコードやサイズ変換の作業が必要になるので、CLI で行う時は前者のパッケージを使ったほうが楽ができます。
import { promises as fs } from "fs";
import path from "path";
import { optimizeImage } from "wasm-image-optimization";
// ArrayBufferまたはUint8ArrayをBase64文字列に変換するヘルパー関数
export const arrayBufferToBase64 = (
buffer: ArrayBuffer | Uint8Array<ArrayBufferLike>
): string => {
const bytes = new Uint8Array(buffer);
const binary = Array.from(bytes).map((byte) => String.fromCharCode(byte));
return btoa(binary.join(""));
};
const main = async () => {
// publicディレクトリが存在しない場合は作成
await fs.mkdir("./public", { recursive: true });
// imagesディレクトリ内のファイルを読み込む
const files = await fs.readdir("./images");
for (const fileName of files) {
fs.readFile(`./images/${fileName}`).then(async (image) => {
// wasm-image-optimization を使ってThumbHashを生成
const hash = await optimizeImage({
image,
width: 100, // ThumbHash生成のためのリサイズサイズ
height: 100,
format: "thumbhash",
});
if (hash) {
// 生成されたThumbHashをファイル名に埋め込むための文字列に変換
// 1. Base64に変換
// 2. URLセーフな文字列にするため、`+`を`-`に、`/`を`_`に置換
// 3. 末尾のパディング文字`=`を除去
const hashString = arrayBufferToBase64(hash)
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
const p = path.parse(fileName);
const newFileName = `${p.name}-[${hashString}]${p.ext}`;
console.log(`${fileName} -> ${newFileName}`);
// ハッシュ値が埋め込まれた新しいファイル名で画像をpublicディレクトリに保存
await fs.writeFile(`./public/${p.name}-[${hashString}]${p.ext}`, image);
}
});
}
};
main();ファイル名から ThumbHash を復元して blurDataURL に設定 (src/app/page.tsx)
ThumbhashImage コンポーネントは、ファイル名に埋め込まれた ThumbHash 文字列を抽出し、それを元のバイナリハッシュに復元します。その後、thumbhashToDataURL 関数を使用して、このハッシュから Data URL 形式の低解像度プレースホルダー画像を生成し、Next.js の Image コンポーネントの blurDataURL プロパティに設定します。
これにより、画像が読み込まれるまでの間、ThumbHash によって生成されたぼやけたプレースホルダーが表示され、ユーザー体験が向上します。
import React, { useMemo } from "react";
import { thumbHashToDataURL } from "thumbhash";
import Image from "next/image";
// Base64文字列をUint8Arrayに変換するヘルパー関数
function base64ToUint8Array(base64Str: string) {
const raw = atob(base64Str);
return Uint8Array.from(
Array.prototype.map.call(raw, (x) => {
return x.charCodeAt(0);
})
);
}
const ThumbhashImage = ({ src }: { src: string }) => {
const hashUrl = useMemo(() => {
// ファイル名からハッシュのもとになる文字列(例: `-[HASH_STRING]`)を取り出す
const hashString = src.match(/-\[(.*?)\]/)?.[1];
if (!hashString) return undefined;
// URLセーフな文字列から元のBase64形式に復元
// 1. `-`を`+`に、`_`を`/`に置換
// 2. 除去したパディング文字`=`を復元(Base64の長さが4の倍数になるように調整)
const hashBase64 =
hashString.replace(/-/g, "+").replace(/_/g, "/") +
"==".slice(0, (3 * hashString.length) % 4);
// Base64文字列からUint8Array形式のThumbHashバイナリデータへ変換
const hash = base64ToUint8Array(hashBase64);
// ThumbHashバイナリデータからData URL形式の無圧縮PNG画像を生成
return thumbHashToDataURL(hash);
}, [src]);
return (
<Image
src={src}
alt=""
width={300}
height={300}
placeholder="blur" // プレースホルダーとしてblurDataURLを使用することを指定
blurDataURL={hashUrl} // 生成したThumbHashのData URLを設定
/>
);
};
export default function HomePage() {
return (
<div className="flex flex-wrap gap-2 justify-center m-4">
<ThumbhashImage src="/image01-[1NYFDwQga3p4h5aheDaoKGl4xKCIT4kM].jpg" />
<ThumbhashImage src="/image02-[YTgKFwb2eHiLeGd5Z0d3h5eHVlAGB3MD].jpg" />
<ThumbhashImage src="/image03-[abcFPwqD7WSvi2VwhiVzxamSmQRnOXEA].jpg" />
<ThumbhashImage src="/image04-[l1cKJwoVaWhgmXWgWWiLNod4lwOGeXAH].jpg" />
</div>
);
}まとめ
画像ファイル名にhashを埋め込むことによって、SSR時に高速なplaceholder用の画像の生成が可能になりました。手法としては非常に単純なので、実装も簡単です。機会があればぜひやってみてください。