IT博客汇
  • 首页
  • 精华
  • 技术
  • 设计
  • 资讯
  • 扯淡
  • 权利声明
  • 登录 注册

    React i18n CSR 最佳实践

    Innei发表于 2024-09-15 07:19:52
    love 0
    该渲染由 Shiro API 生成,可能存在排版问题,最佳体验请前往:https://innei.in/posts/tech/React-i18n-CSR-best-practices

    最近,社区又开始给 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,
      })
    }

    那么现在就有类型提示。

    分离 namespace

    当我们项目变得越来越大,我们就会发现,如果把所有的文字都放在一个文件里,会非常难维护。因此我们需要把文字拆分到不同的文件里。也就是 namespace。

    在 Follow 中,目前为止,一共拆分了以下几个 namespace:

    • 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

    按需加载语言

    当我们引入了越来越多的语言,我们就会发现,打包之后的体积也会越来越大。而用户一般只会使用一种语言,因此我们希望可以按需加载语言。

    但是其实 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, // [!code --]
        shortcuts: shortcuts_zhCN, // [!code --]
        common: common_zhCN, // [!code --]
        external: external_zhCN, // [!code --]
      },
      // 其他语言
    }

    这里我们除了英语是全量引入之外,其他语言都是按需引入。其次删除其他语言的大部分 namespace 资源,只保留 common 和 lang 两个 namespace。由于这两个 namespace 是通用模块的,并且大小也比较小,这里可以全量引入。在实际使用场景中,你也可以完全删除。比如:

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

    类似上面,只有一个英语的资源。现在我们可以改改文件名,resources.ts 改成 default-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) // 可以通过全量加载的英语中获取到所有的 namespace
    
      const res = await Promise.allSettled(
        // 通过 namespace 去加载对应的语言资源
        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() 去切换语言。

    在生产环境中合并 namespace 资源

    在上面的例子中,我们拆分了多个 namespace 资源,但是在生产环境中,我们希望可以把所有的 namespace 资源合并成一个文件,这样可以减少网络请求的次数。

    我们来写一个 Vite 插件,在生产环境中,把所有的 namespace 资源合并成一个文件。

    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 目录,下面包含了所有的语言资源的合并后的文件。

    当然除了这个插件还不行,我们继续修改 i18n-provider.tsx 中的 langChangedHandler 方法。

    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) {   // [!code ++]
        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 的方式加载语言资源。

    现在在生产环境中,测试切换语言,可以看到,只会请求一个文件。

    动态加载日期库的 i18n

    同样的,我们也要兼顾日期库的 i18n。这里以 dayjs 为例。

    我们需要维护一个 Dayjs 的国际化配置的 import 表。类似:

    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"),
              },
            })
    
            // return empty array to prevent the default 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>
      )
    }

    扁平 Key 的处理

    为了开发方便,我们一般让 i18n 的数据更加扁平。键值全部扁平处理,为了后续能够直接通过搜索找到对应的文案。

    例如这样:

    {
      "copied_link": "Copied link to clipboard",
      "feed.follower_one": "follower",
      "feed.follower_other": "followers"
    }

    那么在实际业务中,按照模块去划分,这种方式会造成大量的重复前缀。

    {
      "entry_actions.copy_link": "Copy link",
      "entry_actions.failed_to_save_to_eagle": "Failed to save to Eagle.",
      "entry_actions.failed_to_save_to_instapaper": "Failed to save to Instapaper.",
      "entry_actions.failed_to_save_to_readwise": "Failed to save to Readwise.",
      "entry_actions.link_copied": "Link copied to clipboard.",
      "entry_actions.mark_as_read": "Mark as read",
      "entry_actions.mark_as_unread": "Mark as unread",
      "entry_actions.open_in_browser": "Open in browser",
      "entry_actions.save_media_to_eagle": "Save media to Eagle",
      "entry_actions.save_to_instapaper": "Save to Instapaper",
      "entry_actions.save_to_readwise": "Save to Readwise",
      "entry_actions.saved_to_eagle": "Saved to Eagle.",
      "entry_actions.saved_to_instapaper": "Saved to Instapaper.",
      "entry_actions.saved_to_readwise": "Saved to Readwise.",
      "entry_actions.share": "Share",
      "entry_actions.star": "Star",
      "entry_actions.starred": "Starred.",
      "entry_actions.tip": "Tip",
      "entry_actions.unstar": "Unstar",
      "entry_actions.unstarred": "Unstarred.",
    }

    在「在生产环境中合并 namespace 资源」章节中提到,在生产中我们合并了 namespace,我们继续优化一下这个部分,让在生产中加载嵌套结构的 json 文件。

    function localesPlugin(): Plugin {
      return {
        name: "locales-merge",
        enforce: "post",
        generateBundle(_options, bundle) {
          const localesDir = path.resolve(__dirname, "../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] = {}
              }
    
              const obj = {} // [!code ++]
    
              const keys = Object.keys(content as object) // [!code ++]
              for (const accessorKey of keys) { // [!code ++]
                set(obj, accessorKey, (content as any)[accessorKey]) // [!code ++]
              } // [!code ++]
    
              languageResources[lang][namespace] = obj // [!code ++]
            })
          })
    
          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,
            })
          })
    
          // Remove original JSON chunks
          Object.keys(bundle).forEach((key) => {
            if (key.startsWith("locales/") && key.endsWith(".json")) {
              delete bundle[key]
            }
          })
        },
      }
    }

    这里我们通过 lodash.set 方法让扁平数据结构转换为嵌套结构。

    总结

    上面我们实现了一个比较完整的 i18n 解决方案。

    包括了:

    • 全量引入
    • 按需引入
    • 动态加载
    • 生产环境合并 namespace
    • 计算语言翻译完成度
    • HMR 支持

    此方案应用于 Follow 中。

    https://github.com/RSSNext/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 邀请码,你能找到吗?)

    看完了?说点什么呢



沪ICP备19023445号-2号
友情链接