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:
@@ -19,7 +19,7 @@ import getSystem from "@/utils/get-system";
|
|||||||
import { routers } from "@/pages/_routers";
|
import { routers } from "@/pages/_routers";
|
||||||
import { TooltipIcon } from "@/components/base/base-tooltip-icon";
|
import { TooltipIcon } from "@/components/base/base-tooltip-icon";
|
||||||
import { ContentCopyRounded } from "@mui/icons-material";
|
import { ContentCopyRounded } from "@mui/icons-material";
|
||||||
import { languages } from "@/services/i18n";
|
import { supportedLanguages } from "@/services/i18n";
|
||||||
import { showNotice } from "@/services/noticeService";
|
import { showNotice } from "@/services/noticeService";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -28,7 +28,7 @@ interface Props {
|
|||||||
|
|
||||||
const OS = getSystem();
|
const OS = getSystem();
|
||||||
|
|
||||||
const languageOptions = Object.entries(languages).map(([code, _]) => {
|
const languageOptions = supportedLanguages.map((code) => {
|
||||||
const labels: { [key: string]: string } = {
|
const labels: { [key: string]: string } = {
|
||||||
en: "English",
|
en: "English",
|
||||||
ru: "Русский",
|
ru: "Русский",
|
||||||
@@ -39,8 +39,13 @@ const languageOptions = Object.entries(languages).map(([code, _]) => {
|
|||||||
ar: "العربية",
|
ar: "العربية",
|
||||||
ko: "한국어",
|
ko: "한국어",
|
||||||
tr: "Türkçe",
|
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) => {
|
const SettingVergeBasic = ({ onError }: Props) => {
|
||||||
|
|||||||
45
src/hooks/use-i18n.ts
Normal file
45
src/hooks/use-i18n.ts
Normal 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,
|
||||||
|
};
|
||||||
|
};
|
||||||
60
src/main.tsx
60
src/main.tsx
@@ -13,7 +13,7 @@ import { ComposeContextProvider } from "foxact/compose-context-provider";
|
|||||||
import { BrowserRouter } from "react-router-dom";
|
import { BrowserRouter } from "react-router-dom";
|
||||||
import { BaseErrorBoundary } from "./components/base";
|
import { BaseErrorBoundary } from "./components/base";
|
||||||
import Layout from "./pages/_layout";
|
import Layout from "./pages/_layout";
|
||||||
import "./services/i18n";
|
import { initializeLanguage } from "./services/i18n";
|
||||||
import {
|
import {
|
||||||
LoadingCacheProvider,
|
LoadingCacheProvider,
|
||||||
ThemeModeProvider,
|
ThemeModeProvider,
|
||||||
@@ -39,29 +39,47 @@ document.addEventListener("keydown", (event) => {
|
|||||||
["F", "G", "H", "J", "P", "Q", "R", "U"].includes(
|
["F", "G", "H", "J", "P", "Q", "R", "U"].includes(
|
||||||
event.key.toUpperCase(),
|
event.key.toUpperCase(),
|
||||||
));
|
));
|
||||||
disabledShortcuts && event.preventDefault();
|
if (disabledShortcuts) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const contexts = [
|
const initializeApp = async () => {
|
||||||
<ThemeModeProvider />,
|
try {
|
||||||
<LoadingCacheProvider />,
|
await initializeLanguage("zh");
|
||||||
<UpdateStateProvider />,
|
|
||||||
];
|
|
||||||
|
|
||||||
const root = createRoot(container);
|
const contexts = [
|
||||||
root.render(
|
<ThemeModeProvider key="theme" />,
|
||||||
<React.StrictMode>
|
<LoadingCacheProvider key="loading" />,
|
||||||
<ComposeContextProvider contexts={contexts}>
|
<UpdateStateProvider key="update" />,
|
||||||
<BaseErrorBoundary>
|
];
|
||||||
<AppDataProvider>
|
|
||||||
<BrowserRouter>
|
const root = createRoot(container);
|
||||||
<Layout />
|
root.render(
|
||||||
</BrowserRouter>
|
<React.StrictMode>
|
||||||
</AppDataProvider>
|
<ComposeContextProvider contexts={contexts}>
|
||||||
</BaseErrorBoundary>
|
<BaseErrorBoundary>
|
||||||
</ComposeContextProvider>
|
<AppDataProvider>
|
||||||
</React.StrictMode>,
|
<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) => {
|
window.addEventListener("error", (event) => {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import i18next from "i18next";
|
|
||||||
import relativeTime from "dayjs/plugin/relativeTime";
|
import relativeTime from "dayjs/plugin/relativeTime";
|
||||||
import { SWRConfig, mutate } from "swr";
|
import { SWRConfig, mutate } from "swr";
|
||||||
import { useEffect, useCallback, useState, useRef } from "react";
|
import { useEffect, useCallback, useState, useRef } from "react";
|
||||||
@@ -11,6 +10,7 @@ import { routers } from "./_routers";
|
|||||||
import { getAxios } from "@/services/api";
|
import { getAxios } from "@/services/api";
|
||||||
import { forceRefreshClashConfig } from "@/services/cmds";
|
import { forceRefreshClashConfig } from "@/services/cmds";
|
||||||
import { useVerge } from "@/hooks/use-verge";
|
import { useVerge } from "@/hooks/use-verge";
|
||||||
|
import { useI18n } from "@/hooks/use-i18n";
|
||||||
import LogoSvg from "@/assets/image/logo.svg?react";
|
import LogoSvg from "@/assets/image/logo.svg?react";
|
||||||
import iconLight from "@/assets/image/icon_light.svg?react";
|
import iconLight from "@/assets/image/icon_light.svg?react";
|
||||||
import iconDark from "@/assets/image/icon_dark.svg?react";
|
import iconDark from "@/assets/image/icon_dark.svg?react";
|
||||||
@@ -158,6 +158,7 @@ const Layout = () => {
|
|||||||
const [enableLog] = useEnableLog();
|
const [enableLog] = useEnableLog();
|
||||||
const [logLevel] = useLocalStorage<LogLevel>("log:log-level", "info");
|
const [logLevel] = useLocalStorage<LogLevel>("log:log-level", "info");
|
||||||
const { language, start_page } = verge ?? {};
|
const { language, start_page } = verge ?? {};
|
||||||
|
const { switchLanguage } = useI18n();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const routersEles = useRoutes(routers);
|
const routersEles = useRoutes(routers);
|
||||||
@@ -439,9 +440,9 @@ const Layout = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (language) {
|
if (language) {
|
||||||
dayjs.locale(language === "zh" ? "zh-cn" : language);
|
dayjs.locale(language === "zh" ? "zh-cn" : language);
|
||||||
i18next.changeLanguage(language);
|
switchLanguage(language);
|
||||||
}
|
}
|
||||||
}, [language]);
|
}, [language, switchLanguage]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (start_page) {
|
if (start_page) {
|
||||||
|
|||||||
@@ -1,29 +1,59 @@
|
|||||||
import i18n from "i18next";
|
import i18n from "i18next";
|
||||||
import { initReactI18next } from "react-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(
|
export const languages: Record<string, any> = supportedLanguages.reduce(
|
||||||
Object.entries(languages).map(([key, value]) => [
|
(acc, lang) => {
|
||||||
key,
|
acc[lang] = {};
|
||||||
{ translation: value },
|
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({
|
i18n.use(initReactI18next).init({
|
||||||
resources,
|
resources: {},
|
||||||
lng: "zh",
|
lng: "zh",
|
||||||
fallbackLng: "zh",
|
fallbackLng: "zh",
|
||||||
interpolation: {
|
interpolation: {
|
||||||
escapeValue: false,
|
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);
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user