代码链接:gitee.com/yu_jianchen…
系统目录
|- node_modules
|- src
|-- utils
|--- event-bus.ts
|-- config
|--- i18n-hmr.ts
|-- @type
|--- resources.ts
|--- i18next.d.ts
|--- constants.ts
|--- dayjs.d.ts
|-- providers
|--- i18n-provider.tsx
|-- locales
|--- common
|---- en.json
|---- zh_CN.json
|--- language
|---- en.json
|---- zh_CN.json
|-- App.tsx
|-- i18n.ts
|-.gitinore
|- eslint.config.js
|- index.html
|- package.json
|- pnpm-lock.yaml
|- README.md
|- tsconfig.json
|- tsconfig.node.json
|- vite.config.js
版本汇总
- node: v20.12.0
- pnpm: v8.14.3
基础配置
- 安装相关插件
pnpm install react-i18next i18next
- 在src目录下创建i18n.ts文件,配置i18n相关语法
import i18next from 'i18next'
import { initReactI18next } from 'react-i18next'
i18next.use(initReactI18next).init({
lng: 'zh',
fallbackLng: 'en',
resources: {
en: {
translation: en,
},
zh: {
translation: zhCN,
}
}
})
export const { t } = i18next;
文件路径简化
在vite.config.js文件下做如下配置,简化引入文件时的路径
import { defineConfig } from "vite";
export default definConfig({
resolve: {
alias: {
"@": "/src"
}
}
})
解决typescript类型问题
建立typescript类型检查及智能提示体系
- 在@type目录下创建resources文件,全量引入资源文件并规范类型操作
import common_en from "@/locales/modules/common/en.json";
import common_zhCN from "@/locales/modules/common/zh_CN.json";
import lang_zhCN from "@/locales/modules/languages/zh_CN.json";
import lang_en from "@/locales/modules/languages/en.json";
const resources = {
en: {
common: common_en,
lang: lang_en
},
'zh_CN': {
common: common_zhCN,
lang: lang_zhCN
},
} as const;
export default resources;
export type Resources = typeof resources;
- 在@type目录下创建i18next.d.ts文件,规范配置类型
import 'i18next'
import { Resources } from './resources'
declare module 'i18next' {
interface CustomTypeOptions {
defaultNS: 'translation';
resources: Resources;
}
}
- 修改i18n.ts文件
import resources from "./@types/resources";
import i18next from "i18next";
import { initReactI18next } from "react-i18next";
export const defaultNs = "common" as const;
export const fallbackLanguage = "en" as const;
export const language = "en" as const;
export const ns = ["common", "language"] as const;
export const initI18n = () => {
return i18next.use(initReactI18next).init({
lng: language,
fallbackLng: fallbackLanguage,
defaultNS: defaultNs,
ns,
resources,
});
}
按需加载语言
随着项目增大,全量加载语言资源包可能会影响打包体积,从而降低性能
使用按需加载来解决这个问题,但是i18next并没有按需加载的事件,所以自己写个逻辑
- 修改resources文件,默认加载英语资源
import common_en from "@/locales/modules/common/en.json";
import lang_en from "@/locales/modules/languages/en.json";
const resources = {
en: {
common: common_en,
lang: lang_en
}
} as const;
export default resources;
export type Resources = typeof resources;
- 在providers目录下i18n-provider.tsx文件中写按需相关逻辑
思路如下:
- 导出I18nProvider组件,组件监控i18n字段的值,字段变更触发langChangeHandler事件,开始走按需逻辑代码
- 创建Set集合,存储非重复成员
- 动态加载语言资源
- 调用i18next的addResourceBundle()加载资源
- 调用i18next的changeLanuage()切换语言
- 页面重排
代码如下:
import i18next from "i18next";
import { I18nextProvider } from "react-i18next";
import resources from "@/@types/resources";
import { initI18n } from "@/i18n";
import { atom, useAtom } from "jotai";
import {
useEffect,
useLayoutEffect,
type FC,
type PropsWithChildren,
} from "react";
// 初始化i18n
initI18n();
export const i18nAtom = atom(i18next);
const loadingLangLock = new Set<string>();
const langChangeHandler = async (lang: string) => {
// 如切换的语言重复,直接return
if (loadingLangLock.has(lang)) return;
loadingLangLock.add(lang);
// 动态加载指定路径下的语言资源包
const nsGlobbyMap = import.meta.glob("/src/locales/*/*.json", {
eager: true,
});
// 获取所有所需文件名
const namespaces = Object.keys(resources.en);
// 同步加载
const res = await Promise.allSettled(
namespaces.map(async (ns) => {
// 指定路径
const filePath = `/src/locales/${ns}/${lang}.json`;
// 加载资源
const module = nsGlobbyMap[filePath] as {
default: Record<string, any>;
};
if (!module) return;
// 执行i18next多语言加载事件
i18next.addResourceBundle(lang, ns, module.default, true, true);
})
);
// 异常捕获
for (const r of res) {
if (r.status === "rejected") {
console.log(`error: ${lang}`);
loadingLangLock.delete(lang);
}
return;
}
// i18next重新loading
await i18next.reloadResources();
// 切换所需语言
await i18next.changeLanguage(lang);
// 当前lang加载完毕后delete
loadingLangLock.delete(lang);
};
export const I18nProvider: FC<PropsWithChildren> = ({ children }) => {
const [currentI18NInstance, update] = useAtom(i18nAtom);
// 监控i18n字段变更
useLayoutEffect(() => {
const [currentI18NInstance, update] = useAtom(i18nAtom)
// 字段变更触发langChangeHandler
i18next.on("languageChanged", langChangeHandler);
// 组件销毁解除监听,防止内存泄漏
return () => {
i18next.off("languageChanged", langChangeHandler);
};
}, [currentI18NInstance]);
return (
<I18nextProvider i18n={currentI18NInstance}>{children}</I18nextProvider>
);
};
生产环境中合并namespace
精简请求,将多个不同业务的语言包汇总成一个语言包,这样在生产环境就只需要请求一次多语言包
思路如下
- 确定路径
- 遍历所有语言包
- 汇总所有字段
- 生成合并后的语言文件
- 指定vite生命周期下提交相关文件操作
- 删除原始的 JSON 文件
- 撰写相关逻辑
import { fileURLToPath } from "node:url";
import path from "node:path";
import fs from "node:fs";
import type { Plugin } from "vite";
import type { OutputBundle, OutputAsset } from "rollup";
import { set } from "es-toolkit/compat"
export default function localesPlugin(): Plugin {
return {
name: "locales-merge",
// enforce: pre -- 在其他插件之前执行 默认值--在核心插件执行之后 post -- 在其他插件之后执行
enforce: "post",
generateBundle(options: any, bundle: OutputBundle) {
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const localesDir = path.resolve(__dirname, "../locales/modules");
const namespace = fs.readdirSync(localesDir);
const languageResources: Record<string, Record<string, any>> = {};
// 收集所有语言资源
namespace.forEach((namespace: string) => {
const namespacePath = path.join(localesDir, namespace);
if (!fs.statSync(namespacePath).isDirectory()) return;
const files = fs
.readdirSync(namespacePath)
.filter((file: string) => file.endsWith(".json"));
files.forEach((file: string) => {
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 = {};
const keys = Object.keys(content as object);
for(const accessorKey of keys) {
set(obj, accessorKey, (content as any)[accessorKey]);
}
languageResources[lang][namespace] = obj;
});
});
// 生成合并后的语言文件
Object.entries(languageResources).forEach(([lang, resources]) => {
const fileName = `locales/${lang}.js`;
const content = `export default ${JSON.stringify(resources, null, 2)};`;
this.emitFile({
type: "asset",
fileName,
source: content,
});
});
// 删除原始的 JSON 文件
for (const fileName of Object.keys(bundle)) {
const file = bundle[fileName] as OutputAsset;
// 检查是否是 JSON 文件并且在 locales 目录下
if (
file.type === "asset" &&
fileName.includes("/locales/") &&
fileName.endsWith(".json")
) {
delete bundle[fileName];
}
}
},
};
}
- 在vite.config.js下导入
import { defineConfig } from "vite";
export default definConfig({
base: "./", // 新增这行配置
plugins: [react(), localesPlugin()],
resolve: {
alias: {
"@": "/src"
}
},
build: {
rollupOptions: {
input: {
main: "./index.html",
},
output: {
// 文件指定导出
assetFileNames: (assetInfo) => {
if (assetInfo.name.endsWith(".json")) {
return "[dir]/[name][extname]";
}
if (
assetInfo.name.includes("locales") &&
assetInfo.name.endsWith(".js")
) {
return "locales/[name][extname]";
}
if (assetInfo.name.endsWith(".css")) {
// 新增 CSS 文件路径处理
return "assets/[name][extname]";
}
return "assets/[name]-[hash][extname]";
},
},
},
},
})
3. 修改i18Provider.tsx文件,分情况导入文件
import i18next from "i18next";
import { atom, useAtom } from "jotai";
import {
useEffect,
useLayoutEffect,
type FC,
type PropsWithChildren,
} from "react";
import { I18nextProvider } from "react-i18next";
import resources from "@/@types/resources";
import { initI18n } from "@/i18n";
import { isEmptyObject } from "@/utils/index";
// 初始化i18n
initI18n();
export const i18nAtom = atom(i18next);
const loadingLangLock = new Set<string>();
const langChangeHandler = async (lang: string) => {
if (loadingLangLock.has(lang)) return;
// const loaded = i18next.getResourceBundle(lang, defaultNs);
// if (loaded) return;
loadingLangLock.add(lang);
if (import.meta.env.DEV) {
const nsGlobbyMap = import.meta.glob("/src/locales/modules/*/*.json", {
eager: true,
});
const namespaces = Object.keys(resources.en);
const res = await Promise.allSettled(
namespaces.map(async (ns) => {
const filePath = `/src/locales/modules/${ns}/${lang}.json`;
const module = nsGlobbyMap[filePath] as {
default: Record<string, any>;
};
if (!module) return;
i18next.addResourceBundle(lang, ns, module.default, true, true);
})
);
for (const r of res) {
if (r.status === "rejected") {
console.log(`error: ${lang}`);
loadingLangLock.delete(lang);
}
return;
}
} else {
const res = await import(/* @vite-ignore */ `../locales/${lang}.js`) // [!code ++]
.then((res) => res?.default || res)
.catch(() => {
loadingLangLock.delete(lang);
return {};
}); // 使用import的方式加载
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 I18nProvider: FC<PropsWithChildren> = ({ children }) => {
const [currentI18NInstance, update] = useAtom(i18nAtom);
useEffect(() => {
if (import.meta.env.DEV) {
EventBus.subscribe("I18N_UPDATE", (lang) => {
console.log(I18N_COMPLETENESS_MAP[lang], lang);
const nextI18n = i18next.cloneInstance({
lng: lang,
});
update(nextI18n);
});
}
}, [update]);
useLayoutEffect(() => {
const i18next = currentI18NInstance;
i18next.on("languageChanged", langChangeHandler);
return () => {
i18next.off("languageChanged", langChangeHandler);
};
}, [currentI18NInstance]);
return (
<I18nextProvider i18n={currentI18NInstance}>{children}</I18nextProvider>
);
};
- 效果如图:

动态加载日期库的i18n
兼顾日期库的多地区变化
- 新建constants配置文件,维护dayjs国际化配置的 import 表
type LocaleLoader = () => Promise<any>;
export const dayjsLocaleImportMap: Record<string, [string, LocaleLoader]> = {
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")],
};
- 新建day.d.ts文件,declare规范数据类型
declare module 'dayjs/locale/*' {
const locale: any;
export default locale;
}
- 修改i18nProvider文件,适配dayjs国际化配置
const loadLocale = async (lang: string) => {
if (lang in dayjsLocaleImportMap) {
const [localeName, importFn] = dayjsLocaleImportMap[lang];
await importFn();
dayjs.locale(localeName);
}
};
const langChangeHandler = async (lang: string) => {
loadLocale(lang).then(() => {
console.log(dayjs().format("YYYY年MM月DD日"));
});
省略一下代码...
}
DX优化: HMR支持
在开发环境中,多语言资源修改会导致页面整个重排,现在想让系统只热更新指定部分
思路如下
- 新建i18n-hmr捕获热更新操作,如热更新文件满足需求,触发server.ws.send事件
import { readFileSync } from "node:fs";
import type { Plugin } from "vite";
export 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"),
},
});
// 返回一个空数组,告诉 Vite 不需要重新加载模块
return [];
}
},
};
}
- 在i18n.ts中进行监控自定义派发时间,加载指定语言资源并reload
import i18next from "i18next";
import { initReactI18next } from "react-i18next";
import resources from "./@types/resources";
import { atom } from "jotai";
import { jotaiStore } from "@/lib/jotai";
import { EventBus } from "@/utils/event-bus";
export const defaultNs = "common" as const;
export const fallbackLanguage = "en" as const;
export const language = "en" as const;
export const ns = ["common", "settings"] as const;
export const i18nAtom = atom(i18next);
export const initI18n = () => {
const i18next = jotaiStore.get(i18nAtom);
return i18next.use(initReactI18next).init({
lng: language,
fallbackLng: fallbackLanguage,
defaultNS: defaultNs,
ns,
resources,
});
};
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(/modules\/([^/\\]+)/)?.[1];
if (!nsName) {
return;
}
const lang = file.split("/").pop()?.replace(".json", "");
if (!lang) {
return;
}
i18next.addResourceBundle(lang, nsName, resources, true, true);
await i18next.reloadResources(lang, nsName);
// 加载完成,通知组件重新渲染
import.meta.env.DEV && EventBus.dispatch("I18N_UPDATE", lang);
}
);
}
declare module "@/utils/event-bus" {
interface CustomEvent {
I18N_UPDATE: string;
}
}
export const { t } = i18next;
3. 新建Event-bus文件,自定义Event-bus订阅发布事件
export interface CustomEvent {}
export interface EventBusMap extends CustomEvent {}
class EventBusEvent extends Event {
static type = "EventBusEvent";
constructor(public _type: string, public data: any) {
super(EventBusEvent.type);
}
}
type AnyObject = Record<string, any>;
class EventBusStatic<E extends AnyObject> {
dispatch<T extends keyof E>(event: T, data: E[T]): void;
dispatch<T extends keyof E>(event: T): void;
dispatch<T extends keyof E>(event: T, data?: E[T]) {
window.dispatchEvent(new EventBusEvent(event as string, data));
}
subscribe<T extends keyof E>(event: T, callback: (data: E[T]) => void) {
const handler = (e: any) => {
if (e instanceof EventBusEvent && e._type === event) {
callback(e.data);
}
};
window.addEventListener(EventBusEvent.type, handler);
return this.unsubscribe.bind(this, event as string, handler);
}
unsubscribe(_event: string, handler: (e: any) => void) {
window.removeEventListener(EventBusEvent.type, handler);
}
}
export const EventBus = new EventBusStatic<EventBusMap>();
export const createEventBus = <E extends AnyObject>() =>
new EventBusStatic<E>();
4. 通知i18nProvider文件刷新组件
export const I18nProvider: FC<PropsWithChildren> = ({ children }) => {
const [currentI18NInstance, update] = useAtom(i18nAtom);
useEffect(() => {
if (import.meta.env.DEV) {
EventBus.subscribe("I18N_UPDATE", (lang) => {
console.log(I18N_COMPLETENESS_MAP[lang], lang);
const nextI18n = i18next.cloneInstance({
lng: lang,
});
update(nextI18n);
});
}
}, [update]);
useLayoutEffect(() => {
const i18next = currentI18NInstance;
i18next.on("languageChanged", langChangeHandler);
return () => {
i18next.off("languageChanged", langChangeHandler);
};
}, [currentI18NInstance]);
return (
<I18nextProvider i18n={currentI18NInstance}>{children}</I18nextProvider>
);
};
计算语言完成翻译度
自动化统计各语言翻译完成度,避免疏漏
- 新建i18n-completeness文件,自动化统计指定语言完成度
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;
console.log("开始计算对象的键数量:", obj);
for (const key in obj) {
if (typeof obj[key] === "object") {
count += countKeys(obj[key]);
} else {
count++;
}
}
return count;
}
function calculateCompleteness(localesDir: string): languageCompletion {
console.log("开始计算完整度,目录:", localesDir);
const namespaces = getNamespaces(localesDir);
console.log("找到的命名空间:", namespaces);
const languages = new Set<string>();
const keyCount: Record<string, number> = {};
namespaces.forEach((namespace) => {
const namespaceDir = path.join(localesDir, namespace);
console.log("处理命名空间目录:", namespaceDir);
const files = getLanguageFiles(namespaceDir);
console.log(`命名空间 ${namespace} 中的语言文件:`, files);
files.forEach((file: string) => {
const lang = path.basename(file, ".json");
console.log(`处理语言文件: ${file}, 提取语言代码: ${lang}`);
languages.add(lang); // [!code --]
try {
const filePath = path.join(namespaceDir, file);
console.log("读取文件:", filePath);
const content = JSON.parse(fs.readFileSync(filePath, "utf-8"));
const keys = countKeys(content);
console.log(`文件 ${file} 中的键数量:`, keys);
keyCount[lang] = (keyCount[lang] || 0) + keys;
} catch (error) {
console.error(`处理文件 ${file} 时出错:`, error);
}
});
});
console.log("所有语言的键数量:", keyCount);
console.log("检测到的语言:", Array.from(languages));
const enCount = keyCount["en"] || 0;
console.log("英语键数量 (基准):", enCount);
const completeness: languageCompletion = {};
languages.forEach((lang) => {
if (lang !== "en") {
const percent = Math.round((keyCount[lang] / enCount) * 100);
completeness[lang] = percent;
console.log(`语言 ${lang} 的完整度: ${percent}%`);
}
});
console.log("最终完整度结果:", completeness);
return completeness;
}
const i18n = calculateCompleteness(
path.resolve(__dirname, "../locales/modules")
);
export default i18n;
- 修改i18nProvider文件,console打印指定语言完成度
const langChangeHandler = async (lang: string) => {
console.log(I18N_COMPLETENESS_MAP[lang], lang);
}
- 效果如图

扁平key的处理
有些多语言是直接文件名.key这样的形式存放的,不是通过创建目录-文件-key的形式,比如
common_link: 1111
要对这种格式的多语言进行处理,处理成集合嵌套的方式,如下
common: {
link: 111
}
- 修改vite.render.config.ts
import { set } from "es-toolkit/compat"
...省略以上代码
generateBundle(options: any, bundle: OutputBundle) {
...省略以上代码
files.forEach((file: string) => {
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 ++]
});
...省略以下代码
}
该方案实现节点如下
- 按需引入
- 动态加载
- Event-bus自定义订阅发布事件
- 合并namespace
- 语言完成度自动化计算
- HMR支持
- 多种key适配