异步加载国际化时,useI18n 和 normalFn.t 的时机问题
这篇文章解决什么问题
项目里接入国际化后,很多人会觉得:
- 组件里直接写
const { t } = useI18n()就行; - 或者把
t挂到全局方法上,比如normalFn.t,后面哪里都能用。
同步加载语言包时,这样通常没什么问题。
但如果语言资源是异步加载的,情况就不一样了:
- 页面模块先执行了;
useI18n()先取了;normalFn.t先挂好了;- 语言包却还没真正加载完成。
这时候业务代码虽然能跑,但拿到的文案不一定对,常见表现包括:
- 显示默认语言;
- 显示旧语言;
- 显示 key 本身;
- 某些配置项在切换语言后也不会更新。
这篇文章主要讲的,就是这个“时机问题”。
先说结论
异步国际化场景下,真正危险的不是 t 写在哪,而是:
t绑定得太早,或者翻译结果计算得太早。
所以要记住三件事:
- 不要在过早执行的全局位置依赖
useI18n(); - 不要把翻译结果在模块加载阶段直接算成常量;
normalFn.t这类全局方法,最好在调用时再取最新的国际化能力,而不是初始化时就把它定死。
一句话理解:
异步加载语言包时,怕的不是“全局调用”,怕的是“全局提前调用”。
为什么会出问题
这个问题本质上和“执行时机”有关。
很多项目启动顺序大概是这样的:
- 先加载基础模块;
- 执行页面、工具类、公共方法里的顶层代码;
- 初始化一些全局对象,比如
normalFn; - 再去异步请求或注入语言包;
- 最后页面真正开始稳定渲染。
如果 t 的绑定发生在第 2 步或第 3 步,而语言包要到第 4 步才准备好,那么前面拿到的翻译能力就可能处在“不完整状态”。
尤其要区分两件事:
- 缓存
t这个方法; - 缓存
t('xxx')的结果。
前者有时还能勉强工作,后者问题通常更明显,因为结果一旦在模块初始化时算出来,后面语言包再加载完成,它也不会自己重新算。
常见错误写法一:过早声明 const { t } = useI18n()
有些代码会把国际化能力提得很早:
js
const { t } = useI18n();
export const columns = [
{
title: t("user.name"),
dataIndex: "name",
},
];这个写法的问题不是语法,而是时机。
columns 在模块加载时就已经生成了。如果这时候异步语言包还没到,title 很可能就已经被算成了错误值。后面即使语言包加载完成,columns 里的 title 也不会自动变成新文案。
常见错误写法二:normalFn.t 提前绑定
这个坑在业务里更常见,因为很多项目会封装一个全局方法:
js
normalFn.t = i18n.global.t;看起来很方便,但它也有两个风险。
第一类风险是:绑定时机太早。
js
// 应用初始化早期
normalFn.t = i18n.global.t;
// 这之后才异步加载语言包
await loadLocaleMessages();如果你的国际化实例还没进入最终可用状态,就先把 normalFn.t 暴露出去了,后面业务即使统一走 normalFn.t("xxx"),也可能还是拿不到预期文案。
第二类风险是:虽然 normalFn.t 没问题,但业务把结果提前算死了。
js
normalFn.t = (...args) => i18n.global.t(...args);
export const actionList = [
{
label: normalFn.t("common.edit"),
value: "edit",
},
];这里真正有问题的已经不是 normalFn.t,而是 label 在模块初始化时就被计算了。异步语言加载完成后,actionList 不会自动重建。
关键区别:缓存方法,还是缓存结果
这是排查这类问题时最值得先看的点。
风险更高的是缓存结果
js
const text = t("common.confirm");或者:
js
const text = normalFn.t("common.confirm");这两种写法本质一样,都是“现在立刻把翻译结果算出来”。
如果当下语言资源还没准备好,那算出来的就是不对的。后面语言环境变了,这个 text 也不会自己刷新。
相对更稳的是延迟调用
js
const getText = () => t("common.confirm");或者:
js
normalFn.t = (...args) => i18n.global.t(...args);这种写法的核心价值是:把真正的翻译动作延后到“业务实际使用时”。
这样至少不会在应用刚启动、语言资源还没就绪时过早把结果定死。
更稳的写法一:把依赖国际化的配置改成函数
如果某段配置会受语言切换或异步加载影响,不要直接导出静态结果,改成工厂函数更稳。
错误写法:
js
const { t } = useI18n();
export const columns = [
{
title: t("user.name"),
dataIndex: "name",
},
];更稳的写法:
js
export const createColumns = (t) => [
{
title: t("user.name"),
dataIndex: "name",
},
];在组件里使用:
js
const { t } = useI18n();
const columns = createColumns(t);这样做的好处是:把国际化依赖交回真正使用它的地方,而不是在模块加载时偷跑。
更稳的写法二:normalFn.t 在调用时取最新值
如果项目里确实需要保留 normalFn.t 这种全局入口,更建议写成函数代理,而不是直接做一次性赋值。
不建议:
js
normalFn.t = i18n.global.t;更建议:
js
// 用函数代理,避免在初始化阶段把翻译能力过早定死
normalFn.t = (...args) => i18n.global.t(...args);这样至少能保证每次调用 normalFn.t 时,走的都是当前 i18n.global 的翻译能力。
但要注意,这只能解决“方法绑定太早”的一部分问题,不能解决“结果算太早”的问题。
下面这种写法仍然有风险:
js
export const buttonText = normalFn.t("common.submit");因为它依然是在模块初始化阶段就把结果算死了。
更稳的写法三:等语言包加载完成后再初始化
如果你的项目启动流程可控,也可以把初始化顺序调整一下:
- 先确定当前语言;
- 先异步加载对应语言包;
- 再挂
normalFn.t; - 再启动页面或挂载应用。
示意代码:
js
await loadLocaleMessages(currentLocale);
normalFn.t = (...args) => i18n.global.t(...args);
app.mount("#app");这种方式的优点很直接:
- 页面第一次渲染时,语言资源更完整;
normalFn.t暴露出去时,国际化环境通常已经准备好了。
但它也不是万能解法。
如果项目后面还支持动态切换语言,或者某些配置项是在模块顶层静态生成的,仍然要避免过早计算翻译结果。
一个更贴业务的案例
下面用一个很常见的后台页面举例。
需求很简单:
- 项目里有一个用户列表页;
- 表格列标题需要国际化;
- 业务里已经有全局方法
normalFn.t; - 当前语言包是进入系统后异步加载的。
很多人一开始会这么写。
错误写法
先在应用启动时挂全局方法:
js
// main.js
normalFn.t = (...args) => i18n.global.t(...args);
await loadLocaleMessages(currentLocale);然后在表格配置文件里直接使用:
js
// userColumns.js
export const userColumns = [
{
// 这里在模块加载时就已经执行,不会等语言包准备完再算
title: normalFn.t("user.name"),
dataIndex: "name",
},
{
title: normalFn.t("user.phone"),
dataIndex: "phone",
},
];页面里再直接引入:
js
import { userColumns } from "./userColumns";这段代码的问题在于:
userColumns.js一被加载,title就立刻算出来了;- 这时异步语言包可能还没完成注入;
- 最终页面拿到的列标题,可能是默认语言,也可能直接是 key;
- 就算后面语言包加载好了,
userColumns这个常量也不会自己重算。
这就是很多人会遇到的现象:
- 页面首次打开时表头文案不对;
- 刷新一次又好了;
- 切换语言后,按钮文字变了,但表格列标题没变。
更稳的改法一:把列配置改成工厂函数
js
// userColumns.js
export const createUserColumns = (t) => [
{
title: t("user.name"),
dataIndex: "name",
},
{
title: t("user.phone"),
dataIndex: "phone",
},
];页面里再在真正使用时生成:
js
const { t } = useI18n();
const columns = createUserColumns(t);这个写法更稳,因为列配置不再在模块加载时提前计算,而是在页面真正进入 setup、国际化上下文已经可用时才生成。
更稳的改法二:如果必须走 normalFn.t,也不要在模块顶层直接算结果
如果项目已经大量依赖 normalFn.t,短期不方便全面改掉,也至少可以先这样收口:
js
// userColumns.js
export const createUserColumns = () => [
{
title: normalFn.t("user.name"),
dataIndex: "name",
},
{
title: normalFn.t("user.phone"),
dataIndex: "phone",
},
];页面里:
js
const columns = createUserColumns();这样虽然还是依赖全局 normalFn.t,但至少把“翻译结果的计算时机”从模块加载阶段,推迟到了页面实际执行阶段。
它不一定是最终最优解,但在老项目里通常是一个改动更小、收益比较直接的过渡方案。
如果页面还支持切换语言
那还要再多想一步:
- 不是只保证“首次打开时”文案正确;
- 还要保证“语言切换后”依赖国际化的配置会重新生成。
比如表格列如果要跟着语言切换自动更新,就更适合放进 computed:
js
const { t, locale } = useI18n();
const columns = computed(() => {
// 这里依赖 locale,是为了让列配置在语言切换后重新计算
locale.value;
return createUserColumns(t);
});这里核心不是 computed 这个形式本身,而是:
只要文案会变,就要给它重新计算的机会。
哪些地方最容易踩坑
下面这些位置最容易中招:
- 表格列配置
title - 表单项
label - 按钮文案配置
- 菜单数组
- 弹窗按钮文字
- 校验规则里的
message - 状态枚举的展示名
- 工具模块里导出的静态选项列表
它们有个共同点:
很多都是“配置长得像常量”,但实际上内容依赖当前语言。
只要依赖当前语言,它就不应该被太早算死。
怎么判断这里该不该提前翻译
可以直接问自己一个问题:
这段文案会不会随着语言包加载完成,或者随着语言切换而变化?
如果答案是“会”,那就尽量不要在模块顶层直接写:
js
t("xxx")
normalFn.t("xxx")而应该改成下面几类思路:
- 在组件里就近调用;
- 在渲染时调用;
- 在工厂函数里调用;
- 在
computed里生成; - 在语言资源准备完成后再初始化。
最后总结
异步国际化场景下,useI18n() 和 normalFn.t 的坑,本质上都不是 API 本身的问题,而是初始化顺序和执行时机的问题。
真正要避免的是两件事:
- 在语言资源未就绪时,过早绑定
t; - 在模块加载阶段,过早把翻译结果计算成常量。
所以更实用的经验不是“永远不要全局调用”,而是:
可以有全局入口,但不要让它在错误的时机提前产出最终文案。
记住这条判断原则,很多国际化诡异问题都会好排查很多。