T-CREATOR

Storybook で実現する多言語 UI プレビュー

Storybook で実現する多言語 UI プレビュー

グローバル化が進む現代の Web 開発において、多言語対応は避けて通れない課題となっています。特に UI コンポーネントの多言語化では、文字数の変化によるレイアウト崩れや、文化的な違いによる表示の違いを事前に確認する必要があります。

Storybook は、この多言語 UI の開発とテストにおいて非常に強力なツールとなります。本記事では、Storybook と i18next を組み合わせて、効率的な多言語 UI プレビュー環境を構築する方法を詳しく解説します。

実際のプロジェクトで発生しがちなエラーとその解決策も含めて、実践的な知識をお届けします。

Storybook での多言語対応の基本概念

Storybook で多言語対応を実現する際の基本的な考え方について説明します。

多言語対応の 3 つのアプローチ

Storybook での多言語対応には、主に 3 つのアプローチがあります:

  1. 言語別ストーリー作成: 各言語ごとに個別のストーリーを作成
  2. 動的言語切り替え: 1 つのストーリー内で言語を動的に切り替え
  3. グローバル言語設定: Storybook 全体で言語を統一管理

必要な技術スタック

多言語対応に必要な主要なパッケージを確認しましょう。

json{
  "dependencies": {
    "i18next": "^23.7.0",
    "react-i18next": "^13.5.0",
    "i18next-browser-languagedetector": "^7.2.0",
    "i18next-http-backend": "^2.4.2"
  },
  "devDependencies": {
    "@storybook/react": "^7.6.0",
    "@storybook/addon-essentials": "^7.6.0",
    "@storybook/addon-controls": "^7.6.0"
  }
}

言語リソースの構造設計

効率的な多言語対応のため、言語リソースの構造を適切に設計することが重要です。

typescript// locales/ja/common.json
{
  "button": {
    "submit": "送信",
    "cancel": "キャンセル",
    "loading": "読み込み中..."
  },
  "form": {
    "email": "メールアドレス",
    "password": "パスワード",
    "validation": {
      "required": "必須項目です",
      "invalidEmail": "正しいメールアドレスを入力してください"
    }
  }
}
typescript// locales/en/common.json
{
  "button": {
    "submit": "Submit",
    "cancel": "Cancel",
    "loading": "Loading..."
  },
  "form": {
    "email": "Email Address",
    "password": "Password",
    "validation": {
      "required": "This field is required",
      "invalidEmail": "Please enter a valid email address"
    }
  }
}

i18next と Storybook の統合

i18next と Storybook を統合して、多言語対応の基盤を構築します。

i18next の初期設定

まず、i18next の基本設定を行います。

typescript// i18n.ts
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';

// 言語リソースの読み込み
import jaCommon from './locales/ja/common.json';
import enCommon from './locales/en/common.json';
import zhCommon from './locales/zh/common.json';

const resources = {
  ja: {
    common: jaCommon,
  },
  en: {
    common: enCommon,
  },
  zh: {
    common: zhCommon,
  },
};

i18n
  .use(LanguageDetector)
  .use(initReactI18next)
  .init({
    resources,
    fallbackLng: 'ja',
    debug: process.env.NODE_ENV === 'development',

    interpolation: {
      escapeValue: false,
    },

    defaultNS: 'common',
    ns: ['common'],
  });

export default i18n;

Storybook での i18next 統合

Storybook の設定ファイルで i18next を初期化します。

typescript// .storybook/preview.ts
import type { Preview } from '@storybook/react';
import '../src/i18n'; // i18nextの初期化

const preview: Preview = {
  parameters: {
    actions: { argTypesRegex: '^on[A-Z].*' },
    controls: {
      matchers: {
        color: /(background|color)$/i,
        date: /Date$/,
      },
    },
  },
};

export default preview;

よくあるエラーと解決策

i18next の初期化時に発生しがちなエラーを確認しましょう。

bash# エラー1: i18next not initialized
Error: i18next has not been initialized, call i18next.init() first

# 解決策: 初期化の順序を確認
typescript// 正しい初期化順序
// 1. i18nextの初期化
import './i18n';

// 2. Storybookの設定
import type { Preview } from '@storybook/react';

const preview: Preview = {
  // 設定内容
};
bash# エラー2: リソースファイルが見つからない
Module not found: Can't resolve './locales/ja/common.json'

# 解決策: TypeScript設定の追加
json// tsconfig.json
{
  "compilerOptions": {
    "resolveJsonModule": true,
    "esModuleInterop": true
  }
}

言語切り替え機能の実装

Storybook 内で言語を動的に切り替える機能を実装します。

カスタムデコレーターの作成

言語切り替え機能を持つカスタムデコレーターを作成します。

typescript// .storybook/decorators/i18nDecorator.tsx
import React, { useState, useEffect } from 'react';
import { I18nextProvider } from 'react-i18next';
import i18n from '../../src/i18n';

interface I18nDecoratorProps {
  children: React.ReactNode;
  locale?: string;
}

export const I18nDecorator: React.FC<
  I18nDecoratorProps
> = ({ children, locale = 'ja' }) => {
  const [currentI18n, setCurrentI18n] = useState(i18n);

  useEffect(() => {
    if (locale !== currentI18n.language) {
      currentI18n.changeLanguage(locale);
    }
  }, [locale, currentI18n]);

  return (
    <I18nextProvider i18n={currentI18n}>
      {children}
    </I18nextProvider>
  );
};

言語切り替え UI コンポーネント

Storybook 内で言語を切り替えるための UI コンポーネントを作成します。

typescript// components/LanguageSwitcher.tsx
import React from 'react';
import { useTranslation } from 'react-i18next';

interface LanguageSwitcherProps {
  onLanguageChange: (language: string) => void;
  currentLanguage: string;
}

export const LanguageSwitcher: React.FC<
  LanguageSwitcherProps
> = ({ onLanguageChange, currentLanguage }) => {
  const { t } = useTranslation();

  const languages = [
    { code: 'ja', name: '日本語' },
    { code: 'en', name: 'English' },
    { code: 'zh', name: '中文' },
  ];

  return (
    <div
      style={{
        position: 'fixed',
        top: '10px',
        right: '10px',
        zIndex: 1000,
        background: 'white',
        padding: '8px',
        border: '1px solid #ccc',
        borderRadius: '4px',
      }}
    >
      <select
        value={currentLanguage}
        onChange={(e) => onLanguageChange(e.target.value)}
      >
        {languages.map((lang) => (
          <option key={lang.code} value={lang.code}>
            {lang.name}
          </option>
        ))}
      </select>
    </div>
  );
};

Storybook アドオンの作成

言語切り替え機能を Storybook アドオンとして実装します。

typescript// .storybook/addons/i18nAddon.ts
import { addons, types } from '@storybook/addons';
import { I18nPanel } from './I18nPanel';

const ADDON_ID = 'i18n-addon';
const PANEL_ID = `${ADDON_ID}/panel`;

addons.register(ADDON_ID, () => {
  addons.add(PANEL_ID, {
    type: types.PANEL,
    title: 'i18n',
    match: ({ viewMode }) =>
      !!(viewMode && viewMode.match(/^(story|docs)$/)),
    render: I18nPanel,
  });
});
typescript// .storybook/addons/I18nPanel.tsx
import React, { useState } from 'react';
import {
  useAddonState,
  useChannel,
} from '@storybook/addons';
import { I18nPanelProps } from './types';

export const I18nPanel: React.FC<I18nPanelProps> = ({
  active,
}) => {
  const [language, setLanguage] = useAddonState(
    'i18n-language',
    'ja'
  );
  const emit = useChannel({});

  const handleLanguageChange = (newLanguage: string) => {
    setLanguage(newLanguage);
    emit('i18n-language-changed', newLanguage);
  };

  if (!active) return null;

  return (
    <div style={{ padding: '16px' }}>
      <h3>言語設定</h3>
      <select
        value={language}
        onChange={(e) =>
          handleLanguageChange(e.target.value)
        }
      >
        <option value='ja'>日本語</option>
        <option value='en'>English</option>
        <option value='zh'>中文</option>
      </select>
    </div>
  );
};

多言語ストーリーの作成パターン

実際のコンポーネントで多言語ストーリーを作成する方法を解説します。

基本的な多言語ストーリー

シンプルなボタンコンポーネントの多言語ストーリー例です。

typescript// components/Button/Button.tsx
import React from 'react';
import { useTranslation } from 'react-i18next';

interface ButtonProps {
  variant?: 'primary' | 'secondary';
  children?: React.ReactNode;
  onClick?: () => void;
}

export const Button: React.FC<ButtonProps> = ({
  variant = 'primary',
  children,
  onClick,
}) => {
  const { t } = useTranslation();

  return (
    <button
      className={`btn btn-${variant}`}
      onClick={onClick}
    >
      {children || t('button.submit')}
    </button>
  );
};
typescript// components/Button/Button.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import { Button } from './Button';
import { I18nDecorator } from '../../.storybook/decorators/i18nDecorator';

const meta: Meta<typeof Button> = {
  title: 'Components/Button',
  component: Button,
  decorators: [I18nDecorator],
  parameters: {
    layout: 'centered',
  },
};

export default meta;
type Story = StoryObj<typeof Button>;

// 日本語版ストーリー
export const Japanese: Story = {
  args: {
    variant: 'primary',
  },
  parameters: {
    locale: 'ja',
  },
};

// 英語版ストーリー
export const English: Story = {
  args: {
    variant: 'primary',
  },
  parameters: {
    locale: 'en',
  },
};

// 中国語版ストーリー
export const Chinese: Story = {
  args: {
    variant: 'primary',
  },
  parameters: {
    locale: 'zh',
  },
};

動的言語切り替えストーリー

1 つのストーリー内で言語を動的に切り替えるパターンです。

typescript// components/Form/Form.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import { Form } from './Form';
import { I18nDecorator } from '../../.storybook/decorators/i18nDecorator';

const meta: Meta<typeof Form> = {
  title: 'Components/Form',
  component: Form,
  decorators: [I18nDecorator],
  parameters: {
    layout: 'padded',
  },
};

export default meta;
type Story = StoryObj<typeof Form>;

export const MultiLanguage: Story = {
  args: {
    fields: ['email', 'password'],
    showValidation: true,
  },
  parameters: {
    docs: {
      description: {
        story:
          '言語切り替えパネルを使用して、異なる言語での表示を確認できます。',
      },
    },
  },
};

複雑なコンポーネントの多言語対応

複数の翻訳キーを使用する複雑なコンポーネントの例です。

typescript// components/UserProfile/UserProfile.tsx
import React from 'react';
import { useTranslation } from 'react-i18next';

interface UserProfileProps {
  user: {
    name: string;
    email: string;
    role: string;
  };
  showActions?: boolean;
}

export const UserProfile: React.FC<UserProfileProps> = ({
  user,
  showActions = true,
}) => {
  const { t } = useTranslation();

  return (
    <div className='user-profile'>
      <h3>{t('profile.title')}</h3>

      <div className='profile-info'>
        <div className='info-row'>
          <label>{t('profile.name')}:</label>
          <span>{user.name}</span>
        </div>

        <div className='info-row'>
          <label>{t('profile.email')}:</label>
          <span>{user.email}</span>
        </div>

        <div className='info-row'>
          <label>{t('profile.role')}:</label>
          <span>{t(`profile.roles.${user.role}`)}</span>
        </div>
      </div>

      {showActions && (
        <div className='actions'>
          <button className='btn btn-primary'>
            {t('profile.actions.edit')}
          </button>
          <button className='btn btn-secondary'>
            {t('profile.actions.delete')}
          </button>
        </div>
      )}
    </div>
  );
};
typescript// components/UserProfile/UserProfile.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import { UserProfile } from './UserProfile';
import { I18nDecorator } from '../../.storybook/decorators/i18nDecorator';

const meta: Meta<typeof UserProfile> = {
  title: 'Components/UserProfile',
  component: UserProfile,
  decorators: [I18nDecorator],
  parameters: {
    layout: 'padded',
  },
};

export default meta;
type Story = StoryObj<typeof UserProfile>;

const sampleUser = {
  name: '田中太郎',
  email: 'tanaka@example.com',
  role: 'admin',
};

export const JapaneseProfile: Story = {
  args: {
    user: sampleUser,
    showActions: true,
  },
  parameters: {
    locale: 'ja',
  },
};

export const EnglishProfile: Story = {
  args: {
    user: sampleUser,
    showActions: true,
  },
  parameters: {
    locale: 'en',
  },
};

動的コンテンツの多言語対応

動的に変化するコンテンツの多言語対応について解説します。

数値フォーマットの多言語化

数値や日付のフォーマットを言語に応じて変更します。

typescript// utils/formatters.ts
import i18n from 'i18next';

export const formatNumber = (
  value: number,
  locale?: string
): string => {
  const currentLocale = locale || i18n.language;

  return new Intl.NumberFormat(currentLocale, {
    style: 'currency',
    currency: currentLocale === 'ja' ? 'JPY' : 'USD',
  }).format(value);
};

export const formatDate = (
  date: Date,
  locale?: string
): string => {
  const currentLocale = locale || i18n.language;

  return new Intl.DateTimeFormat(currentLocale, {
    year: 'numeric',
    month: 'long',
    day: 'numeric',
  }).format(date);
};
typescript// components/PriceDisplay/PriceDisplay.tsx
import React from 'react';
import { useTranslation } from 'react-i18next';
import { formatNumber } from '../../utils/formatters';

interface PriceDisplayProps {
  amount: number;
  showCurrency?: boolean;
}

export const PriceDisplay: React.FC<PriceDisplayProps> = ({
  amount,
  showCurrency = true,
}) => {
  const { t, i18n } = useTranslation();

  const formattedPrice = formatNumber(
    amount,
    i18n.language
  );

  return (
    <div className='price-display'>
      <span className='label'>{t('price.label')}:</span>
      <span className='amount'>{formattedPrice}</span>
      {showCurrency && (
        <span className='currency-note'>
          {t('price.currencyNote')}
        </span>
      )}
    </div>
  );
};

複数形の多言語対応

言語によって複数形のルールが異なる場合の対応方法です。

typescript// utils/pluralization.ts
import i18n from 'i18next';

export const getPluralKey = (
  count: number,
  baseKey: string
): string => {
  const currentLocale = i18n.language;

  // 日本語の場合は単純
  if (currentLocale === 'ja') {
    return count === 1
      ? `${baseKey}.singular`
      : `${baseKey}.plural`;
  }

  // 英語の場合は複雑なルール
  if (currentLocale === 'en') {
    if (count === 1) return `${baseKey}.singular`;
    if (count === 0) return `${baseKey}.zero`;
    return `${baseKey}.plural`;
  }

  return `${baseKey}.plural`;
};
typescript// components/ItemCounter/ItemCounter.tsx
import React from 'react';
import { useTranslation } from 'react-i18next';
import { getPluralKey } from '../../utils/pluralization';

interface ItemCounterProps {
  count: number;
  itemType: string;
}

export const ItemCounter: React.FC<ItemCounterProps> = ({
  count,
  itemType,
}) => {
  const { t } = useTranslation();

  const pluralKey = getPluralKey(
    count,
    `counter.${itemType}`
  );

  return (
    <div className='item-counter'>
      <span className='count'>{count}</span>
      <span className='label'>{t(pluralKey)}</span>
    </div>
  );
};

条件付き翻訳の実装

コンテキストに応じて翻訳を変更する方法です。

typescript// components/StatusMessage/StatusMessage.tsx
import React from 'react';
import { useTranslation } from 'react-i18next';

interface StatusMessageProps {
  status: 'success' | 'error' | 'warning' | 'info';
  context?: 'form' | 'system' | 'user';
  customMessage?: string;
}

export const StatusMessage: React.FC<
  StatusMessageProps
> = ({ status, context = 'system', customMessage }) => {
  const { t } = useTranslation();

  const getMessageKey = () => {
    if (customMessage) return customMessage;
    return `status.${context}.${status}`;
  };

  const getIcon = () => {
    const icons = {
      success: '✅',
      error: '❌',
      warning: '⚠️',
      info: 'ℹ️',
    };
    return icons[status];
  };

  return (
    <div className={`status-message status-${status}`}>
      <span className='icon'>{getIcon()}</span>
      <span className='message'>{t(getMessageKey())}</span>
    </div>
  );
};

テストとデバッグ手法

多言語対応の品質を保つためのテストとデバッグ手法を解説します。

翻訳キーの存在チェック

翻訳キーが存在するかを自動でチェックするテストを作成します。

typescript// tests/i18n.test.ts
import i18n from '../src/i18n';

describe('i18n Translation Tests', () => {
  const languages = ['ja', 'en', 'zh'];
  const namespaces = ['common', 'profile', 'form'];

  // 翻訳キーの存在チェック
  test('all translation keys exist in all languages', () => {
    const baseKeys = getTranslationKeys(
      i18n.getResourceBundle('ja', 'common')
    );

    languages.forEach((language) => {
      namespaces.forEach((namespace) => {
        const resourceBundle = i18n.getResourceBundle(
          language,
          namespace
        );
        if (resourceBundle) {
          baseKeys.forEach((key) => {
            expect(resourceBundle).toHaveProperty(key);
          });
        }
      });
    });
  });

  // 翻訳キーの取得ヘルパー関数
  const getTranslationKeys = (
    obj: any,
    prefix = ''
  ): string[] => {
    const keys: string[] = [];

    Object.keys(obj).forEach((key) => {
      const fullKey = prefix ? `${prefix}.${key}` : key;

      if (
        typeof obj[key] === 'object' &&
        obj[key] !== null
      ) {
        keys.push(...getTranslationKeys(obj[key], fullKey));
      } else {
        keys.push(fullKey);
      }
    });

    return keys;
  };
});

Storybook での翻訳テスト

Storybook 内で翻訳の表示をテストする方法です。

typescript// components/Button/Button.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import { Button } from './Button';
import { I18nDecorator } from '../../.storybook/decorators/i18nDecorator';

const meta: Meta<typeof Button> = {
  title: 'Components/Button',
  component: Button,
  decorators: [I18nDecorator],
  parameters: {
    layout: 'centered',
  },
};

export default meta;
type Story = StoryObj<typeof Button>;

// 翻訳テスト用ストーリー
export const TranslationTest: Story = {
  args: {
    variant: 'primary',
  },
  parameters: {
    docs: {
      description: {
        story:
          '各言語での翻訳が正しく表示されることを確認します。',
      },
    },
  },
  play: async ({ canvasElement, step }) => {
    const canvas = within(canvasElement);

    await step('日本語での表示確認', async () => {
      // 言語を日本語に設定
      // 実際のテストコードでは言語切り替え機能を使用
      await expect(
        canvas.getByText('送信')
      ).toBeInTheDocument();
    });

    await step('英語での表示確認', async () => {
      // 言語を英語に設定
      await expect(
        canvas.getByText('Submit')
      ).toBeInTheDocument();
    });
  },
};

よくあるエラーと対処法

多言語対応で発生しがちなエラーとその解決策を紹介します。

bash# エラー1: 翻訳キーが見つからない
Warning: Key "button.submit" for namespace "common" won't get resolved as namespace "common" was not yet loaded

# 解決策: 名前空間の事前読み込み
typescript// i18n.ts の修正
i18n
  .use(LanguageDetector)
  .use(initReactI18next)
  .init({
    resources,
    fallbackLng: 'ja',
    debug: process.env.NODE_ENV === 'development',

    // 名前空間の事前読み込み
    preload: ['ja', 'en', 'zh'],
    ns: ['common', 'profile', 'form'],
    defaultNS: 'common',

    interpolation: {
      escapeValue: false,
    },
  });
bash# エラー2: 動的翻訳キーの解決失敗
Error: Key "profile.roles.admin" not found

# 解決策: ネストした翻訳キーの適切な設定
typescript// locales/ja/profile.json
{
  "roles": {
    "admin": "管理者",
    "user": "ユーザー",
    "guest": "ゲスト"
  }
}
bash# エラー3: 言語切り替え時のレンダリングエラー
Error: Maximum update depth exceeded

# 解決策: useEffectの依存関係の適切な設定
typescript// 修正前(無限ループが発生)
useEffect(() => {
  if (locale !== currentI18n.language) {
    currentI18n.changeLanguage(locale);
  }
}, [locale, currentI18n]); // currentI18nが毎回変更される

// 修正後
useEffect(() => {
  if (locale !== currentI18n.language) {
    currentI18n.changeLanguage(locale);
  }
}, [locale]); // localeのみを依存関係に設定

パフォーマンス最適化

多言語対応によるパフォーマンスへの影響を最小化する方法を解説します。

遅延読み込みの実装

必要な言語リソースのみを遅延読み込みします。

typescript// i18n.ts の最適化版
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import Backend from 'i18next-http-backend';

i18n
  .use(Backend)
  .use(LanguageDetector)
  .use(initReactI18next)
  .init({
    fallbackLng: 'ja',
    debug: process.env.NODE_ENV === 'development',

    // バックエンド設定
    backend: {
      loadPath: '/locales/{{lng}}/{{ns}}.json',
    },

    // 遅延読み込み設定
    load: 'languageOnly',
    preload: ['ja'], // 初期言語のみ事前読み込み

    interpolation: {
      escapeValue: false,
    },
  });

export default i18n;

メモ化による最適化

翻訳結果をメモ化してパフォーマンスを向上させます。

typescript// hooks/useMemoizedTranslation.ts
import { useTranslation } from 'react-i18next';
import { useMemo } from 'react';

export const useMemoizedTranslation = (ns?: string) => {
  const { t, i18n } = useTranslation(ns);

  const memoizedT = useMemo(() => {
    return (key: string, options?: any) => t(key, options);
  }, [t, i18n.language]);

  return { t: memoizedT, i18n };
};
typescript// components/OptimizedButton/OptimizedButton.tsx
import React from 'react';
import { useMemoizedTranslation } from '../../hooks/useMemoizedTranslation';

interface OptimizedButtonProps {
  variant?: 'primary' | 'secondary';
  children?: React.ReactNode;
}

export const OptimizedButton: React.FC<
  OptimizedButtonProps
> = ({ variant = 'primary', children }) => {
  const { t } = useMemoizedTranslation();

  // 翻訳結果をメモ化
  const buttonText = useMemo(() => {
    return children || t('button.submit');
  }, [children, t]);

  return (
    <button className={`btn btn-${variant}`}>
      {buttonText}
    </button>
  );
};

バンドルサイズの最適化

言語リソースのバンドルサイズを削減します。

typescript// webpack.config.js (Storybook用)
const path = require('path');

module.exports = ({ config }) => {
  // 言語リソースの分離
  config.optimization.splitChunks.cacheGroups.i18n = {
    test: /[\\/]locales[\\/]/,
    name: 'i18n',
    chunks: 'all',
    priority: 10,
  };

  return config;
};
typescript// .storybook/main.ts
import type { StorybookConfig } from '@storybook/react-webpack5';

const config: StorybookConfig = {
  stories: [
    '../src/**/*.mdx',
    '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)',
  ],
  addons: [
    '@storybook/addon-links',
    '@storybook/addon-essentials',
    '@storybook/addon-onboarding',
    '@storybook/addon-interactions',
  ],
  framework: {
    name: '@storybook/react-webpack5',
    options: {},
  },
  docs: {
    autodocs: 'tag',
  },
  webpackFinal: async (config) => {
    // 言語リソースの最適化
    if (
      config.optimization &&
      config.optimization.splitChunks
    ) {
      config.optimization.splitChunks.cacheGroups.i18n = {
        test: /[\\/]locales[\\/]/,
        name: 'i18n',
        chunks: 'all',
        priority: 10,
      };
    }

    return config;
  },
};

export default config;

パフォーマンス監視

多言語対応のパフォーマンスを監視する仕組みを実装します。

typescript// utils/performanceMonitor.ts
export class I18nPerformanceMonitor {
  private static instance: I18nPerformanceMonitor;
  private metrics: Map<string, number> = new Map();

  static getInstance(): I18nPerformanceMonitor {
    if (!I18nPerformanceMonitor.instance) {
      I18nPerformanceMonitor.instance =
        new I18nPerformanceMonitor();
    }
    return I18nPerformanceMonitor.instance;
  }

  measureTranslation(
    key: string,
    language: string
  ): () => void {
    const startTime = performance.now();
    const metricKey = `${language}:${key}`;

    return () => {
      const endTime = performance.now();
      const duration = endTime - startTime;
      this.metrics.set(metricKey, duration);

      // パフォーマンス警告
      if (duration > 10) {
        console.warn(
          `Slow translation: ${metricKey} took ${duration}ms`
        );
      }
    };
  }

  getMetrics(): Map<string, number> {
    return new Map(this.metrics);
  }

  reset(): void {
    this.metrics.clear();
  }
}
typescript// hooks/usePerformanceTranslation.ts
import { useTranslation } from 'react-i18next';
import { useCallback } from 'react';
import { I18nPerformanceMonitor } from '../utils/performanceMonitor';

export const usePerformanceTranslation = (ns?: string) => {
  const { t, i18n } = useTranslation(ns);
  const monitor = I18nPerformanceMonitor.getInstance();

  const performanceT = useCallback(
    (key: string, options?: any) => {
      const endMeasure = monitor.measureTranslation(
        key,
        i18n.language
      );
      const result = t(key, options);
      endMeasure();
      return result;
    },
    [t, i18n.language, monitor]
  );

  return { t: performanceT, i18n };
};

まとめ

Storybook と i18next を組み合わせた多言語 UI プレビュー環境の構築について詳しく解説しました。

主要なポイント

  1. 基本設定: i18next と Storybook の適切な統合が重要
  2. 言語切り替え: カスタムデコレーターとアドオンによる動的言語切り替え
  3. ストーリー設計: 言語別ストーリーと動的切り替えの使い分け
  4. 動的コンテンツ: 数値フォーマットや複数形の適切な対応
  5. 品質保証: 翻訳キーの存在チェックとテスト自動化
  6. パフォーマンス: 遅延読み込みとメモ化による最適化

実装のベストプラクティス

  • 翻訳キーの命名規則を統一する
  • 言語リソースの構造を適切に設計する
  • パフォーマンス監視を継続的に行う
  • テスト自動化で品質を保証する
  • チーム内での多言語対応ガイドラインを策定する

今後の発展

多言語対応は継続的な改善が必要な分野です。新しい言語の追加や、文化的な違いへの対応、アクセシビリティの向上など、常に最新のベストプラクティスを取り入れることが重要です。

Storybook の多言語対応機能を活用することで、開発効率の向上と品質の確保を両立できる環境を構築できます。

関連リンク