プレースホルダー設計 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);
  },
};

まとめ

プレースホルダー技術の選択と実装は、サイトの性格と技術制約に大きく依存します。重要なポイントは:

  1. 手法の特性理解: LQIP(汎用性)、SQIP(芸術性)、BlurHash(軽量性)の使い分け
  2. パフォーマンス最適化: 生成コストと効果のバランス、適切なキャッシュ戦略
  3. UX 設計: 過度な装飾を避け、実用性重視のシンプルな実装
  4. 測定と改善: Web Vitals での効果測定、A/B テストによる継続改善
  5. アクセシビリティ: 視覚障害者向けの適切な代替情報提供

これらの実践により、レスポンシブ画像設計 と組み合わせた総合的な画像配信最適化を実現し、ユーザー体験の大幅な向上を図ることができます。技術選択に迷った場合は、実装コストが低く汎用性の高い LQIP から始めることを推奨します。

関連記事