最近,社區又開始給 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,
shortcuts: shortcuts_zhCN,
common: common_zhCN,
external: external_zhCN,
},
// 其他語言
}
這裡我們除了英語是全量引入之外,其他語言都是按需引入。其次刪除其他語言的大部分 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
。其他的不變。
接下來我們來實現如何按需加載語言。
大概的思路是:
- 透過
import()
去加載需要的語言資源的,然後使用i18n.addResourceBundle()
去完成加載 - 然後再次調用
i18n.changeLanguage()
去切換語言 - 重新設置一個
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) {
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>
)
}
總結#
上面我們實現了一個比較完整的 i18n 解決方案。
包括了:
- 全量引入
- 按需引入
- 動態加載
- 生產環境合併 namespace
- 計算語言翻譯完成度
- 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