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 の効果をより明確に確認できます。
セットアップと実行方法
このプロジェクトをローカルでセットアップし、実行する手順は以下の通りです。
リポジトリのクローン:
1git clone https://github.com/SoraKumo001/next-thumbhash.git2cd next-thumbhash依存関係のインストール:
pnpm を使用して依存関係をインストールします。1pnpm installThumbHash の生成とファイル名への埋め込み:
bin/convert.ts スクリプトを実行して、images ディレクトリ内の画像から ThumbHash を生成し、そのハッシュ値をファイル名に埋め込んだ新しい画像を public ディレクトリに保存します。1pnpm tsx bin/convert.tsこのコマンドを実行すると、以下のような出力が表示され、public フォルダにハッシュ値が埋め込まれたファイルが作成されます。
1image02.jpg -> image02-[YTgKFwb2eHiLeGd5Z0d3h5eHVlAGB3MD].jpg2image01.jpg -> image01-[1NYFDwQga3p4h5aheDaoKGl4xKCIT4kM].jpg3image04.jpg -> image04-[l1cKJwoVaWhgmXWgWWiLNod4lwOGeXAH].jpg4image03.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";45// 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};1314const 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};4647main();
ファイル名から 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";45// 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}1415const ThumbhashImage = ({ src }: { src: string }) => {16 const hashUrl = useMemo(() => {17 // ファイル名からハッシュのもとになる文字列(例: `-[HASH_STRING]`)を取り出す18 const hashString = src.match(/-\[(.*?)\]/)?.[1];19 if (!hashString) return undefined;2021 // URLセーフな文字列から元のBase64形式に復元22 // 1. `-`を`+`に、`_`を`/`に置換23 // 2. 除去したパディング文字`=`を復元(Base64の長さが4の倍数になるように調整)24 const hashBase64 =25 hashString.replace(/-/g, "+").replace(/_/g, "/") +26 "==".slice(0, (3 * hashString.length) % 4);2728 // Base64文字列からUint8Array形式のThumbHashバイナリデータへ変換29 const hash = base64ToUint8Array(hashBase64);3031 // ThumbHashバイナリデータからData URL形式の無圧縮PNG画像を生成32 return thumbHashToDataURL(hash);33 }, [src]);3435 return (36 <Image37 src={src}38 alt=""39 width={300}40 height={300}41 placeholder="blur" // プレースホルダーとしてblurDataURLを使用することを指定42 blurDataURL={hashUrl} // 生成したThumbHashのData URLを設定43 />44 );45};4647export 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用の画像の生成が可能になりました。手法としては非常に単純なので、実装も簡単です。機会があればぜひやってみてください。