banner
innei

innei

写代码是因为爱,写到世界充满爱!
github
telegram
twitter

React i18n CSR 最佳实践

最近、コミュニティは再び Follow に力を入れ始め、i18n 作業を進めています。

始める前に、整った i18n 基盤が必要です。私たちは react-i18next を選択しました。

次に、私たちは浅いところから深いところへと、整った i18n 基盤を設定していきます。

基礎設定#

インストール#

npm install react-i18next i18next

i18n 設定ファイルを作成します。例えば i18n.ts のように。

import i18next from 'i18next'
import { initReactI18next } from 'react-i18next'

import en from '@/locales/en.json'
import zhCN from '@/locales/zh_CN.json'

i18next.use(initReactI18next).init({
  lng: 'zh',
  fallbackLng: 'en',
  resources: {
    en: {
      translation: en,
    },
    zh: {
      translation: zhCN,
    },
  },
})

次に、エントリーファイルでインポートします。

import './i18n'

これでプロジェクト内で i18n を使用できるようになります。

import { useTranslation } from 'react-i18next'

const { t } = useTranslation()

TypeScript の型問題を解決する#

上記のコードは正常に動作しますが、TypeScript では型チェックやインテリセンスが得られません。

したがって、型安全な書き方を希望します。

公式の推奨方法に従い、resources を @types に置き、i18next.d.ts ファイルを作成します。

import en from '@/locales/en.json'
import lang_en from '@/locales/modules/languages/en.json'
import lang_zhCN from '@/locales/modules/languages/zh_CN.json'
import zhCN from '@/locales/zh_CN.json'

const resources = {
  en: {
    translation: en,
    lang: lang_en,
  },
  zh_CN: {
    translation: zhCN,
    lang: lang_zhCN,
  },
}
export default resources
import type resources from './resources'

declare module 'i18next' {
  interface CustomTypeOptions {
    resources: (typeof resources)['en']
    defaultNS: 'translation'
  }
}

次に i18n.ts ファイルを修正します。

import i18next from 'i18next'
import { initReactI18next } from 'react-i18next'

import resources from './@types/resources'

export const defaultNS = 'translation'
export const fallbackLanguage = 'en'
export const initI18n = () => {
  i18next.use(initReactI18next).init({
    lng: language,
    fallbackLng: fallbackLanguage,
    defaultNS,
    ns: [defaultNS],

    resources,
  })
}

これで型ヒントが得られるようになります。

名前空間を分離する#

プロジェクトが大きくなるにつれて、すべてのテキストを 1 つのファイルにまとめるのは非常に管理が難しくなることがわかります。したがって、テキストを異なるファイルに分割する必要があります。これが名前空間です。

Follow では、これまでに以下の名前空間に分割されています:

  • app アプリ関連
  • lang 言語
  • external 外部ページ
  • settings 設定
  • shortcuts ショートカットキー
  • common 共通

ディレクトリ構造は次のようになります:

. locales
├── app
│   ├── en.json
│   ├── zh-CN.json
│   └── zh-TW.json
├── common
│   ├── en.json
│   ├── zh-CN.json
│   └── zh-TW.json
├── external
│   ├── en.json
│   ├── zh-CN.json
│   └── zh-TW.json
├── lang
│   ├── en.json
│   ├── zh-CN.json
│   └── zh-TW.json
├── settings
│   ├── en.json
│   ├── zh-CN.json
│   └── zh-TW.json
└── shortcuts
    ├── en.json
    ├── zh-CN.json
    └── zh-TW.json

このように分割した後、上記の resources.d.ts にすべての言語ファイルをインポートするだけで済みます。

import en from '@/locales/en.json'
import lang_en from '@/locales/modules/languages/en.json'
import lang_zhCN from '@/locales/modules/languages/zh_CN.json'
import lang_zhTW from '@/locales/modules/languages/zh_TW.json'
import settings_en from '@/locales/modules/settings/en.json'
import settings_zhCN from '@/locales/modules/settings/zh_CN.json'
import shortcuts_en from '@/locales/modules/shortcuts/en.json'
import shortcuts_zhCN from '@/locales/modules/shortcuts/zh_CN.json'
import common_en from '@/locales/modules/common/en.json'
import common_zhCN from '@/locales/modules/common/zh_CN.json'
import external_en from '@/locales/modules/external/en.json'
import external_zhCN from '@/locales/modules/external/zh_CN.json'
import external_zhTW from '@/locales/modules/external/zh_TW.json'
const resources = {
  en: {
    translation: en,
    lang: lang_en,
    settings: settings_en,
    shortcuts: shortcuts_en,
    common: common_en,
    external: external_en,
  },
  zh_CN: {
    translation: zhCN,
    lang: lang_zhCN,
    settings: settings_zhCN,
    shortcuts: shortcuts_zhCN,
    common: common_zhCN,
    external: external_zhCN,
  },

  // 他の言語
  zh_TW: {
    translation: zhTW,
    lang: lang_zhTW,
    settings: settings_zhTW,
    shortcuts: shortcuts_zhTW,
    common: common_zhTW,
    external: external_zhTW,
  },
}
export default resources

必要に応じて言語をロードする#

言語をどんどん追加していくと、パッケージ後のサイズもどんどん大きくなります。そして、ユーザーは一般的に 1 つの言語しか使用しないため、必要に応じて言語をロードしたいと考えています。

しかし、実際には i18next には必要に応じてロードするロジックが組み込まれていないため、自分で実装する必要があります。まず、resource.ts ファイルを修正します。

export const resources = {
  en: {
    app: en,
    lang: lang_en,
    common: common_en,
    external: external_en,
    settings: settings_en,
    shortcuts: shortcuts_en,
  },
  'zh-CN': {
    lang: lang_zhCN,
    common: common_zhCN,
    settings: settings_zhCN, 
    shortcuts: shortcuts_zhCN, 
    common: common_zhCN, 
    external: external_zhCN, 
  },
  // 他の言語
}

ここでは、英語は全量インポートしていますが、他の言語は必要に応じてインポートしています。次に、他の言語の大部分の名前空間リソースを削除し、commonlang の 2 つの名前空間だけを残します。これらの 2 つの名前空間は共通モジュールであり、サイズも比較的小さいため、ここでは全量インポートできます。実際の使用シーンでは、完全に削除することもできます。例えば:

export const resources = {
  en: {
    app: en,
    lang: lang_en,
    common: common_en,
    external: external_en,
    settings: settings_en,
    shortcuts: shortcuts_en,
  },
}

上記のように、英語のリソースだけがある状態です。今、ファイル名を変更して、resources.tsdefault-resources.ts に変更します。他はそのままにします。

次に、必要に応じて言語をロードする方法を実装します。

大まかな考え方は:

  1. import() を使用して必要な言語リソースをロードし、i18n.addResourceBundle() を使用してロードを完了します。
  2. その後、再度 i18n.changeLanguage() を呼び出して言語を切り替えます。
  3. 新しい i18next インスタンスを設定して、コンポーネントを再レンダリングします。

I18nProvider を作成してこのロジックを実装します。

import i18next from 'i18next'
import { atom } from 'jotai'

export const i18nAtom = atom(i18next)

export const I18nProvider: FC<PropsWithChildren> = ({ children }) => {
  const [currentI18NInstance, update] = useAtom(i18nAtom)
  return (
    <I18nextProvider i18n={currentI18NInstance}>{children}</I18nextProvider>
  )
}

次に、i18n 言語の変更を監視します。ここで注意が必要なのは、現在関連する言語がなくても、languageChanged はトリガーされることです。

const loadingLangLock = new Set<string>()

const langChangedHandler = async (lang: string) => {
  const { t } = jotaiStore.get(i18nAtom)
  if (loadingLangLock.has(lang)) return

  const loaded = i18next.getResourceBundle(lang, defaultNS)

  if (loaded) {
    return
  }

  loadingLangLock.add(lang)

  const nsGlobbyMap = import.meta.glob('@locales/*/*.json')

  const namespaces = Object.keys(defaultResources.en) // 全量ロードされた英語からすべての名前空間を取得できます

  const res = await Promise.allSettled(
    // 名前空間を使用して対応する言語リソースをロードします
    namespaces.map(async (ns) => {
      const loader = nsGlobbyMap[`../../locales/${ns}/${lang}.json`] // このパスはプロジェクトによって異なる場合があるため、実際の状況に応じて調整する必要があります

      if (!loader) return
      const nsResources = await loader().then((m: any) => m.default)

      i18next.addResourceBundle(lang, ns, nsResources, true, true)
    }),
  )

  await i18next.reloadResources()
  await i18next.changeLanguage(lang) // 再度言語を切り替えます
  loadingLangLock.delete(lang)
}

useLayoutEffect(() => {
  const i18next = currentI18NInstance

  i18next.on('languageChanged', langChangedHandler)

  return () => {
    i18next.off('languageChanged')
  }
}, [currentI18NInstance])

ここで注意が必要なのは、言語のロードが完了した後、再度 i18next.changeLanguage() を呼び出して言語を切り替える必要があることです。

本番環境での名前空間リソースの統合#

上記の例では、複数の名前空間リソースを分割しましたが、本番環境では、すべての名前空間リソースを 1 つのファイルに統合したいと考えています。これにより、ネットワークリクエストの回数を減らすことができます。

Vite プラグインを作成して、本番環境でのすべての名前空間リソースを 1 つのファイルに統合します。

function localesPlugin(): Plugin {
  return {
    name: 'locales-merge',
    enforce: 'post',
    generateBundle(options, bundle) {
      const localesDir = path.resolve(__dirname, '../locales') // locales ディレクトリを変更することに注意してください
      const namespaces = fs.readdirSync(localesDir)
      const languageResources = {}

      namespaces.forEach((namespace) => {
        const namespacePath = path.join(localesDir, namespace)
        const files = fs
          .readdirSync(namespacePath)
          .filter((file) => file.endsWith('.json'))

        files.forEach((file) => {
          const lang = path.basename(file, '.json')
          const filePath = path.join(namespacePath, file)
          const content = JSON.parse(fs.readFileSync(filePath, 'utf-8'))

          if (!languageResources[lang]) {
            languageResources[lang] = {}
          }
          languageResources[lang][namespace] = content
        })
      })

      Object.entries(languageResources).forEach(([lang, resources]) => {
        const fileName = `locales/${lang}.js`
        const content = `export default ${JSON.stringify(resources)};`

        this.emitFile({
          type: 'asset',
          fileName,
          source: content,
        })
      })

      Object.keys(bundle).forEach((key) => {
        if (key.startsWith('locales/') && key.endsWith('.json')) {
          delete bundle[key]
        }
      })
    },
  }
}

次に、vite.config.ts にインポートします。

import localesPlugin from './locales-plugin'

export default defineConfig({
  plugins: [localesPlugin()],
})

これで、パッケージ後の生成物に locales ディレクトリが生成され、その下にすべての言語リソースが統合されたファイルが含まれます。

image

もちろん、このプラグインだけでは不十分です。i18n-provider.tsxlangChangedHandler メソッドも修正します。

const langChangedHandler = async (lang: string) => {
  const { t } = jotaiStore.get(i18nAtom)
  if (loadingLangLock.has(lang)) return
  const isSupport = currentSupportedLanguages.includes(lang)
  if (!isSupport) {
    return
  }
  const loaded = i18next.getResourceBundle(lang, defaultNS)

  if (loaded) {
    return
  }

  loadingLangLock.add(lang)

  if (import.meta.env.DEV) {   
    const nsGlobbyMap = import.meta.glob('@locales/*/*.json')

    const namespaces = Object.keys(defaultResources.en)

    const res = await Promise.allSettled(
      namespaces.map(async (ns) => {
        const loader = nsGlobbyMap[`../../locales/${ns}/${lang}.json`]

        if (!loader) return
        const nsResources = await loader().then((m: any) => m.default)

        i18next.addResourceBundle(lang, ns, nsResources, true, true)
      }),
    )

    for (const r of res) {
      if (r.status === 'rejected') {
        toast.error(`${t('common:tips.load-lng-error')}: ${lang}`)
        loadingLangLock.delete(lang)

        return
      }
    }
  } else {
    const res = await import(`/locales/${lang}.js`) // import の方法でロードします
      .then((res) => res?.default || res)
      .catch(() => {
        toast.error(`${t('common:tips.load-lng-error')}: ${lang}`)
        loadingLangLock.delete(lang)
        return {}
      })

    if (isEmptyObject(res)) {
      return
    }
    for (const namespace in res) {
      i18next.addResourceBundle(lang, namespace, res[namespace], true, true)
    }
  }

  await i18next.reloadResources()
  await i18next.changeLanguage(lang)
  loadingLangLock.delete(lang)
}

開発環境と本番環境を区別し、本番環境では import の方法で言語リソースをロードし、開発環境では import.meta.glob の方法で言語リソースをロードします。

これで、本番環境で言語を切り替えるテストを行うと、1 つのファイルだけがリクエストされることがわかります。

image

日付ライブラリの i18n を動的にロードする#

同様に、日付ライブラリの i18n にも配慮する必要があります。ここでは dayjs を例にします。

Dayjs の国際化設定のインポートマップを維持する必要があります。次のように:

export const dayjsLocaleImportMap = {
  en: ['en', () => import('dayjs/locale/en')],
  ['zh-CN']: ['zh-cn', () => import('dayjs/locale/zh-cn')],
  ['ja']: ['ja', () => import('dayjs/locale/ja')],
  ['fr']: ['fr', () => import('dayjs/locale/fr')],
  ['pt']: ['pt', () => import('dayjs/locale/pt')],
  ['zh-TW']: ['zh-tw', () => import('dayjs/locale/zh-tw')],
}

言語コードは https://github.com/iamkun/dayjs/tree/dev/src/locale から取得できます。

次に、langChangedHandler 内で dayjsLocaleImportMap を使用して対応する言語リソースをロードします。

const langChangedHandler = async (lang: string) => {
  const dayjsImport = dayjsLocaleImportMap[lang]

  if (dayjsImport) {
    const [locale, loader] = dayjsImport
    loader().then(() => {
      dayjs.locale(locale)
    })
  }
}

DX 最適化:HMR サポート#

何も処理を行わない場合、開発環境で言語リソースファイルの json を変更すると、ページが完全にリロードされます。変更後のテキストをリアルタイムで見ることはできません。

Vite プラグインを作成して HMR を実現できます。

function customI18nHmrPlugin(): Plugin {
  return {
    name: "custom-i18n-hmr",
    handleHotUpdate({ file, server }) {
      if (file.endsWith(".json") && file.includes("locales")) {
        server.ws.send({
          type: "custom",
          event: "i18n-update",
          data: {
            file,
            content: readFileSync(file, "utf-8"),
          },
        })

        // デフォルトの HMR を防ぐために空の配列を返します
        return []
      }
    },
  }
}

/// vite.config.ts にインポートします

export default defineConfig({
  plugins: [customI18nHmrPlugin()],
})

これで、言語リソースファイルの json を変更しても、ページが完全にリロードされることはありません。Vite の HMR 処理ロジックは私たちによってキャッチされました。次に、手動で処理する必要があります。上記のプラグインでは、json が変更されたときに i18n-update イベントを送信します。このイベントを i18n.ts で処理します。

if (import.meta.hot) {
  import.meta.hot.on(
    "i18n-update",
    async ({ file, content }: { file: string; content: string }) => {
      const resources = JSON.parse(content)
      const i18next = jotaiStore.get(i18nAtom)

      const nsName = file.match(/locales\/(.+?)\//)?.[1]

      if (!nsName) return
      const lang = file.split("/").pop()?.replace(".json", "")
      if (!lang) return
      i18next.addResourceBundle(lang, nsName, resources, true, true)

      console.info("reload", lang, nsName)
      await i18next.reloadResources(lang, nsName)

      import.meta.env.DEV && EventBus.dispatch("I18N_UPDATE", "") // ロード完了、コンポーネントに再レンダリングを通知
    },
  )
}

declare module "@/lib/event-bus" {
  interface CustomEvent {
    I18N_UPDATE: string
  }
}

I18nProvider 内でこのイベントをリッスンします。

export const I18nProvider: FC<PropsWithChildren> = ({ children }) => {
  const [currentI18NInstance, update] = useAtom(i18nAtom)

  if (import.meta.env.DEV)
    // eslint-disable-next-line react-hooks/rules-of-hooks
    useEffect(
      () =>
        EventBus.subscribe('I18N_UPDATE', () => {
          const lang = getGeneralSettings().language
          // i18n インスタンスを再作成します
          const nextI18n = i18next.cloneInstance({
            lng: lang,
          })
          update(nextI18n)
        }),
      [update],
    )
}

言語翻訳の完了度を計算する#

動的にロードされた言語リソースを使用しているため、言語翻訳の完了度を実行時に計算することはできません。コンパイル時に計算する必要があります。

計算メソッドを作成します。

import fs from "node:fs"
import path from "node:path"

type LanguageCompletion = Record<string, number>

function getLanguageFiles(dir: string): string[] {
  return fs.readdirSync(dir).filter((file) => file.endsWith(".json"))
}

function getNamespaces(localesDir: string): string[] {
  return fs
    .readdirSync(localesDir)
    .filter((file) => fs.statSync(path.join(localesDir, file)).isDirectory())
}

function countKeys(obj: any): number {
  let count = 0
  for (const key in obj) {
    if (typeof obj[key] === "object") {
      count += countKeys(obj[key])
    } else {
      count++
    }
  }
  return count
}

function calculateCompleteness(localesDir: string): LanguageCompletion {
  const namespaces = getNamespaces(localesDir)
  const languages = new Set<string>()
  const keyCount: Record<string, number> = {}

  namespaces.forEach((namespace) => {
    const namespaceDir = path.join(localesDir, namespace)
    const files = getLanguageFiles(namespaceDir)

    files.forEach((file) => {
      const lang = path.basename(file, ".json")
      languages.add(lang)

      const content = JSON.parse(fs.readFileSync(path.join(namespaceDir, file), "utf-8"))
      keyCount[lang] = (keyCount[lang] || 0) + countKeys(content)
    })
  })

  const enCount = keyCount["en"] || 0
  const completeness: LanguageCompletion = {}

  languages.forEach((lang) => {
    if (lang !== "en") {
      const percent = Math.round((keyCount[lang] / enCount) * 100)
      completeness[lang] = percent
    }
  })

  return completeness
}

const i18n = calculateCompleteness(path.resolve(__dirname, "../locales"))
export default i18n

次に、このコンパイルマクロを Vite にインポートします。

export default defineConfig({
  define: {
    I18N_COMPLETENESS_MAP: JSON.stringify({ ...i18nCompleteness, en: 100 }),
  }
})

ビジネスで使用します:

export const LanguageSelector = () => {
  const { t, i18n } = useTranslation("settings")
  const { t: langT } = useTranslation("lang")
  const language = useGeneralSettingSelector((state) => state.language)

  const finalRenderLanguage = currentSupportedLanguages.includes(language)
    ? language
    : fallbackLanguage
  return (
    <div className="mb-3 mt-4 flex items-center justify-between">
      <span className="shrink-0 text-sm font-medium">{t("general.language")}</span>
      <Select
        defaultValue={finalRenderLanguage}
        value={finalRenderLanguage}
        onValueChange={(value) => {
          setGeneralSetting("language", value as string)
          i18n.changeLanguage(value as string)
        }}
      >
        <SelectTrigger size="sm" className="w-48">
          <SelectValue />
        </SelectTrigger>
        <SelectContent position="item-aligned">
          {currentSupportedLanguages.map((lang) => {
            const percent = I18N_COMPLETENESS_MAP[lang]

            return (
              <SelectItem key={lang} value={lang}>
                {langT(`langs.${lang}` as any)}{" "}
                {/* パーセンテージが 100 の場合は表示しない */}
                {typeof percent === "number" ? (percent === 100 ? null : `(${percent}%)`) : null}
              </SelectItem>
            )
          })}
        </SelectContent>
      </Select>
    </div>
  )
}

image

まとめ#

上記では、比較的完全な i18n ソリューションを実現しました。

含まれているもの:

  • 全量インポート
  • 必要に応じたインポート
  • 動的ロード
  • 本番環境での名前空間の統合
  • 言語翻訳の完了度の計算
  • HMR サポート

このソリューションは Follow に適用されています。

具体的な実装はコードを参照してください:

https://github.com/RSSNext/Follow/blob/dev/src/renderer/src/providers/i18n-provider.tsx

https://github.com/RSSNext/Follow/blob/dev/src/renderer/src/i18n.ts

(そういえば、この記事には隠された Follow 招待コードがあります。あなたは見つけられますか?)

この記事は Mix Space によって xLog に同期更新されました
元のリンクは https://innei.in/posts/tech/React-i18n-CSR-best-practices


読み込み中...
文章は、創作者によって署名され、ブロックチェーンに安全に保存されています。