CSR mode下基于react+i18next实践国际化多语言解决方案

代码链接:gitee.com/yu_jianchen...

系统目录

lua 复制代码
|- 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

基础配置

  1. 安装相关插件
命令语句 复制代码
pnpm install react-i18next i18next
  1. 在src目录下创建i18n.ts文件,配置i18n相关语法
javascript 复制代码
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文件下做如下配置,简化引入文件时的路径

javascript 复制代码
import { defineConfig } from "vite";
export default definConfig({
    resolve: {
        alias: {
            "@": "/src"
        }
    }
})

解决typescript类型问题

建立typescript类型检查及智能提示体系

  1. 在@type目录下创建resources文件,全量引入资源文件并规范类型操作
javascript 复制代码
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;
  1. 在@type目录下创建i18next.d.ts文件,规范配置类型
javascript 复制代码
import 'i18next'
import { Resources } from './resources'

declare module 'i18next' {
    interface CustomTypeOptions {
        defaultNS: 'translation';
        resources: Resources;
    }
}
  1. 修改i18n.ts文件
javascript 复制代码
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并没有按需加载的事件,所以自己写个逻辑

  1. 修改resources文件,默认加载英语资源
javascript 复制代码
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;
  1. 在providers目录下i18n-provider.tsx文件中写按需相关逻辑 思路如下:
  • 导出I18nProvider组件,组件监控i18n字段的值,字段变更触发langChangeHandler事件,开始走按需逻辑代码
  • 创建Set集合,存储非重复成员
  • 动态加载语言资源
  • 调用i18next的addResourceBundle()加载资源
  • 调用i18next的changeLanuage()切换语言
  • 页面重排

代码如下:

javascript 复制代码
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 文件
  1. 撰写相关逻辑
javascript 复制代码
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];
        }
      }
    },
  };
}
  1. 在vite.config.js下导入
php 复制代码
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]";
        },
      },
    },
  },
})
  1. 修改i18Provider.tsx文件,分情况导入文件
javascript 复制代码
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>
  );
};
  1. 效果如图:

动态加载日期库的i18n

兼顾日期库的多地区变化

  1. 新建constants配置文件,维护dayjs国际化配置的 import 表
javascript 复制代码
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")],
};
  1. 新建day.d.ts文件,declare规范数据类型
javascript 复制代码
declare module 'dayjs/locale/*' {
  const locale: any;
  export default locale;
}
  1. 修改i18nProvider文件,适配dayjs国际化配置
javascript 复制代码
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支持

在开发环境中,多语言资源修改会导致页面整个重排,现在想让系统只热更新指定部分 思路如下

  1. 新建i18n-hmr捕获热更新操作,如热更新文件满足需求,触发server.ws.send事件
javascript 复制代码
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 [];
     }
   },
 };
}
  1. 在i18n.ts中进行监控自定义派发时间,加载指定语言资源并reload
typescript 复制代码
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;
  1. 新建Event-bus文件,自定义Event-bus订阅发布事件
typescript 复制代码
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>();
  1. 通知i18nProvider文件刷新组件
ini 复制代码
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>
  );
};

计算语言完成翻译度

自动化统计各语言翻译完成度,避免疏漏

  1. 新建i18n-completeness文件,自动化统计指定语言完成度
javascript 复制代码
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;
  1. 修改i18nProvider文件,console打印指定语言完成度
javascript 复制代码
const langChangeHandler = async (lang: string) => {
    console.log(I18N_COMPLETENESS_MAP[lang], lang);
}
  1. 效果如图

扁平key的处理

有些多语言是直接文件名.key这样的形式存放的,不是通过创建目录-文件-key的形式,比如

yaml 复制代码
common_link: 1111

要对这种格式的多语言进行处理,处理成集合嵌套的方式,如下

css 复制代码
common: {
    link: 111
}
  1. 修改vite.render.config.ts
typescript 复制代码
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 ++]
        });
    ...省略以下代码
}

该方案实现节点如下

  1. 按需引入
  2. 动态加载
  3. Event-bus自定义订阅发布事件
  4. 合并namespace
  5. 语言完成度自动化计算
  6. HMR支持
  7. 多种key适配
相关推荐
旭久1 小时前
react+antd中做一个外部按钮新增 表格内部本地新增一条数据并且支持编辑删除(无难度上手)
前端·javascript·react.js
windyrain1 小时前
ant design pro 模版简化工具
前端·react.js·ant design
GISer_Jing2 小时前
React-Markdown详解
前端·react.js·前端框架
太阳花ˉ2 小时前
React(九)React Hooks
前端·react.js
旭久4 小时前
react+antd封装一个可回车自定义option的select并且与某些内容相互禁用
前端·javascript·react.js
阿丽塔~4 小时前
React 函数组件间怎么进行通信?
前端·javascript·react.js
前端菜鸟来报道5 小时前
前端react 实现分段进度条
前端·javascript·react.js·进度条
傻球8 小时前
Jotai 使用详解:React 轻量级状态管理库
前端·react.js
市民中心的蟋蟀11 小时前
第四章: 使用订阅来共享模块状态
前端·javascript·react.js
水煮白菜王12 小时前
首屏加载时间优化解决
前端·javascript·react.js