Skip to content

异步加载国际化时,useI18n 和 normalFn.t 的时机问题

这篇文章解决什么问题

项目里接入国际化后,很多人会觉得:

  • 组件里直接写 const { t } = useI18n() 就行;
  • 或者把 t 挂到全局方法上,比如 normalFn.t,后面哪里都能用。

同步加载语言包时,这样通常没什么问题。

但如果语言资源是异步加载的,情况就不一样了:

  • 页面模块先执行了;
  • useI18n() 先取了;
  • normalFn.t 先挂好了;
  • 语言包却还没真正加载完成。

这时候业务代码虽然能跑,但拿到的文案不一定对,常见表现包括:

  • 显示默认语言;
  • 显示旧语言;
  • 显示 key 本身;
  • 某些配置项在切换语言后也不会更新。

这篇文章主要讲的,就是这个“时机问题”。

先说结论

异步国际化场景下,真正危险的不是 t 写在哪,而是:

t 绑定得太早,或者翻译结果计算得太早。

所以要记住三件事:

  • 不要在过早执行的全局位置依赖 useI18n()
  • 不要把翻译结果在模块加载阶段直接算成常量;
  • normalFn.t 这类全局方法,最好在调用时再取最新的国际化能力,而不是初始化时就把它定死。

一句话理解:

异步加载语言包时,怕的不是“全局调用”,怕的是“全局提前调用”。

为什么会出问题

这个问题本质上和“执行时机”有关。

很多项目启动顺序大概是这样的:

  1. 先加载基础模块;
  2. 执行页面、工具类、公共方法里的顶层代码;
  3. 初始化一些全局对象,比如 normalFn
  4. 再去异步请求或注入语言包;
  5. 最后页面真正开始稳定渲染。

如果 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");

因为它依然是在模块初始化阶段就把结果算死了。

更稳的写法三:等语言包加载完成后再初始化

如果你的项目启动流程可控,也可以把初始化顺序调整一下:

  1. 先确定当前语言;
  2. 先异步加载对应语言包;
  3. 再挂 normalFn.t
  4. 再启动页面或挂载应用。

示意代码:

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";

这段代码的问题在于:

  1. userColumns.js 一被加载,title 就立刻算出来了;
  2. 这时异步语言包可能还没完成注入;
  3. 最终页面拿到的列标题,可能是默认语言,也可能直接是 key;
  4. 就算后面语言包加载好了,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
  • 在模块加载阶段,过早把翻译结果计算成常量。

所以更实用的经验不是“永远不要全局调用”,而是:

可以有全局入口,但不要让它在错误的时机提前产出最终文案。

记住这条判断原则,很多国际化诡异问题都会好排查很多。

基于 VitePress 的个人知识库骨架