banner
innei

innei

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

React i18n CSR Best Practices

Recently, the community has started to intensify efforts on Follow, initiating i18n work.

Having a complete i18n infrastructure before starting is essential. We chose react-i18next.

Next, we will configure a comprehensive i18n infrastructure step by step.

Basic Configuration#

Installation#

npm install react-i18next i18next

Create an i18n configuration file, such as 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,
    },
  },
})

Then import it in the entry file.

import './i18n'

Now you can use i18n in the project.

import { useTranslation } from 'react-i18next'

const { t } = useTranslation()

Solving TypeScript Type Issues#

Although the above code works normally, you won't get any type checking or smart hints in TypeScript.

So, we hope to have a type-safe way of writing.

Following the recommended approach from the official website, we can place resources in @types, and then create an i18next.d.ts file.

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'
  }
}

Then modify the i18n.ts file.

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,
  })
}

Now you have type hints.

Separating Namespaces#

As our project grows larger, we will find that putting all text in one file becomes very difficult to maintain. Therefore, we need to split the text into different files, which is called namespaces.

In Follow, so far, we have split the following namespaces:

  • app Application related
  • lang Language
  • external External pages
  • settings Settings
  • shortcuts Shortcuts
  • common Common

The directory structure is as follows:

. 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

After splitting, we only need to import all language files in the above 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,
  },

  // Other languages
  zh_TW: {
    translation: zhTW,
    lang: lang_zhTW,
    settings: settings_zhTW,
    shortcuts: shortcuts_zhTW,
    common: common_zhTW,
    external: external_zhTW,
  },
}
export default resources

Loading Languages on Demand#

As we import more and more languages, we will find that the size of the package increases. Since users generally only use one language, we hope to load languages on demand.

However, i18next does not have built-in logic for on-demand loading, so we need to implement it ourselves. First, we need to modify the resource.ts file.

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, 
  },
  // Other languages
}

Here, we fully import English, while other languages are imported on demand. Next, delete most of the namespace resources for other languages, keeping only the common and lang namespaces. Since these two namespaces are common modules and relatively small, we can fully import them. In actual usage scenarios, you can also completely delete them. For example:

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

Similar to the above, there is only one English resource. Now we can change the file name from resources.ts to default-resources.ts. Everything else remains unchanged.

Next, let's implement how to load languages on demand.

The general idea is:

  1. Load the required language resources using import(), then use i18n.addResourceBundle() to complete the loading.
  2. Then call i18n.changeLanguage() again to switch languages.
  3. Reset an i18next instance to re-render the component.

Create an I18nProvider to implement this logic.

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>
  )
}

Then listen for changes in the i18n language. Note that even if there is currently no related language, languageChanged will still be triggered.

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) // Get all namespaces from the fully loaded English

  const res = await Promise.allSettled(
    // Load the corresponding language resources by namespace
    namespaces.map(async (ns) => {
      const loader = nsGlobbyMap[`../../locales/${ns}/${lang}.json`] // This path may vary for each project, adjust according to the actual situation

      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) // Switch languages again
  loadingLangLock.delete(lang)
}

useLayoutEffect(() => {
  const i18next = currentI18NInstance

  i18next.on('languageChanged', langChangedHandler)

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

Note that after the language loading is complete, we also need to call i18next.changeLanguage() again to switch languages.

Merging Namespace Resources in Production#

In the above example, we split multiple namespace resources, but in production, we want to merge all namespace resources into one file to reduce the number of network requests.

Let's write a Vite plugin to merge all namespace resources into one file in production.

function localesPlugin(): Plugin {
  return {
    name: 'locales-merge',
    enforce: 'post',
    generateBundle(options, bundle) {
      const localesDir = path.resolve(__dirname, '../locales') // Note to modify your locales directory
      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]
        }
      })
    },
  }
}

Then import it in vite.config.ts.

import localesPlugin from './locales-plugin'

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

Now, after packaging, a locales directory will be generated in the output, containing all the merged language resource files.

image

Of course, this plugin alone is not enough; we continue to modify the langChangedHandler method in i18n-provider.tsx.

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`) // Use import to load
      .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)
}

Differentiate between development and production environments, using the import method to load language resources in production, and using the import.meta.glob method in development.

Now, in the production environment, test switching languages, and you will see that only one file is requested.

image

Dynamically Loading Date Library i18n#

Similarly, we also need to consider the i18n of date libraries. Taking dayjs as an example.

We need to maintain an import table for Dayjs's internationalization configuration. For example:

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')],
}

Language codes can be found at: https://github.com/iamkun/dayjs/tree/dev/src/locale

Then we can use dayjsLocaleImportMap in langChangedHandler to load the corresponding language resources.

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

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

DX Optimization: HMR Support#

If we do nothing, in the development environment, modifying any language resource file in JSON will cause the page to reload completely, rather than seeing the modified text in real-time.

We can write a Vite plugin to implement 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"),
          },
        })

        // return empty array to prevent the default HMR
        return []
      }
    },
  }
}

/// Import in vite.config.ts

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

Now, when we modify any language resource file in JSON, the page will not completely reload, as Vite's HMR handling logic has been captured by us. Now we need to handle it manually. In the above plugin, when JSON is modified, we send an i18n-update event, which we can handle in 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", "") // Loading complete, notify components to re-render
    },
  )
}

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

Listen for this event in 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
          // Recreate the i18n instance
          const nextI18n = i18next.cloneInstance({
            lng: lang,
          })
          update(nextI18n)
        }),
      [update],
    )
}

Calculating Language Translation Completeness#

Since we use dynamically loaded language resources, calculating language translation completeness cannot be done at runtime; we need to calculate it at compile time.

Let's write a calculation method.

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

Then import this compile macro in Vite.

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

Use it in business:

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)}{" "}
                {/* If the percentage is 100, do not display */}
                {typeof percent === "number" ? (percent === 100 ? null : `(${percent}%)`) : null}
              </SelectItem>
            )
          })}
        </SelectContent>
      </Select>
    </div>
  )
}

image

Summary#

Above, we have implemented a relatively complete i18n solution.

Including:

  • Full import
  • On-demand import
  • Dynamic loading
  • Merging namespaces in production
  • Calculating language translation completeness
  • HMR support

This solution is applied in Follow.

🧡 Next generation information browser.

For specific implementation, you can refer to the code:

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

(By the way, there is a hidden Follow invitation code in this article, can you find it?)

This article is synchronized by Mix Space to xLog
The original link is https://innei.in/posts/tech/React-i18n-CSR-best-practices


Loading...
Ownership of this post data is guaranteed by blockchain and smart contracts to the creator alone.