React i18n CSR 最佳实践

React i18n CSR 最佳实践

静かな森 (Innei)
该渲染由 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 资源,只保留 commonlang 两个 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&LTPropsWithChildren> = ({ children }) => {
const [currentI18NInstance, update] = useAtom(i18nAtom)
return (
&LTI18nextProvider i18n={currentI18NInstance}>{children}&LT/I18nextProvider>
)
}

然后监听 i18n 语言变化。这里注意即便是目前没有相关的语言,languageChanged 也会触发。

const loadingLangLock = new Set&LTstring>()

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&LTPropsWithChildren> = ({ 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&LTstring, 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&LTstring>()
const keyCount: Record&LTstring, 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 (
&LTdiv className="mb-3 mt-4 flex items-center justify-between">
&LTspan className="shrink-0 text-sm font-medium">{t("general.language")}&LT/span>
&LTSelect
defaultValue={finalRenderLanguage}
value={finalRenderLanguage}
onValueChange={(value) => {
setGeneralSetting("language", value as string)
i18n.changeLanguage(value as string)
}}
>
&LTSelectTrigger size="sm" className="w-48">
&LTSelectValue />
&LT/SelectTrigger>
&LTSelectContent position="item-aligned">
{currentSupportedLanguages.map((lang) => {
const percent = I18N_COMPLETENESS_MAP[lang]

return (
&LTSelectItem key={lang} value={lang}>
{langT(`langs.${lang}` as any)}{" "}
{/* 如果百分比是 100,则不显示 */}
{typeof percent === "number" ? (percent === 100 ? null : `(${percent}%)`) : null}
&LT/SelectItem>
)
})}
&LT/SelectContent>
&LT/Select>
&LT/div>
)
}

总结

上面我们实现了一个比较完整的 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 邀请码,你能找到吗?)


看完了?说点什么呢


Generated by RSStT. The copyright belongs to the original author.

Source

Report Page