动态 namespace 加载与代码分割
High Contrast
Dark Mode
Light Mode
Sepia
Forest
3 min read668 words

动态 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(通过 getServerSidePropsloader 函数),客户端水合时这些翻译已存在,无需再次加载。


进入第 03 章:前端框架配置好了,后端 API 也需要支持多语言——如何处理 Accept-Language 头、设计多语言数据库字段、以及让错误信息说用户的语言。

Accept-Language 头处理与语言协商