From cf1eb6965ea1e4ac9a3954bfe0bac7a8c0aff7ee Mon Sep 17 00:00:00 2001 From: xiaomai Date: Wed, 6 May 2026 10:10:07 +0800 Subject: [PATCH] refactor(i18n): isolate Vue I18n instances per request for SSR Replace global I18n singleton with a factory function Inject request-specific I18n instances into Nuxt app and SEO metadata Prevent cross-request locale state pollution during server-side rendering --- DESIGN.md | 1 + SSR_MIGRATION_TASKLIST.md | 8 ++++++- frontend/plugins/01-i18n.ts | 12 +++++++++- frontend/plugins/02-seo.ts | 7 ++++-- frontend/src/i18n.ts | 45 ++++++++++++++++++++++++------------- frontend/src/seo.ts | 40 ++++++++++++++++++++++++++------- 6 files changed, 86 insertions(+), 27 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 805a68a..785df21 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -33,6 +33,7 @@ - 前端使用 Vue I18n 管理界面文案,并通过 `X-Locale` 请求头告知后端当前语言。 - 前端当前语言保存在 `localStorage` 的 `pokopia_locale`。 +- Nuxt SSR 运行时每个 Nuxt app/request 创建独立 Vue I18n 实例,避免跨请求共享 locale 或系统文案状态;服务端默认使用 `en`,客户端 hydration 后按 `pokopia_locale` 恢复用户语言。 - 后端默认语言为 `en`。 - 语言配置存储在 `languages`: - `code` diff --git a/SSR_MIGRATION_TASKLIST.md b/SSR_MIGRATION_TASKLIST.md index 7a117af..ac6f890 100644 --- a/SSR_MIGRATION_TASKLIST.md +++ b/SSR_MIGRATION_TASKLIST.md @@ -61,7 +61,7 @@ Keep this file aligned with implementation progress while the SSR migration is i - [ ] Change Nuxt config from `ssr: false` to `ssr: true` only after browser-only usage and auth strategy are ready. - [ ] Split plugins by runtime where needed: `.client.ts` for DOM/event/storage logic, `.server.ts` for SSR-only initialization, and universal plugins only for code safe in both contexts. -- [ ] Ensure Vue I18n is installed safely for SSR and does not share mutable per-request state across users. +- [x] Ensure Vue I18n is installed safely for SSR and does not share mutable per-request state across users. - [x] Move direct `document.head` SEO mutation to Nuxt `useHead` / `useSeoMeta` or another SSR-aware head strategy. - [x] Ensure route metadata remains the source for default SEO, required auth, required permission, editor modal behavior, and noindex rules. - [ ] Confirm route-backed modal pages still preserve underlying page context and avoid unwanted scroll jumps. @@ -73,6 +73,12 @@ Keep this file aligned with implementation progress while the SSR migration is i - `frontend/plugins/02-seo.ts` is a universal Nuxt plugin that binds route metadata and client-side detail overrides to `useHead`. - The Nuxt config analytics script is declarative and no longer injects a script with `document.head.appendChild`. +### Phase 4 I18n Notes + +- `frontend/src/i18n.ts` now exports a Vue I18n factory instead of a module-level singleton. +- `frontend/plugins/01-i18n.ts` creates and installs one I18n instance per Nuxt app/request; only the browser instance is registered for legacy helpers that need localStorage and locale-change events. +- SEO route metadata translation uses the current Nuxt app's I18n translator instead of importing a shared global I18n instance. + ## Phase 5: Server-Side Data And SEO - [ ] Implement SSR data loading for stable public routes in small groups, starting with low-risk public pages. diff --git a/frontend/plugins/01-i18n.ts b/frontend/plugins/01-i18n.ts index 85decd0..1d5f42d 100644 --- a/frontend/plugins/01-i18n.ts +++ b/frontend/plugins/01-i18n.ts @@ -1,5 +1,15 @@ -import { i18n } from '../src/i18n'; +import { createPokopiaI18n, setActiveI18n } from '../src/i18n'; export default defineNuxtPlugin((nuxtApp) => { + const i18n = createPokopiaI18n(); + if (import.meta.client) { + setActiveI18n(i18n); + } + nuxtApp.vueApp.use(i18n); + return { + provide: { + pokopiaI18n: i18n + } + }; }); diff --git a/frontend/plugins/02-seo.ts b/frontend/plugins/02-seo.ts index 005ac27..749f5ac 100644 --- a/frontend/plugins/02-seo.ts +++ b/frontend/plugins/02-seo.ts @@ -1,11 +1,13 @@ import { computed, ref } from 'vue'; import { onLocaleChange } from '../src/i18n'; -import { applyRouteSeo, onSeoChange, resolveRouteSeo, type ResolvedSeoConfig } from '../src/seo'; +import { applyRouteSeo, onSeoChange, resolveRouteSeo, setSeoTranslator, type ResolvedSeoConfig } from '../src/seo'; export default defineNuxtPlugin(() => { const router = useRouter(); + const nuxtApp = useNuxtApp(); + const t = (nuxtApp.$pokopiaI18n as { global: { t: (key: string, values?: Record) => string } }).global.t; const dynamicSeo = ref(null); - const activeSeo = computed(() => dynamicSeo.value ?? resolveRouteSeo(router.currentRoute.value)); + const activeSeo = computed(() => dynamicSeo.value ?? resolveRouteSeo(router.currentRoute.value, t)); useHead(() => ({ title: activeSeo.value.title, @@ -42,6 +44,7 @@ export default defineNuxtPlugin(() => { return; } + setSeoTranslator(t); onSeoChange((seo) => { dynamicSeo.value = seo; }); diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index 7ef9df4..377fb3f 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -18,13 +18,22 @@ type SystemWordingsResponse = { export type MessageKey = keyof typeof messages.en; -export const i18n = createI18n({ - legacy: false, - globalInjection: true, - locale: readStoredLocale(), - fallbackLocale: defaultLocale, - messages -}); +export function createPokopiaI18n(initialLocale = readStoredLocale()) { + return createI18n({ + legacy: false, + globalInjection: true, + locale: initialLocale || defaultLocale, + fallbackLocale: defaultLocale, + messages + }); +} + +type PokopiaI18n = ReturnType; +let activeI18n: PokopiaI18n | null = null; + +export function setActiveI18n(instance: PokopiaI18n): void { + activeI18n = instance; +} export function setSystemWordingsApiBaseUrl(value: unknown): void { setSystemWordingsApiBaseUrls({ browser: value, server: value }); @@ -54,7 +63,7 @@ function activeApiBaseUrl(): string { return typeof window === 'undefined' ? serverApiBaseUrl : browserApiBaseUrl; } -function readStoredLocale(): string { +export function readStoredLocale(): string { if (typeof localStorage === 'undefined') { return defaultLocale; } @@ -64,11 +73,11 @@ function readStoredLocale(): string { } function globalLocaleRef() { - return i18n.global.locale as unknown as { value: string }; + return activeI18n?.global.locale as unknown as { value: string } | undefined; } export function getCurrentLocale(): string { - return globalLocaleRef().value || defaultLocale; + return globalLocaleRef()?.value || defaultLocale; } function isMessageTree(value: SystemWordingTree[string] | undefined): value is SystemWordingTree { @@ -97,6 +106,11 @@ function builtInMessagesFor(locale: string): SystemWordingTree { } export async function loadSystemWordings(locale = getCurrentLocale(), force = false): Promise { + if (!activeI18n) { + return; + } + + const targetI18n = activeI18n; const targetLocale = locale || defaultLocale; if (!force && loadedWordingLocales.has(targetLocale)) { return; @@ -116,13 +130,13 @@ export async function loadSystemWordings(locale = getCurrentLocale(), force = fa } const data = (await response.json()) as SystemWordingsResponse; - i18n.global.setLocaleMessage( + targetI18n.global.setLocaleMessage( targetLocale, mergeMessageTrees(messages[defaultLocale], messages[targetLocale], data.messages) as never ); loadedWordingLocales.add(targetLocale); } catch { - i18n.global.setLocaleMessage(targetLocale, builtInMessagesFor(targetLocale) as never); + targetI18n.global.setLocaleMessage(targetLocale, builtInMessagesFor(targetLocale) as never); } finally { pendingWordingLoads.delete(targetLocale); } @@ -134,7 +148,10 @@ export async function loadSystemWordings(locale = getCurrentLocale(), force = fa export function setCurrentLocale(locale: string): void { const nextLocale = locale || defaultLocale; - globalLocaleRef().value = nextLocale; + const localeRef = globalLocaleRef(); + if (localeRef) { + localeRef.value = nextLocale; + } if (typeof document !== 'undefined') { document.documentElement.lang = nextLocale; @@ -157,5 +174,3 @@ export function onLocaleChange(callback: () => void): () => void { window.addEventListener(localeChangeEvent, callback); return () => window.removeEventListener(localeChangeEvent, callback); } - -setCurrentLocale(getCurrentLocale()); diff --git a/frontend/src/seo.ts b/frontend/src/seo.ts index 4591894..83f4a39 100644 --- a/frontend/src/seo.ts +++ b/frontend/src/seo.ts @@ -1,5 +1,6 @@ import type { RouteLocationNormalizedLoaded } from 'vue-router'; -import { getCurrentLocale, i18n } from './i18n'; +import { getCurrentLocale } from './i18n'; +import { defaultLocale, systemWordingMessages, type SystemWordingTree } from '../../system-wordings'; const siteName = 'Pokopia Wiki'; const defaultCanonicalPath = '/'; @@ -10,6 +11,7 @@ let currentSeo = resolveSeo(); const seoListeners = new Set<(seo: ResolvedSeoConfig) => void>(); type TranslationValues = Record; +type Translator = (key: string, values?: TranslationValues) => string; export type RouteSeoConfig = { title?: string; @@ -39,7 +41,12 @@ export type ResolvedSeoConfig = { structuredData: Record; }; -const translate = i18n.global.t as (key: string, values?: TranslationValues) => string; +const messages = systemWordingMessages as unknown as Record; +let activeTranslator: Translator | null = null; + +export function setSeoTranslator(translator: Translator): void { + activeTranslator = translator; +} export function setConfiguredSiteUrl(value: unknown): void { if (typeof value === 'string' && value.trim() !== '') { @@ -86,7 +93,24 @@ function metaTitle(title?: string): string { } function metaDescription(description?: string): string { - return description?.trim() || translate('seo.siteDescription'); + return description?.trim() || translateSeo('seo.siteDescription'); +} + +function builtInTranslate(key: string, values: TranslationValues = {}): string { + let message: SystemWordingTree[string] | undefined = messages[defaultLocale]; + for (const part of key.split('.')) { + message = typeof message === 'object' && message !== null ? message[part] : undefined; + } + + if (typeof message !== 'string') { + return key; + } + + return Object.entries(values).reduce((nextMessage, [name, value]) => nextMessage.replaceAll(`{${name}}`, String(value)), message); +} + +function translateSeo(key: string, values?: TranslationValues, translator = activeTranslator): string { + return translator ? translator(key, values) : builtInTranslate(key, values); } export function resolveSeo(config: SeoConfig = {}): ResolvedSeoConfig { @@ -119,7 +143,7 @@ export function resolveSeo(config: SeoConfig = {}): ResolvedSeoConfig { }; } -export function routeSeoConfig(route: RouteLocationNormalizedLoaded): SeoConfig { +export function routeSeoConfig(route: RouteLocationNormalizedLoaded, translator?: Translator): SeoConfig { const routeSeo = route.meta.seo as RouteSeoConfig | undefined; const canonicalPath = typeof routeSeo?.canonicalPath === 'function' @@ -127,16 +151,16 @@ export function routeSeoConfig(route: RouteLocationNormalizedLoaded): SeoConfig : routeSeo?.canonicalPath ?? route.path ?? defaultCanonicalPath; return { - title: routeSeo?.titleKey ? translate(routeSeo.titleKey) : routeSeo?.title, - description: routeSeo?.descriptionKey ? translate(routeSeo.descriptionKey) : routeSeo?.description, + title: routeSeo?.titleKey ? translateSeo(routeSeo.titleKey, undefined, translator) : routeSeo?.title, + description: routeSeo?.descriptionKey ? translateSeo(routeSeo.descriptionKey, undefined, translator) : routeSeo?.description, canonicalPath, image: routeSeo?.image, noindex: routeSeo?.noindex }; } -export function resolveRouteSeo(route: RouteLocationNormalizedLoaded): ResolvedSeoConfig { - return resolveSeo(routeSeoConfig(route)); +export function resolveRouteSeo(route: RouteLocationNormalizedLoaded, translator?: Translator): ResolvedSeoConfig { + return resolveSeo(routeSeoConfig(route, translator)); } export function onSeoChange(callback: (seo: ResolvedSeoConfig) => void): () => void {