最近,社区又开始给 Follow 上强度了,搞起了 i18n 工作。
开始之前有一个完善的 i18n 基建才是硬道理。我们选择 react-i18next。
接下来,我们会由浅入深去配置一个完善的 i18n 基建。
建立一个 i18n 配置文件,比如 i18n.ts
。
随后在入口文件中引入。
那么这样就可以在项目中使用 i18n 了。
上面的代码虽然可以正常工作,但是在 TypeScript 中,你得不到任何类型检查以及智能提示。
那么,我们希望可以有一个类型安全的写法。
我们按照官网的推荐做法,可以把 resources 放到 @types
中,然后建立 i18next.d.ts
文件。
然后修改 i18n.ts
文件。
那么现在就有类型提示。
当我们项目变得越来越大,我们就会发现,如果把所有的文字都放在一个文件里,会非常难维护。因此我们需要把文字拆分到不同的文件里。也就是 namespace。
在 Follow 中,目前为止,一共拆分了以下几个 namespace:
app
应用相关lang
语言external
外部页面settings
设置shortcuts
快捷键common
通用目录结构如下:
这样拆分之后,我们只需要在上面的 resources.d.ts
中引入所有的语言文件即可。
当我们引入了越来越多的语言,我们就会发现,打包之后的体积也会越来越大。而用户一般只会使用一种语言,因此我们希望可以按需加载语言。
但是其实 i18next 并没有内置按需加载的逻辑,因此我们需要自己实现。首先我们需要修改 resource.ts
文件。
这里我们除了英语是全量引入之外,其他语言都是按需引入。其次删除其他语言的大部分 namespace 资源,只保留 common
和 lang
两个 namespace。由于这两个 namespace 是通用模块的,并且大小也比较小,这里可以全量引入。在实际使用场景中,你也可以完全删除。比如:
类似上面,只有一个英语的资源。现在我们可以改改文件名,resources.ts
改成 default-resources.ts
。其他的不变。
接下来我们来实现如何按需加载语言。
大概的思路是:
import()
去加载需要的语言资源的,然后使用 i18n.addResourceBundle()
去完成加载i18n.changeLanguage()
去切换语言i18next
实例,让组件重新渲染创建一个 I18nProvider
去实现这个逻辑。
然后监听 i18n 语言变化。这里注意即便是目前没有相关的语言,languageChanged
也会触发。
这里注意,当语言加载完成之后,我们还需要重新调用 i18next.changeLanguage()
去切换语言。
在上面的例子中,我们拆分了多个 namespace 资源,但是在生产环境中,我们希望可以把所有的 namespace 资源合并成一个文件,这样可以减少网络请求的次数。
我们来写一个 Vite 插件,在生产环境中,把所有的 namespace 资源合并成一个文件。
然后在 vite.config.ts
中引入。
现在,打包之后的产物中,会生成一个 locales
目录,下面包含了所有的语言资源的合并后的文件。
当然除了这个插件还不行,我们继续修改 i18n-provider.tsx
中的 langChangedHandler
方法。
区分开发环境和生产环境,在生产环境中使用 import
的方式加载语言资源,在开发环境中使用 import.meta.glob
的方式加载语言资源。
现在在生产环境中,测试切换语言,可以看到,只会请求一个文件。
同样的,我们也要兼顾日期库的 i18n。这里以 dayjs
为例。
我们需要维护一个 Dayjs 的国际化配置的 import 表。类似:
语言代码通过:https://github.com/iamkun/dayjs/tree/dev/src/locale 获取
然后我们就可以在 langChangedHandler
中使用 dayjsLocaleImportMap
去加载对应的语言资源。
如果我们不做任何处理,在开发环境中,当我们修改任何语言资源文件的 json,都会导致页面完全重载。而不是实时看到修改后的文字。
我们可以写一个 Vite 插件去实现 HMR。
现在当我们修改任何语言资源文件的 json,都不会导致页面完全重载,Vite 的 HMR 处理逻辑已经被我们捕获了。那么现在我们需要去手动处理他。在上面的插件中,当 json 修改,我们会发送一个 i18n-update
事件,我们可以在 i18n.ts
中处理该事件。
在 I18nProvider
中监听该事件。
由于我们使用了动态加载的语言资源,那么计算语言翻译完成度不能在运行时进行了,我们需要在编译时就计算出来。
我们来写一个计算方法。
然后在 Vite 中引入这个编译宏。
在业务中使用:
为了开发方便,我们一般让 i18n 的数据更加扁平。键值全部扁平处理,为了后续能够直接通过搜索找到对应的文案。
例如这样:
那么在实际业务中,按照模块去划分,这种方式会造成大量的重复前缀。
在「在生产环境中合并 namespace 资源」章节中提到,在生产中我们合并了 namespace,我们继续优化一下这个部分,让在生产中加载嵌套结构的 json 文件。
这里我们通过 lodash.set
方法让扁平数据结构转换为嵌套结构。
上面我们实现了一个比较完整的 i18n 解决方案。
包括了:
此方案应用于 Follow 中。
具体实现可以参考代码:
(对了,此文章中隐藏了一枚 Follow 邀请码,你能找到吗?)
npm install react-i18next i18next
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'
import { useTranslation } from 'react-i18next'
const { t } = useTranslation()
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'
}
}
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,
})
}
. 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
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
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 --]
},
// 其他语言
}
export const resources = {
en: {
app: en,
lang: lang_en,
common: common_en,
external: external_en,
settings: settings_en,
shortcuts: shortcuts_en,
},
}
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>
)
}
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])
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]
}
})
},
}
}
import localesPlugin from './locales-plugin'
export default defineConfig({
plugins: [localesPlugin()],
})
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)
}
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')],
}
const langChangedHandler = async (lang: string) => {
const dayjsImport = dayjsLocaleImportMap[lang]
if (dayjsImport) {
const [locale, loader] = dayjsImport
loader().then(() => {
dayjs.locale(locale)
})
}
}
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()],
})
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
}
}
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
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>
)
}
{
"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.",
}
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]
}
})
},
}
}
本文详细介绍了在 React 项目(以 Follow 为例)中用 react-i18next 搭建完善的多语言(i18n)基建流程,包括:基础配置、TypeScript 类型支持、namespace 拆分、按需加载语言、生产环境 namespace 合并、动态加载日期库 dayjs 的多语言包、开发环境下 JSON 语言包的 HMR 支持,以及利用宏计算编译时的翻译完成度等。实现方式涉及 Vite 插件自定义、资源文件结构优化、和代码适配,为大中型前端项目的国际化提供了完整的实践方案。
隐藏的 Follow 邀请码在整篇文章中未被直接明示或以明文形式给出。