我不知道的 i18next(01)— 命名空间与资源加载
很多人把 i18next 的命名空间当成"翻译文件的分类文件夹",配置一下 ns 数组就完事了。但命名空间决定的不只是文件怎么分,它直接影响了资源的加载粒度、请求数量和内存占用。一个看似简单的配置选择,可能导致首屏多发十几个请求,也可能让翻译资源在内存里无限堆积。
很多人把 i18next 的命名空间当成”翻译文件的分类文件夹”,配置一下 ns 数组就完事了。但命名空间决定的不只是文件怎么分,它直接影响了资源的加载粒度、请求数量和内存占用。一个看似简单的配置选择,可能导致首屏多发十几个请求,也可能让翻译资源在内存里无限堆积。
一、命名空间到底是什么
先看一个典型的 i18next 初始化:
import i18next from 'i18next';
import Backend from 'i18next-http-backend';
i18next.use(Backend).init({
lng: 'en',
ns: ['common', 'product', 'user'],
defaultNS: 'common',
backend: {
loadPath: '/locales/{{lng}}/{{ns}}.json',
},
});
ns 数组里的每一项,对应一个独立的翻译 JSON 文件。使用时通过冒号分隔命名空间和键名:
i18next.t('welcome'); // 从 defaultNS(common)取
i18next.t('product:item.name'); // 从 product 命名空间取
说白了,命名空间就是 i18next 的资源加载单元。每个命名空间是一次独立的加载操作——这个特性既是优势,也是陷阱。
二、一个命名空间 = 一次 HTTP 请求
这里有一个很多人会忽略的细节——i18next 的 Backend 插件对每个命名空间发起独立请求。
假设项目配置了 3 个命名空间、支持 2 种语言,初始化时会发出多少请求?
i18next.init({
lng: 'zh',
fallbackLng: 'en',
ns: ['common', 'product', 'user'],
});
答案是 6 个请求:zh/common.json、zh/product.json、zh/user.json、en/common.json、en/product.json、en/user.json。因为 fallbackLng 也会被预加载。
如果命名空间增长到 10 个,就是 20 个请求。再加上预加载多语言(preload: ['en', 'zh', 'ja']),请求数呈乘法增长。这是大型项目国际化性能问题的常见根源。
三、按需加载:loadNamespaces 的工作方式
i18next 的 loadNamespaces 方法解决了”初始化时全量加载”的问题:
// 初始化时只加载核心命名空间
i18next.init({
ns: ['common'],
defaultNS: 'common',
});
// 进入商品页时,按需加载 product 命名空间
async function enterProductPage() {
await i18next.loadNamespaces('product');
console.log(i18next.t('product:item.name'));
}
加载完成后,翻译数据存入 i18next 内部的 resourceStore。这是一个嵌套对象结构:
// resourceStore 的实际结构
{
en: {
common: { welcome: 'Welcome', cancel: 'Cancel' },
product: { 'item.name': 'Product Name' },
},
zh: {
common: { welcome: '欢迎', cancel: '取消' },
product: { 'item.name': '商品名称' },
},
}
t() 函数的查找路径就是 resourceStore[lng][ns][key]。换句话说,命名空间决定了查找时的第二层索引。
四、缓存机制:默认行为与隐患
i18next 对已加载的命名空间有内置缓存——同一个命名空间不会重复请求:
await i18next.loadNamespaces('product'); // 第一次:发请求
await i18next.loadNamespaces('product'); // 第二次:直接返回,无请求
这个缓存是纯内存的,生命周期等于页面会话。问题在于:它没有上限,也没有过期机制。
在 SPA 应用中,用户可能在一次会话里浏览几十个页面,每个页面加载不同的命名空间。所有翻译数据都驻留在内存里,不会被清理。对于大多数应用这不是问题,但如果翻译文件本身很大(比如法律条款、产品目录),内存占用会持续增长。
如果你只记住一句话,记住这个:i18next 的缓存是”只增不减”的,需要手动管理边界。
五、三种常见的加载策略对比
下面这三种策略适用于不同规模的项目。先看配置,再看各自的取舍:
(1)全量预加载
i18next.init({
ns: ['common', 'product', 'user', 'checkout', 'admin'],
preload: ['en', 'zh'],
});
启动时加载所有命名空间和语言。适合翻译量小(<50KB)的项目,简单粗暴,但首屏请求多。
(2)路由级按需加载
// 路由守卫中加载对应命名空间
async function beforeEnter(route) {
const nsMap = {
'/product': 'product',
'/checkout': 'checkout',
'/admin': 'admin',
};
const ns = nsMap[route.path];
if (ns) await i18next.loadNamespaces(ns);
}
初始化只加载 common,其他跟随路由按需加载。大多数 SPA 项目的最佳实践。
(3)合并文件 + 单次请求
i18next.init({
backend: {
loadPath: '/locales/{{lng}}/all.json',
},
ns: ['translation'],
});
把所有翻译合并到一个文件里。请求数降到最低,但文件体积大,且无法按需加载。适合翻译量中等、对首屏速度不敏感的场景。
三种策略的对比:
| 策略 | 首屏请求 | 首屏体积 | 按需能力 | 适用场景 |
|---|---|---|---|---|
| 全量预加载 | 多 | 大 | 无 | 小项目、SSR |
| 路由级按需 | 少 | 小 | 强 | SPA、大型项目 |
| 合并文件 | 1 | 中 | 无 | 中型项目、简单架构 |
六、实战:自定义缓存控制
如果项目需要更精细的缓存控制(比如限制内存中的命名空间数量),可以实现一个自定义 Backend:
const cache = new Map();
const MAX_NS = 20;
i18next.use({
type: 'backend',
read(lng, ns, callback) {
const key = `${lng}:${ns}`;
if (cache.has(key)) {
return callback(null, cache.get(key));
}
fetch(`/locales/${lng}/${ns}.json`)
.then((res) => res.json())
.then((data) => {
// 超出上限时淘汰最早的条目
if (cache.size >= MAX_NS) {
const oldest = cache.keys().next().value;
cache.delete(oldest);
}
cache.set(key, data);
callback(null, data);
})
.catch((err) => callback(err));
},
});
这段代码实现了一个简单的 FIFO 淘汰策略。实际项目中可以用 lru-cache 等成熟库替代,关键在于意识到默认缓存的局限性。
需要注意的是,自定义 Backend 替换的只是”数据从哪来”的逻辑,resourceStore 本身仍然会保留所有加载过的数据。如果要真正释放内存,需要配合 i18next.removeResourceBundle(lng, ns) 手动清理:
// 离开页面时清理不再需要的命名空间
function onLeavePage(ns) {
i18next.removeResourceBundle('en', ns);
i18next.removeResourceBundle('zh', ns);
}
七、总结
i18next 命名空间的本质是资源加载的最小单元。它决定了请求粒度、缓存边界和内存生命周期。大多数性能问题不是 i18next 本身慢,而是命名空间的加载策略没有匹配项目的实际规模。
本系列其他文章:
- 下一篇:Trans 组件的边界与陷阱
相关主题:
- 如果你对 i18next 的复数处理机制感兴趣,可以看:复数规则与 CLDR 标准