空雲 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 の効果をより明確に確認できます。

セットアップと実行方法

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

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

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

    pnpm install
  • ThumbHash の生成とファイル名への埋め込み:
    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用の画像の生成が可能になりました。手法としては非常に単純なので、実装も簡単です。機会があればぜひやってみてください。