空雲 Blog

Eye catchNext.js と ThumbHash で、画像のファイル名に placeholder を埋め込む

publication: 2025/10/04
update:2025/10/04

next-thumbhash

Next.js で ThumbHash を利用して、画像の読み込み体験を向上させるサンプルです。ハッシュ値はファイル名に埋め込むため、リモートの画像でも事前に placeholder 用画像をよういすることが出来ます。

リポジトリはこちらです
https://github.com/SoraKumo001/next-thumbhash

概要

このプロジェクトは、Next.js アプリケーションで ThumbHash を使用する方法を示しています。ThumbHash は、非常に小さなプレースホルダー画像(ハッシュ)を生成し、実際の画像が読み込まれるまでの間に表示することで、ユーザー体験を向上させます。これにより、画像の遅延読み込み時に発生するレイアウトシフトを軽減し、コンテンツの表示をスムーズにします。

デモはこちらで確認できます: https://next-thumbhash.vercel.app/

効果の確認方法:
開発モードでブラウザのキャッシュを無効化し、ネットワークの通信速度を意図的に遅くすることで、ThumbHash の効果をより明確に確認できます。

セットアップと実行方法

このプロジェクトをローカルでセットアップし、実行する手順は以下の通りです。

  • リポジトリのクローン:

    1git clone https://github.com/SoraKumo001/next-thumbhash.git
    2cd next-thumbhash
  • 依存関係のインストール:
    pnpm を使用して依存関係をインストールします。

    1pnpm install
  • ThumbHash の生成とファイル名への埋め込み:
    bin/convert.ts スクリプトを実行して、images ディレクトリ内の画像から ThumbHash を生成し、そのハッシュ値をファイル名に埋め込んだ新しい画像を public ディレクトリに保存します。

    1pnpm tsx bin/convert.ts

    このコマンドを実行すると、以下のような出力が表示され、public フォルダにハッシュ値が埋め込まれたファイルが作成されます。

    1image02.jpg -> image02-[YTgKFwb2eHiLeGd5Z0d3h5eHVlAGB3MD].jpg
    2image01.jpg -> image01-[1NYFDwQga3p4h5aheDaoKGl4xKCIT4kM].jpg
    3image04.jpg -> image04-[l1cKJwoVaWhgmXWgWWiLNod4lwOGeXAH].jpg
    4image03.jpg -> image03-[abcFPwqD7WSvi2VwhiVzxamSmQRnOXEA].jpg
  • 開発サーバーの起動:
    Next.js 開発サーバーを起動します。

    1pnpm dev
  • ブラウザで確認:
    ブラウザで http://localhost:3000 にアクセスし、アプリケーションを確認します。

技術的な詳細

ファイル名に ThumbHash を埋め込む (bin/convert.ts)

wasm-image-optimization ライブラリを使用して、画像の ThumbHash 値を計算します。計算されたハッシュ値はバイナリデータであるため、ファイル名に含めるために Base64 エンコードし、さらにファイル名で安全に使える文字列に変換しています(+- に、/_ に置換し、末尾の = を除去)。

このスクリプトは CLI から実行されますが、実際のプロダクション環境では、画像をアップロードする際にサーバーサイドやブラウザで同様の処理を行い、ハッシュ値をファイル名に埋め込むのが一般的です。

ちなみにwasm-image-optimizationではなくthumbhashのパッケージでもハッシュ値の計算は可能ですが、画像ファイルのデコードやサイズ変換の作業が必要になるので、CLI で行う時は前者のパッケージを使ったほうが楽ができます。

1import { promises as fs } from "fs";
2import path from "path";
3import { optimizeImage } from "wasm-image-optimization";
4
5// ArrayBufferまたはUint8ArrayをBase64文字列に変換するヘルパー関数
6export const arrayBufferToBase64 = (
7 buffer: ArrayBuffer | Uint8Array<ArrayBufferLike>
8): string => {
9 const bytes = new Uint8Array(buffer);
10 const binary = Array.from(bytes).map((byte) => String.fromCharCode(byte));
11 return btoa(binary.join(""));
12};
13
14const main = async () => {
15 // publicディレクトリが存在しない場合は作成
16 await fs.mkdir("./public", { recursive: true });
17 // imagesディレクトリ内のファイルを読み込む
18 const files = await fs.readdir("./images");
19 for (const fileName of files) {
20 fs.readFile(`./images/${fileName}`).then(async (image) => {
21 // wasm-image-optimization を使ってThumbHashを生成
22 const hash = await optimizeImage({
23 image,
24 width: 100, // ThumbHash生成のためのリサイズサイズ
25 height: 100,
26 format: "thumbhash",
27 });
28 if (hash) {
29 // 生成されたThumbHashをファイル名に埋め込むための文字列に変換
30 // 1. Base64に変換
31 // 2. URLセーフな文字列にするため、`+`を`-`に、`/`を`_`に置換
32 // 3. 末尾のパディング文字`=`を除去
33 const hashString = arrayBufferToBase64(hash)
34 .replace(/\+/g, "-")
35 .replace(/\//g, "_")
36 .replace(/=+$/, "");
37 const p = path.parse(fileName);
38 const newFileName = `${p.name}-[${hashString}]${p.ext}`;
39 console.log(`${fileName} -> ${newFileName}`);
40 // ハッシュ値が埋め込まれた新しいファイル名で画像をpublicディレクトリに保存
41 await fs.writeFile(`./public/${p.name}-[${hashString}]${p.ext}`, image);
42 }
43 });
44 }
45};
46
47main();

ファイル名から ThumbHash を復元して blurDataURL に設定 (src/app/page.tsx)

ThumbhashImage コンポーネントは、ファイル名に埋め込まれた ThumbHash 文字列を抽出し、それを元のバイナリハッシュに復元します。その後、thumbhashToDataURL 関数を使用して、このハッシュから Data URL 形式の低解像度プレースホルダー画像を生成し、Next.js の Image コンポーネントの blurDataURL プロパティに設定します。

これにより、画像が読み込まれるまでの間、ThumbHash によって生成されたぼやけたプレースホルダーが表示され、ユーザー体験が向上します。

1import React, { useMemo } from "react";
2import { thumbHashToDataURL } from "thumbhash";
3import Image from "next/image";
4
5// Base64文字列をUint8Arrayに変換するヘルパー関数
6function base64ToUint8Array(base64Str: string) {
7 const raw = atob(base64Str);
8 return Uint8Array.from(
9 Array.prototype.map.call(raw, (x) => {
10 return x.charCodeAt(0);
11 })
12 );
13}
14
15const ThumbhashImage = ({ src }: { src: string }) => {
16 const hashUrl = useMemo(() => {
17 // ファイル名からハッシュのもとになる文字列(例: `-[HASH_STRING]`)を取り出す
18 const hashString = src.match(/-\[(.*?)\]/)?.[1];
19 if (!hashString) return undefined;
20
21 // URLセーフな文字列から元のBase64形式に復元
22 // 1. `-`を`+`に、`_`を`/`に置換
23 // 2. 除去したパディング文字`=`を復元(Base64の長さが4の倍数になるように調整)
24 const hashBase64 =
25 hashString.replace(/-/g, "+").replace(/_/g, "/") +
26 "==".slice(0, (3 * hashString.length) % 4);
27
28 // Base64文字列からUint8Array形式のThumbHashバイナリデータへ変換
29 const hash = base64ToUint8Array(hashBase64);
30
31 // ThumbHashバイナリデータからData URL形式の無圧縮PNG画像を生成
32 return thumbHashToDataURL(hash);
33 }, [src]);
34
35 return (
36 <Image
37 src={src}
38 alt=""
39 width={300}
40 height={300}
41 placeholder="blur" // プレースホルダーとしてblurDataURLを使用することを指定
42 blurDataURL={hashUrl} // 生成したThumbHashのData URLを設定
43 />
44 );
45};
46
47export default function HomePage() {
48 return (
49 <div className="flex flex-wrap gap-2 justify-center m-4">
50 <ThumbhashImage src="/image01-[1NYFDwQga3p4h5aheDaoKGl4xKCIT4kM].jpg" />
51 <ThumbhashImage src="/image02-[YTgKFwb2eHiLeGd5Z0d3h5eHVlAGB3MD].jpg" />
52 <ThumbhashImage src="/image03-[abcFPwqD7WSvi2VwhiVzxamSmQRnOXEA].jpg" />
53 <ThumbhashImage src="/image04-[l1cKJwoVaWhgmXWgWWiLNod4lwOGeXAH].jpg" />
54 </div>
55 );
56}

まとめ

画像ファイル名にhashを埋め込むことによって、SSR時に高速なplaceholder用の画像の生成が可能になりました。手法としては非常に単純なので、実装も簡単です。機会があればぜひやってみてください。