动态 namespace 加载与代码分割
核心问题:随着翻译文件越来越大,如何避免首屏加载所有语言的所有翻译?如何实现按需懒加载?
真实场景
你的电商平台支持 8 种语言,翻译文件总计 2MB。如果一次性全部加载,首屏性能大幅下降。通过 namespace 拆分 + 路由级懒加载,可以让首屏只加载必要的翻译文件(通常不到 50KB)。
问题:一次性加载的代价
graph LR
A[用户访问首页] --> B[下载 bundle.js]
B --> C[下载所有语言翻译\n约 2MB]
C --> D[页面可交互]
E[优化后] --> F[下载 bundle.js]
F --> G[下载当前语言\n当前路由翻译\n约 50KB]
G --> H[页面可交互\n更快]
性能影响举例:
| 语言数 | 翻译 key 数 | 单文件大小 | 总大小 | 首屏额外时间(3G) |
|---|---|---|---|---|
| 3 种 | 500 keys | 20KB | 60KB | ~0.5s |
| 8 种 | 2000 keys | 80KB | 640KB | ~5s |
| 15 种 | 5000 keys | 200KB | 3MB | ~25s |
策略一:Namespace 按模块拆分
将翻译文件按功能模块(namespace)拆分,每个路由只加载所需模块。
目录结构
public/locales/
├── zh-CN/
│ ├── common.json # 通用 UI(导航、按钮)
│ ├── home.json # 首页
│ ├── product.json # 商品相关
│ ├── cart.json # 购物车
│ ├── checkout.json # 结算(较大,仅结算页加载)
│ ├── account.json # 账户
│ └── admin.json # 后台(仅管理员路由加载)
└── en-US/
├── common.json
├── home.json
...
拆分原则:
- common:所有页面共用,首屏加载
- 其他 namespace:仅在对应路由加载
- 后台 / 低频功能:完全懒加载
策略二:next-i18next 的懒加载
// pages/checkout/index.tsx
import { GetServerSideProps } from 'next';
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
// 只加载 common 和 checkout 两个 namespace
export const getServerSideProps: GetServerSideProps = async ({ locale }) => ({
props: {
...(await serverSideTranslations(locale!, ['common', 'checkout'])),
},
});
// 首页只加载 home namespace
// pages/index.tsx
export const getStaticProps = async ({ locale }) => ({
props: {
...(await serverSideTranslations(locale!, ['common', 'home'])),
},
});
配置按需加载(next-i18next.config.js):
module.exports = {
i18n: {
defaultLocale: 'zh-CN',
locales: ['zh-CN', 'en-US'],
},
// 关键:按需加载,不预加载所有 namespace
ns: ['common'], // 全局加载的 namespace
defaultNS: 'common',
// 其他 namespace 在页面级显式声明
};
策略三:next-intl 的动态加载
// src/i18n/request.ts
import { getRequestConfig } from 'next-intl/server';
import { routing } from './routing';
export default getRequestConfig(async ({ requestLocale }) => {
const locale = (await requestLocale) ?? routing.defaultLocale;
// 基础消息(每个页面都需要)
const commonMessages = (await import(`../../messages/${locale}/common.json`)).default;
return {
locale,
messages: commonMessages, // 只返回通用消息
};
});
// 在需要特定 namespace 的页面中单独加载
// app/[locale]/checkout/page.tsx
import { getTranslations } from 'next-intl/server';
export default async function CheckoutPage() {
// 在页面级加载特定 namespace
const t = await getTranslations('checkout');
const tCommon = await getTranslations('common');
return (
<div>
<h1>{t('title')}</h1>
<button>{tCommon('confirm')}</button>
</div>
);
}
策略四:vue-i18n 动态加载
配置懒加载
// src/i18n.ts
import { createI18n } from 'vue-i18n';
export const i18n = createI18n({
legacy: false,
locale: 'zh-CN',
fallbackLocale: 'en-US',
// 不预加载消息,通过函数按需加载
messages: {},
});
// 已加载的 locale + namespace 记录
const loadedResources = new Set<string>();
export async function loadLocaleMessages(locale: string, namespace: string) {
const key = `${locale}:${namespace}`;
if (loadedResources.has(key)) return; // 已加载,跳过
try {
// 动态 import,Vite 会自动拆分 chunk
const messages = await import(`./locales/${locale}/${namespace}.json`);
i18n.global.mergeLocaleMessage(locale, { [namespace]: messages.default });
loadedResources.add(key);
} catch (e) {
console.warn(`Failed to load ${locale}/${namespace}`, e);
}
}
在路由中使用
// src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router';
import { i18n, loadLocaleMessages } from '@/i18n';
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/',
component: () => import('@/pages/HomePage.vue'),
meta: { namespaces: ['home'] },
},
{
path: '/products/:id',
component: () => import('@/pages/ProductPage.vue'),
meta: { namespaces: ['product'] },
},
{
path: '/checkout',
component: () => import('@/pages/CheckoutPage.vue'),
meta: { namespaces: ['checkout', 'payment'] },
},
{
path: '/admin',
component: () => import('@/pages/AdminLayout.vue'),
meta: { namespaces: ['admin'] },
},
],
});
// 路由守卫:在进入路由前加载所需翻译
router.beforeEach(async (to) => {
const locale = i18n.global.locale.value;
const namespaces = (to.meta.namespaces as string[]) ?? [];
// 始终加载 common
await loadLocaleMessages(locale, 'common');
// 加载路由特定 namespace
await Promise.all(namespaces.map(ns => loadLocaleMessages(locale, ns)));
});
export default router;
切换语言时加载
// src/composables/useLocale.ts
import { i18n, loadLocaleMessages } from '@/i18n';
import { useRouter } from 'vue-router';
export function useLocale() {
const router = useRouter();
async function switchLocale(newLocale: string) {
// 获取当前路由需要的所有 namespace
const currentNamespaces = [
'common',
...((router.currentRoute.value.meta.namespaces as string[]) ?? []),
];
// 加载新语言的所有当前路由需要的 namespace
await Promise.all(
currentNamespaces.map(ns => loadLocaleMessages(newLocale, ns))
);
// 切换 locale
i18n.global.locale.value = newLocale;
localStorage.setItem('preferred-locale', newLocale);
}
return { switchLocale };
}
策略五:Vite 的翻译文件 chunk 优化
使用 Vite 的 import.meta.glob 实现更高效的动态导入:
// src/i18n/loader.ts
// Vite 会把每个文件打包成独立 chunk,实现按需加载
const localeModules = import.meta.glob('../locales/**/*.json');
export async function loadTranslation(locale: string, namespace: string) {
const path = `../locales/${locale}/${namespace}.json`;
const loader = localeModules[path];
if (!loader) {
console.warn(`Translation file not found: ${path}`);
return {};
}
const module = await loader() as { default: Record<string, unknown> };
return module.default;
}
// vite.config.ts 配置 chunk 命名规则
import { defineConfig } from 'vite';
export default defineConfig({
build: {
rollupOptions: {
output: {
// 翻译文件单独成 chunk,便于浏览器缓存
manualChunks(id) {
if (id.includes('/locales/')) {
// 按语言分 chunk:locales-zh-CN, locales-en-US
const match = id.match(/locales\/([^/]+)\//);
if (match) return `locales-${match[1]}`;
}
},
},
},
},
});
策略六:Service Worker 预缓存
对于 PWA 应用,可以在 Service Worker 中预缓存翻译文件:
// sw.js(使用 Workbox)
import { precacheAndRoute } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import { NetworkFirst } from 'workbox-strategies';
// 预缓存当前用户语言的翻译文件
// 其他语言按需缓存(用户切换时)
precacheAndRoute([
// 通过构建工具注入当前版本的翻译文件列表
...self.__WB_MANIFEST,
]);
// 翻译文件:NetworkFirst(优先网络,确保获取最新翻译)
registerRoute(
({ url }) => url.pathname.startsWith('/locales/'),
new NetworkFirst({ cacheName: 'i18n-cache' })
);
性能监控
// 监控翻译文件加载时间
async function loadLocaleMessagesWithMetrics(locale: string, namespace: string) {
const start = performance.now();
await loadLocaleMessages(locale, namespace);
const duration = performance.now() - start;
// 上报到监控系统
analytics.track('i18n_load', {
locale,
namespace,
duration_ms: Math.round(duration),
});
}
// 使用 PerformanceObserver 监控资源加载
const observer = new PerformanceObserver((list) => {
list.getEntries().forEach((entry) => {
if (entry.name.includes('/locales/')) {
console.log(`Locale file loaded: ${entry.name} in ${entry.duration.toFixed(2)}ms`);
}
});
});
observer.observe({ entryTypes: ['resource'] });
加载策略对比
| 策略 | 首屏大小 | 切换速度 | 实现复杂度 | 适用场景 |
|---|---|---|---|---|
| 全量加载 | 大 | 即时 | 简单 | 翻译少(< 100KB) |
| 按语言懒加载 | 中 | 秒级 | 简单 | 多语言,中等规模 |
| 按 namespace 懒加载 | 小 | 毫秒级 | 中等 | 大型应用 |
| namespace + 语言组合 | 最小 | 毫秒级 | 复杂 | 超大型多语言应用 |
| Service Worker 预缓存 | 首次大,后续小 | 即时 | 较复杂 | PWA 应用 |
常见问题
Q:动态加载时会有翻译 key 短暂显示为空的问题吗?
<template>
<!-- 加载中时显示骨架屏 -->
<template v-if="isI18nReady">
<h1>{{ t('title') }}</h1>
</template>
<template v-else>
<SkeletonText />
</template>
</template>
<script setup>
import { ref, onMounted } from 'vue';
const isI18nReady = ref(false);
onMounted(async () => {
await loadLocaleMessages(locale, 'checkout');
isI18nReady.value = true;
});
</script>
Q:SSR + 懒加载如何配合?
A:SSR 时在服务端预加载所有必要的 namespace(通过 getServerSideProps 或 loader 函数),客户端水合时这些翻译已存在,无需再次加载。
进入第 03 章:前端框架配置好了,后端 API 也需要支持多语言——如何处理 Accept-Language 头、设计多语言数据库字段、以及让错误信息说用户的语言。