プレースホルダー設計 LQIP/SQIP/BlurHash の実践 2025
公開: 2025年9月19日 · 読了目安: 8 分 · 著者: Unified Image Tools 編集部
プレースホルダー設計 LQIP/SQIP/BlurHash の実践 2025
ユーザー体験を大きく左右するのが、画像読み込み中の「待ち」の演出です。適切なプレースホルダーは CLS(Cumulative Layout Shift) を防ぎ、知覚的な読み込み速度を向上させる重要な技術要素です。本稿では、LQIP/SQIP/BlurHash の特徴を詳しく比較し、実装方法から運用上の注意点まで、実践的な導入ガイドを提供します。
プレースホルダーの重要性と効果
Core Web Vitals への影響
現代の Web 開発では、単なる読み込み速度だけでなく、ユーザーが感じる体験の質が重要視されています。
CLS(累積レイアウトシフト)の防止
- 画像の寸法を事前に確保し、読み込み完了時のレイアウト崩れを防止
- Google の検索ランキング要因として評価される重要指標
LCP(Largest Contentful Paint)の知覚改善
- 実際の読み込み完了前に意味のあるコンテンツを表示
- ユーザーの離脱率を大幅に削減
First Impression の最適化
- プレースホルダーの品質が、サイト全体の印象を左右
- 特にモバイル環境や低速回線での効果が顕著
心理的効果とユーザビリティ
// プレースホルダーの心理的効果を測定する例
const performanceObserver = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.entryType === 'paint' && entry.name === 'first-contentful-paint') {
console.log('FCP:', entry.startTime);
// プレースホルダー表示からの経過時間
const placeholderDuration = entry.startTime - window.placeholderStartTime;
analytics.track('placeholder_effectiveness', {
duration: placeholderDuration,
technique: 'lqip' // または 'sqip', 'blurhash'
});
}
}
});
performanceObserver.observe({ entryTypes: ['paint'] });
主要技術の詳細比較
LQIP(Low Quality Image Placeholder)
基本概念: 元画像を極度に縮小・圧縮した低品質版をプレースホルダーとして使用
// Node.js での LQIP 生成例
import sharp from 'sharp';
async function generateLQIP(inputPath, outputPath, quality = 10) {
const metadata = await sharp(inputPath).metadata();
// アスペクト比を保持して32px幅にリサイズ
const lqip = await sharp(inputPath)
.resize(32, Math.round(32 * metadata.height / metadata.width))
.jpeg({
quality: quality,
progressive: true,
mozjpeg: true // より効率的な圧縮
})
.toBuffer();
// Base64 エンコードして埋め込み可能な形式に
const base64 = `data:image/jpeg;base64,${lqip.toString('base64')}`;
return {
width: metadata.width,
height: metadata.height,
placeholder: base64,
size: lqip.length
};
}
// 使用例
const lqipData = await generateLQIP('hero-image.jpg', null, 15);
console.log(`プレースホルダーサイズ: ${lqipData.size} bytes`);
最適化テクニック:
- 適応的品質調整: 画像の複雑さに応じて圧縮品質を動的変更
- 色数制限: 256色未満に減色してサイズ削減
- エッジ強調: ぼかし処理前に軽いシャープネスを適用
// 高度な LQIP 生成
async function advancedLQIP(inputPath, targetSize = 800) {
const image = sharp(inputPath);
const metadata = await image.metadata();
// 画像の複雑さを評価(エッジ数で近似)
const edges = await image
.clone()
.resize(100, 100)
.greyscale()
.convolve({
width: 3,
height: 3,
kernel: [-1, -1, -1, -1, 8, -1, -1, -1, -1]
})
.raw()
.toBuffer();
const complexity = edges.reduce((sum, val) => sum + val, 0) / edges.length;
// 複雑さに応じて品質を調整
const quality = Math.max(5, Math.min(25, 10 + complexity / 10));
const lqip = await image
.resize(20, Math.round(20 * metadata.height / metadata.width))
.jpeg({ quality: Math.round(quality) })
.toBuffer();
return {
placeholder: `data:image/jpeg;base64,${lqip.toString('base64')}`,
complexity: complexity,
quality: quality
};
}
適用場面: 写真、自然画像、複雑なテクスチャを持つ画像
SQIP(SVG-based Image Placeholder)
基本概念: 画像を幾何学的プリミティブ(円、多角形)で近似し、SVG として表現
// SQIP の基本実装(simplified)
import { spawn } from 'child_process';
import { writeFile, readFile } from 'fs/promises';
async function generateSQIP(inputPath, primitiveCount = 10) {
// primitive を使用してSVG生成
return new Promise((resolve, reject) => {
const primitive = spawn('primitive', [
'-i', inputPath,
'-o', 'temp.svg',
'-n', primitiveCount.toString(),
'-m', '1' // mode: triangles
]);
primitive.on('close', async (code) => {
if (code === 0) {
const svgContent = await readFile('temp.svg', 'utf8');
// SVG を最適化
const optimizedSVG = svgContent
.replace(/\s+/g, ' ')
.replace(/>\s+</g, '><')
.trim();
resolve({
placeholder: `data:image/svg+xml;base64,${Buffer.from(optimizedSVG).toString('base64')}`,
primitives: primitiveCount,
size: optimizedSVG.length
});
} else {
reject(new Error(`SQIP generation failed with code ${code}`));
}
});
});
}
// より制御しやすい JavaScript 実装
class TriangleSQIP {
constructor(imageData, width, height) {
this.imageData = imageData;
this.width = width;
this.height = height;
}
generateTriangles(count = 20) {
const triangles = [];
for (let i = 0; i < count; i++) {
// K-means クラスタリングで色を抽出
const dominantColor = this.extractDominantColor();
// 三角形の頂点をランダム配置(画像の明度分布を考慮)
const triangle = this.generateTriangle(dominantColor);
triangles.push(triangle);
}
return this.trianglesToSVG(triangles);
}
trianglesToSVG(triangles) {
const svgElements = triangles.map(t =>
`<polygon points="${t.points.join(',')}" fill="${t.color}"/>`
).join('');
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${this.width} ${this.height}">${svgElements}</svg>`;
}
}
パフォーマンス最適化:
- プリミティブ数の最適化: 8-15個が容量と品質のバランス点
- 色パレット制限: 主要色3-5色に限定
- SVG 圧縮: 不要な属性削除と座標の丸め
適用場面: ロゴ、アイコン、幾何学的な要素が多い画像
BlurHash
基本概念: 画像をコンパクトな文字列ハッシュで表現し、クライアント側で近似画像を復元
// BlurHash 生成(TypeScript)
import { encode, decode } from 'blurhash';
import sharp from 'sharp';
interface BlurHashOptions {
componentX?: number;
componentY?: number;
quality?: number;
}
async function generateBlurHash(
imagePath: string,
options: BlurHashOptions = {}
): Promise<{hash: string; width: number; height: number}> {
const { componentX = 4, componentY = 3, quality = 1 } = options;
// 画像を32x32程度にリサイズして処理負荷を軽減
const { data, info } = await sharp(imagePath)
.resize(32, 32, { fit: 'inside' })
.ensureAlpha()
.raw()
.toBuffer({ resolveWithObject: true });
// BlurHash エンコード
const hash = encode(
new Uint8ClampedArray(data),
info.width,
info.height,
componentX,
componentY
);
return {
hash,
width: info.width,
height: info.height
};
}
// クライアント側での復元
function createBlurHashCanvas(
hash: string,
width: number,
height: number
): HTMLCanvasElement {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d')!;
canvas.width = width;
canvas.height = height;
// BlurHash をデコードして ImageData に変換
const pixels = decode(hash, width, height);
const imageData = new ImageData(
new Uint8ClampedArray(pixels),
width,
height
);
ctx.putImageData(imageData, 0, 0);
return canvas;
}
// React での使用例
function BlurHashImage({ src, hash, width, height, alt }) {
const [loaded, setLoaded] = useState(false);
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
if (canvasRef.current && hash) {
const canvas = createBlurHashCanvas(hash, width, height);
canvasRef.current.width = canvas.width;
canvasRef.current.height = canvas.height;
const ctx = canvasRef.current.getContext('2d')!;
ctx.drawImage(canvas, 0, 0);
}
}, [hash, width, height]);
return (
<div style={{ position: 'relative', width, height }}>
{!loaded && (
<canvas
ref={canvasRef}
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%' }}
/>
)}
<img
src={src}
alt={alt}
style={{
opacity: loaded ? 1 : 0,
transition: 'opacity 0.3s ease-in-out'
}}
onLoad={() => setLoaded(true)}
/>
</div>
);
}
調整パラメータ:
- ComponentX/Y: 水平/垂直方向の周波数成分数(4x3が標準)
- ハッシュ長: 通常20-30文字、複雑な画像では40文字程度まで
適用場面: モバイルアプリ、PWA、軽量なプレースホルダーが必要な環境
Next.js での実装戦略
静的生成時のプレースホルダー事前生成
// next.config.js での設定
module.exports = {
images: {
loader: 'custom',
loaderFile: './lib/imageLoader.js',
},
experimental: {
images: {
allowFutureImage: true
}
}
};
// lib/imageLoader.js
export default function imageLoader({ src, width, quality = 75 }) {
// ビルド時にプレースホルダーを生成
return `/api/image?url=${src}&w=${width}&q=${quality}`;
}
// ビルド時のプレースホルダー生成
import { GetStaticProps } from 'next';
export const getStaticProps: GetStaticProps = async () => {
const images = await glob('public/images/**/*.{jpg,jpeg,png}');
const placeholders = await Promise.all(
images.map(async (imagePath) => {
const relativePath = imagePath.replace('public', '');
// 複数の手法でプレースホルダーを生成
const [lqip, blurhash] = await Promise.all([
generateLQIP(imagePath),
generateBlurHash(imagePath)
]);
return {
src: relativePath,
lqip: lqip.placeholder,
blurhash: blurhash.hash,
width: lqip.width,
height: lqip.height
};
})
);
return {
props: {
placeholders: placeholders.reduce((acc, p) => {
acc[p.src] = p;
return acc;
}, {})
}
};
};
動的プレースホルダー生成API
// pages/api/placeholder.ts
import type { NextApiRequest, NextApiResponse } from 'next';
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
const { url, type = 'lqip' } = req.query;
if (!url || typeof url !== 'string') {
return res.status(400).json({ error: 'URL is required' });
}
try {
// キャッシュチェック
const cacheKey = `placeholder:${type}:${url}`;
const cached = await cache.get(cacheKey);
if (cached) {
res.setHeader('Cache-Control', 's-maxage=86400, stale-while-revalidate');
return res.json(JSON.parse(cached));
}
let placeholder;
switch (type) {
case 'lqip':
placeholder = await generateLQIP(url);
break;
case 'blurhash':
placeholder = await generateBlurHash(url);
break;
case 'sqip':
placeholder = await generateSQIP(url);
break;
default:
return res.status(400).json({ error: 'Invalid type' });
}
// キャッシュに保存
await cache.set(cacheKey, JSON.stringify(placeholder), 86400);
res.setHeader('Cache-Control', 's-maxage=86400, stale-while-revalidate');
res.json(placeholder);
} catch (error) {
console.error('Placeholder generation failed:', error);
res.status(500).json({ error: 'Generation failed' });
}
}
レスポンシブ画像との統合
srcset 生成 と組み合わせて、各サイズに最適化されたプレースホルダーを提供:
// 統合コンポーネント例
interface ResponsiveImageProps {
src: string;
alt: string;
sizes: string;
placeholderType?: 'lqip' | 'blurhash' | 'sqip';
priority?: boolean;
}
function ResponsiveImage({
src,
alt,
sizes,
placeholderType = 'lqip',
priority = false
}: ResponsiveImageProps) {
const [placeholder, setPlaceholder] = useState<string>('');
const [loaded, setLoaded] = useState(false);
useEffect(() => {
// プレースホルダーを非同期で取得
fetch(`/api/placeholder?url=${src}&type=${placeholderType}`)
.then(res => res.json())
.then(data => {
if (placeholderType === 'lqip') {
setPlaceholder(data.placeholder);
} else if (placeholderType === 'blurhash') {
const canvas = createBlurHashCanvas(data.hash, data.width, data.height);
setPlaceholder(canvas.toDataURL());
}
})
.catch(console.error);
}, [src, placeholderType]);
return (
<div className="relative overflow-hidden">
{placeholder && !loaded && (
<img
src={placeholder}
alt=""
aria-hidden="true"
className="absolute inset-0 w-full h-full object-cover filter blur-sm scale-110 transition-opacity duration-300"
style={{ opacity: loaded ? 0 : 1 }}
/>
)}
<Image
src={src}
alt={alt}
sizes={sizes}
priority={priority}
className="relative z-10 transition-opacity duration-300"
style={{ opacity: loaded ? 1 : 0 }}
onLoadingComplete={() => setLoaded(true)}
// Next.js 13+ での属性
placeholder="empty"
/>
</div>
);
}
パフォーマンス測定と最適化
効果測定指標
// Web Vitals での効果測定
import { getCLS, getFCP, getLCP } from 'web-vitals';
function measurePlaceholderEffect() {
const observer = new PerformanceObserver((list) => {
list.getEntries().forEach((entry) => {
if (entry.entryType === 'measure' && entry.name.startsWith('placeholder:')) {
// プレースホルダー表示から実画像表示までの時間
console.log(`${entry.name}: ${entry.duration}ms`);
analytics.track('placeholder_performance', {
technique: entry.name.split(':')[1],
duration: entry.duration,
url: entry.detail?.url
});
}
});
});
observer.observe({ entryTypes: ['measure'] });
// プレースホルダー表示開始
performance.mark('placeholder:start');
// 実画像読み込み完了時
image.onload = () => {
performance.mark('placeholder:end');
performance.measure('placeholder:duration', 'placeholder:start', 'placeholder:end');
};
}
// CLS への影響測定
getCLS((metric) => {
console.log('CLS score:', metric.value);
if (metric.value > 0.1) {
// プレースホルダーの寸法指定が不適切な可能性
console.warn('High CLS detected - check placeholder dimensions');
}
});
A/B テストフレームワーク
// プレースホルダー手法の A/B テスト
interface PlaceholderExperiment {
userId: string;
variant: 'lqip' | 'blurhash' | 'sqip' | 'none';
}
function getPlaceholderVariant(userId: string): PlaceholderExperiment['variant'] {
// ユーザーIDのハッシュ値で振り分け
const hash = hashCode(userId);
const bucket = hash % 4;
switch (bucket) {
case 0: return 'lqip';
case 1: return 'blurhash';
case 2: return 'sqip';
case 3: return 'none';
default: return 'lqip';
}
}
// 結果測定
function trackExperimentResult(variant: string, metrics: any) {
analytics.track('placeholder_experiment', {
variant,
fcp: metrics.fcp,
lcp: metrics.lcp,
cls: metrics.cls,
userRating: metrics.userRating // 主観的品質評価
});
}
よくある問題と解決策
1. プレースホルダーが本画像より重い
// サイズ制限付きプレースホルダー生成
async function sizeLimitedLQIP(imagePath, maxSize = 1024) {
let quality = 20;
let lqip;
do {
lqip = await generateLQIP(imagePath, quality);
quality -= 2;
} while (lqip.size > maxSize && quality > 5);
if (lqip.size > maxSize) {
// サイズがどうしても収まらない場合は BlurHash にフォールバック
return generateBlurHash(imagePath);
}
return lqip;
}
2. 色味の大幅な乖離
// 色統計保持型プレースホルダー
async function colorPreservingLQIP(imagePath) {
const image = sharp(imagePath);
const { dominant } = await image.stats();
// 主要色を抽出
const dominantColor = `rgb(${dominant.r}, ${dominant.g}, ${dominant.b})`;
const lqip = await image
.resize(16, 16)
.modulate({
saturation: 1.2, // 彩度を若干上げて印象を保持
lightness: 1.0
})
.jpeg({ quality: 15 })
.toBuffer();
return {
placeholder: `data:image/jpeg;base64,${lqip.toString('base64')}`,
dominantColor,
fallbackColor: dominantColor // CSS background-color としても使用可能
};
}
3. アクセシビリティ対応
// スクリーンリーダー対応
function AccessiblePlaceholderImage({ src, alt, placeholder }) {
const [loaded, setLoaded] = useState(false);
return (
<div
role="img"
aria-label={loaded ? alt : `${alt} (読み込み中)`}
aria-busy={!loaded}
>
{!loaded && (
<img
src={placeholder}
alt=""
aria-hidden="true"
className="placeholder-blur"
/>
)}
<img
src={src}
alt={alt}
onLoad={() => setLoaded(true)}
style={{ opacity: loaded ? 1 : 0 }}
/>
{/* プログレッシブローディングの進行状況 */}
{!loaded && (
<div
role="progressbar"
aria-label="画像読み込み中"
className="sr-only"
>
読み込み中...
</div>
)}
</div>
);
}
関連技術との統合
Service Worker でのキャッシュ戦略
// プレースホルダーの積極的キャッシュ
self.addEventListener('fetch', event => {
const url = new URL(event.request.url);
if (url.pathname.startsWith('/api/placeholder')) {
event.respondWith(
caches.open('placeholder-cache').then(cache => {
return cache.match(event.request).then(response => {
if (response) {
return response;
}
return fetch(event.request).then(fetchResponse => {
// 成功した場合のみキャッシュ
if (fetchResponse.ok) {
cache.put(event.request, fetchResponse.clone());
}
return fetchResponse;
});
});
})
);
}
});
CDN での配信最適化
// Cloudflare Workers でのエッジ生成
export default {
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
if (url.pathname.startsWith('/placeholder/')) {
const imageUrl = url.searchParams.get('url');
const type = url.searchParams.get('type') || 'lqip';
// エッジでプレースホルダーを生成
const placeholder = await generatePlaceholderAtEdge(imageUrl, type);
return new Response(JSON.stringify(placeholder), {
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'public, max-age=86400',
},
});
}
return fetch(request);
},
};
まとめ
プレースホルダー技術の選択と実装は、サイトの性格と技術制約に大きく依存します。重要なポイントは:
- 手法の特性理解: LQIP(汎用性)、SQIP(芸術性)、BlurHash(軽量性)の使い分け
- パフォーマンス最適化: 生成コストと効果のバランス、適切なキャッシュ戦略
- UX 設計: 過度な装飾を避け、実用性重視のシンプルな実装
- 測定と改善: Web Vitals での効果測定、A/B テストによる継続改善
- アクセシビリティ: 視覚障害者向けの適切な代替情報提供
これらの実践により、レスポンシブ画像設計 と組み合わせた総合的な画像配信最適化を実現し、ユーザー体験の大幅な向上を図ることができます。技術選択に迷った場合は、実装コストが低く汎用性の高い LQIP から始めることを推奨します。
関連記事
画像SEO 2025 — alt・構造化データ・サイトマップの実務
検索流入を逃さない画像SEOの最新実装。altテキスト/ファイル名/構造化データ/画像サイトマップ/LCP最適化を一つの方針で整えます。
2025年のレスポンシブ画像設計 — srcset/sizes 実践ガイド
ブレークポイントとカード密度から逆算して、srcset/sizes を正しく書くための決定版チートシート。LCP、アートディレクション、アイコン/SVG の扱いまで網羅。
画像圧縮 完全戦略 2025 ─ 画質を守りつつ体感速度を最適化する実践ガイド
Core Web Vitals と実運用に効く最新の画像圧縮戦略を、用途別の具体的プリセット・コード・ワークフローで徹底解説。JPEG/PNG/WebP/AVIF の使い分け、ビルド/配信最適化、トラブル診断まで網羅。