feat: implement i18n lazy loading optimization

🚀 Performance improvements:
- Replace static language imports with dynamic imports
- Load only current language on startup instead of all 13 languages
- Implement on-demand loading when switching languages

📦 Bundle optimization:
- Reduce initial bundle size by avoiding preloading all language files
- Add resource caching to prevent reloading same language
- Support all 13 languages: en, ru, zh, fa, tt, id, ar, ko, tr, de, es, jp, zhtw

🔧 Technical changes:
- Convert i18n.ts to use dynamic import() for language resources
- Add async initializeLanguage() for app startup
- Create useI18n hook for language management with loading states
- Update main.tsx for async language initialization
- Fix language display labels in settings dropdown
- Maintain backward compatibility with existing language system

 Fixed issues:
- Resolve infinite loop in React components
- Fix missing language labels in settings UI
- Prevent circular dependencies in language loading
- Add proper error handling and fallback mechanisms
This commit is contained in:
Tunglies
2025-09-06 14:05:36 +08:00
Unverified
parent f70b8b1213
commit 0daa8720cd
5 changed files with 142 additions and 43 deletions

View File

@@ -19,7 +19,7 @@ import getSystem from "@/utils/get-system";
import { routers } from "@/pages/_routers";
import { TooltipIcon } from "@/components/base/base-tooltip-icon";
import { ContentCopyRounded } from "@mui/icons-material";
import { languages } from "@/services/i18n";
import { supportedLanguages } from "@/services/i18n";
import { showNotice } from "@/services/noticeService";
interface Props {
@@ -28,7 +28,7 @@ interface Props {
const OS = getSystem();
const languageOptions = Object.entries(languages).map(([code, _]) => {
const languageOptions = supportedLanguages.map((code) => {
const labels: { [key: string]: string } = {
en: "English",
ru: "Русский",
@@ -39,8 +39,13 @@ const languageOptions = Object.entries(languages).map(([code, _]) => {
ar: "العربية",
ko: "한국어",
tr: "Türkçe",
de: "Deutsch",
es: "Español",
jp: "日本語",
zhtw: "繁體中文",
};
return { code, label: labels[code] };
const label = labels[code] || code;
return { code, label };
});
const SettingVergeBasic = ({ onError }: Props) => {

45
src/hooks/use-i18n.ts Normal file
View File

@@ -0,0 +1,45 @@
import { useState, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { changeLanguage, supportedLanguages } from "@/services/i18n";
import { useVerge } from "./use-verge";
export const useI18n = () => {
const { i18n, t } = useTranslation();
const { patchVerge } = useVerge();
const [isLoading, setIsLoading] = useState(false);
const switchLanguage = useCallback(
async (language: string) => {
if (!supportedLanguages.includes(language)) {
console.warn(`Unsupported language: ${language}`);
return;
}
if (i18n.language === language) {
return;
}
setIsLoading(true);
try {
await changeLanguage(language);
if (patchVerge) {
await patchVerge({ language });
}
} catch (error) {
console.error("Failed to change language:", error);
} finally {
setIsLoading(false);
}
},
[i18n.language, patchVerge],
);
return {
currentLanguage: i18n.language,
supportedLanguages,
switchLanguage,
isLoading,
t,
};
};

View File

@@ -13,7 +13,7 @@ import { ComposeContextProvider } from "foxact/compose-context-provider";
import { BrowserRouter } from "react-router-dom";
import { BaseErrorBoundary } from "./components/base";
import Layout from "./pages/_layout";
import "./services/i18n";
import { initializeLanguage } from "./services/i18n";
import {
LoadingCacheProvider,
ThemeModeProvider,
@@ -39,29 +39,47 @@ document.addEventListener("keydown", (event) => {
["F", "G", "H", "J", "P", "Q", "R", "U"].includes(
event.key.toUpperCase(),
));
disabledShortcuts && event.preventDefault();
if (disabledShortcuts) {
event.preventDefault();
}
});
const contexts = [
<ThemeModeProvider />,
<LoadingCacheProvider />,
<UpdateStateProvider />,
];
const initializeApp = async () => {
try {
await initializeLanguage("zh");
const root = createRoot(container);
root.render(
<React.StrictMode>
<ComposeContextProvider contexts={contexts}>
<BaseErrorBoundary>
<AppDataProvider>
<BrowserRouter>
<Layout />
</BrowserRouter>
</AppDataProvider>
</BaseErrorBoundary>
</ComposeContextProvider>
</React.StrictMode>,
);
const contexts = [
<ThemeModeProvider key="theme" />,
<LoadingCacheProvider key="loading" />,
<UpdateStateProvider key="update" />,
];
const root = createRoot(container);
root.render(
<React.StrictMode>
<ComposeContextProvider contexts={contexts}>
<BaseErrorBoundary>
<AppDataProvider>
<BrowserRouter>
<Layout />
</BrowserRouter>
</AppDataProvider>
</BaseErrorBoundary>
</ComposeContextProvider>
</React.StrictMode>,
);
} catch (error) {
console.error("[main.tsx] 应用初始化失败:", error);
const root = createRoot(container);
root.render(
<div style={{ padding: "20px", color: "red" }}>
: {error instanceof Error ? error.message : String(error)}
</div>,
);
}
};
initializeApp();
// 错误处理
window.addEventListener("error", (event) => {

View File

@@ -1,5 +1,4 @@
import dayjs from "dayjs";
import i18next from "i18next";
import relativeTime from "dayjs/plugin/relativeTime";
import { SWRConfig, mutate } from "swr";
import { useEffect, useCallback, useState, useRef } from "react";
@@ -11,6 +10,7 @@ import { routers } from "./_routers";
import { getAxios } from "@/services/api";
import { forceRefreshClashConfig } from "@/services/cmds";
import { useVerge } from "@/hooks/use-verge";
import { useI18n } from "@/hooks/use-i18n";
import LogoSvg from "@/assets/image/logo.svg?react";
import iconLight from "@/assets/image/icon_light.svg?react";
import iconDark from "@/assets/image/icon_dark.svg?react";
@@ -158,6 +158,7 @@ const Layout = () => {
const [enableLog] = useEnableLog();
const [logLevel] = useLocalStorage<LogLevel>("log:log-level", "info");
const { language, start_page } = verge ?? {};
const { switchLanguage } = useI18n();
const navigate = useNavigate();
const location = useLocation();
const routersEles = useRoutes(routers);
@@ -439,9 +440,9 @@ const Layout = () => {
useEffect(() => {
if (language) {
dayjs.locale(language === "zh" ? "zh-cn" : language);
i18next.changeLanguage(language);
switchLanguage(language);
}
}, [language]);
}, [language, switchLanguage]);
useEffect(() => {
if (start_page) {

View File

@@ -1,29 +1,59 @@
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import en from "@/locales/en.json";
import ru from "@/locales/ru.json";
import zh from "@/locales/zh.json";
import fa from "@/locales/fa.json";
import tt from "@/locales/tt.json";
import id from "@/locales/id.json";
import ar from "@/locales/ar.json";
import ko from "@/locales/ko.json";
import tr from "@/locales/tr.json";
export const languages = { en, ru, zh, fa, tt, id, ar, ko, tr };
export const supportedLanguages = [
"en",
"ru",
"zh",
"fa",
"tt",
"id",
"ar",
"ko",
"tr",
"de",
"es",
"jp",
"zhtw",
];
const resources = Object.fromEntries(
Object.entries(languages).map(([key, value]) => [
key,
{ translation: value },
]),
export const languages: Record<string, any> = supportedLanguages.reduce(
(acc, lang) => {
acc[lang] = {};
return acc;
},
{} as Record<string, any>,
);
export const loadLanguage = async (language: string) => {
try {
const module = await import(`@/locales/${language}.json`);
return module.default;
} catch (error) {
console.warn(`Failed to load language ${language}, fallback to zh`);
const fallback = await import("@/locales/zh.json");
return fallback.default;
}
};
i18n.use(initReactI18next).init({
resources,
resources: {},
lng: "zh",
fallbackLng: "zh",
interpolation: {
escapeValue: false,
},
});
export const changeLanguage = async (language: string) => {
if (!i18n.hasResourceBundle(language, "translation")) {
const resources = await loadLanguage(language);
i18n.addResourceBundle(language, "translation", resources);
}
await i18n.changeLanguage(language);
};
export const initializeLanguage = async (initialLanguage: string = "zh") => {
await changeLanguage(initialLanguage);
};