乐于分享
好东西不私藏

从零手写一个i18n国际化插件,原来app.use()背后干了这些事

从零手写一个i18n国际化插件,原来app.use()背后干了这些事

扫描二维码关注或者微信搜一搜:编程智域 前端至全栈交流与成长

发现1000+提升效率与开发的AI工具和实用程序:https://tools.cmdragon.cn/zh/apps?category=ai_chat

一、先搞明白我们要干啥

i18n是"国际化"(Internationalization)的缩写——那个18是因为I和n之间有18个字母,程序员就是这么懒,能缩写绝不写全。

咱们要做的就是一个翻译插件:你给它一个key,它帮你找到对应的翻译文本。比如:

<h1>{{ $translate('greetings.hello') }}</h1>
<!-- 输出:<h1>Bonjour!</h1> -->

这个$translate函数,我们希望它在任何组件的模板里都能直接用,不用每个组件单独导入。这就是插件的活儿了。

二、第一步:创建插件文件

建议把插件放在单独的文件里,方便管理:

// plugins/i18n.js
exportdefault {
install(app, options) {
// 插件代码写在这里
  },
};

就这么简单——一个对象,里面有个install方法。app是Vue应用实例,options是安装时传进来的配置。

三、第二步:实现翻译函数

咱们要实现的功能是:给一个greetings.hello这样的key,去翻译字典里找到对应的值。

翻译字典长这样:

{
greetings: {
hello'你好',
goodbye'再见'
  },
menu: {
home'首页',
about'关于'
  }
}

greetings.hello怎么找到你好呢?把key按.拆开,一层层往里找:

// 'greetings.hello'.split('.') → ['greetings', 'hello']
// 先找 obj['greetings'],再找 obj['greetings']['hello']
key.split(".").reduce((obj, k) => {
if (obj) return obj[k];
}, translations);

这个reduce可能看着有点绕,来拆解一下:

// 假设 key = 'greetings.hello', translations = { greetings: { hello: '你好' } }

// 第一轮:obj = translations, k = 'greetings'
//   obj['greetings'] → { hello: '你好' }

// 第二轮:obj = { hello: '你好' }, k = 'hello'
//   obj['hello'] → '你好'

// 最终结果:'你好'

四、第三步:把翻译函数挂到globalProperties上

// plugins/i18n.js
exportdefault {
install(app, options) {
    app.config.globalProperties.$translate = (key) => {
return key.split(".").reduce((obj, k) => {
if (obj) return obj[k];
      }, options);
    };
  },
};

app.config.globalProperties是Vue提供的一个对象,往上面挂的东西,所有组件都能通过this访问到。所以挂了$translate之后,任何组件里都能用this.$translate()

按惯例,全局属性名以$开头,这样一眼就能看出这是个Vue注入的全局方法,跟你自己定义的data/methods区分开。

五、第四步:在main.js中安装插件

// main.js
import { createApp } from"vue";
importAppfrom"./App.vue";
import i18nPlugin from"./plugins/i18n.js";

const app = createApp(App);

app.use(i18nPlugin, {
greetings: {
hello"你好",
goodbye"再见",
  },
menu: {
home"首页",
about"关于",
  },
});

app.mount("#app");

注意app.use()的第二个参数——这就是传给插件install方法的options。翻译字典就是通过这个参数传进去的。

六、在组件中使用

选项式API

<template>
  <h1>{{ $translate("greetings.hello") }}</h1>
  <p>{{ $translate("menu.home") }}</p>
</template>

在选项式API的模板中,$translate可以直接用,因为模板会自动从this上找属性。

在JS中:

exportdefault {
mounted() {
const hello = this.$translate("greetings.hello");
console.log(hello); // '你好'
  },
};

组合式API(script setup)

<script setup>中没有this,所以不能直接用this.$translate。需要通过getCurrentInstance来访问:

<script setup>
import { getCurrentInstance } from "vue";

const { proxy } = getCurrentInstance();

const hello = proxy.$translate("greetings.hello");
console.log(hello); // '你好'
</script>

<template>
  <!-- 模板中还是可以直接用 -->
  <h1>{{ $translate("greetings.hello") }}</h1>
</template>

不过说实话,在组合式API中用getCurrentInstance来访问globalProperties有点别扭。后面我们会讲更优雅的方式——用app.provide() + inject()

创建插件 i18n.js
定义install方法
实现$translate函数
挂载到globalProperties
main.js中 app.use安装
传入翻译字典options
组件中使用 $translate
选项式: this.$translate
组合式: proxy.$translate / 模板直接用

七、让翻译函数更健壮

上面的$translate还有几个问题,咱们来修一修:

问题一:key找不到时返回undefined

$translate("not.exist.key"); // undefined

加个默认值:

app.config.globalProperties.$translate = (key, defaultValue = "") => {
const result = key.split(".").reduce((obj, k) => {
if (obj) return obj[k];
  }, options);
return result !== undefined ? result : defaultValue;
};

问题二:key不是字符串时会报错

$translate(123); // split不是数字的方法,报错

加个类型检查:

app.config.globalProperties.$translate = (key, defaultValue = "") => {
if (typeof key !== "string"return defaultValue;
const result = key.split(".").reduce((obj, k) => {
if (obj) return obj[k];
  }, options);
return result !== undefined ? result : defaultValue;
};

问题三:支持动态切换语言

现在的翻译字典是安装时写死的,不能动态切换。咱们加个响应式的语言切换功能:

// plugins/i18n.js
import { ref, computed } from"vue";

exportdefault {
install(app, options) {
const locale = ref("zh");
const messages = options;

const$translate = (key, defaultValue = "") => {
if (typeof key !== "string"return defaultValue;
const currentMessages = messages[locale.value] || {};
const result = key.split(".").reduce((obj, k) => {
if (obj) return obj[k];
      }, currentMessages);
return result !== undefined ? result : defaultValue;
    };

    app.config.globalProperties.$translate = $translate;
    app.config.globalProperties.$locale = locale;

    app.provide("i18n", {
      locale,
translate: $translate,
setLocale(lang) {
if (messages[lang]) {
          locale.value = lang;
        }
      },
    });
  },
};

安装时传入多语言字典:

app.use(i18nPlugin, {
zh: {
greetings: { hello"你好"goodbye"再见" },
menu: { home"首页"about"关于" },
  },
en: {
greetings: { hello"Hello"goodbye"Goodbye" },
menu: { home"Home"about"About" },
  },
});

组件里切换语言:

<script setup>
import { inject } from "vue";

const { locale, translate, setLocale } = inject("i18n");

function switchToEnglish() {
  setLocale("en");
}

function switchToChinese() {
  setLocale("zh");
}
</script>

<template>
  <h1>{{ translate("greetings.hello") }}</h1>
  <button @click="switchToEnglish">English</button>
  <button @click="switchToChinese">中文</button>
</template>

八、完整的插件代码

// plugins/i18n.js
import { ref } from"vue";

exportdefault {
install(app, options) {
const locale = ref("zh");
const messages = options;

function$translate(key, defaultValue = "") {
if (typeof key !== "string"return defaultValue;
const currentMessages = messages[locale.value] || {};
const result = key.split(".").reduce((obj, k) => {
if (obj) return obj[k];
      }, currentMessages);
return result !== undefined ? result : defaultValue;
    }

    app.config.globalProperties.$translate = $translate;
    app.config.globalProperties.$locale = locale;

    app.provide("i18n", {
      locale,
translate: $translate,
setLocale(lang) {
if (messages[lang]) {
          locale.value = lang;
        }
      },
availableLocalesObject.keys(messages),
    });
  },
};

课后 Quiz

问题 1

app.config.globalProperties上挂的方法,在<script setup>中怎么访问?

答案解析

<script setup>中没有this,不能直接访问globalProperties。有两种方式:

  1. 1. 通过getCurrentInstance()获取组件实例,再通过proxy访问
  2. 2. 更推荐的方式是用app.provide()把功能注入,组件中用inject()获取

第二种方式更符合组合式API的风格,也更好做类型推断。

问题 2

为什么全局属性名建议以$开头?

答案解析

这是Vue的约定,以$开头的属性表示它是Vue或插件注入的全局方法/属性,跟组件自己定义的data、methods区分开。比如$router$route$translate——看到$开头就知道这不是你自己写的,是框架或插件提供的。

问题 3

app.use(plugin, options)中的options是怎么传到插件里的?

答案解析

app.use内部调用插件的install方法时,会把app实例作为第一个参数,把options作为第二个参数传进去。所以install(app, options)中的options就是你调用app.use时传的第二个参数。

常见报错解决方案

报错 1:this.$translate is not a function

错误场景

<script setup>
const msg = this.$translate("greetings.hello"); // 💥 setup中没有this
</script>

报错原因
<script setup>中没有this对象,globalProperties上的方法无法通过this访问。

解决方案
在模板中可以直接用$translate,在JS中用getCurrentInstanceinject

// 方式一:getCurrentInstance
import { getCurrentInstance } from"vue";
const { proxy } = getCurrentInstance();
const msg = proxy.$translate("greetings.hello");

// 方式二:inject(推荐)
const { translate } = inject("i18n");
const msg = translate("greetings.hello");

报错 2:Cannot read property 'split' of undefined

错误场景

$translate(undefined); // 💥 undefined没有split方法

报错原因
翻译函数没有对key做类型检查,传入了非字符串类型的值。

解决方案
在函数开头加类型检查:

function$translate(key, defaultValue = "") {
if (typeof key !== "string"return defaultValue;
// ...
}

报错 3:翻译字典key写错了,返回undefined

错误场景

$translate("greeting.hello"); // 拼写错误:greeting少了个s
// 返回undefined,页面上显示空白

报错原因
key拼写错误,在字典中找不到对应的翻译。

解决方案
$translate加默认值参数,找不到翻译时显示默认文本而不是空白:

$translate("greeting.hello""翻译缺失"); // 找不到时显示"翻译缺失"

同时在开发环境下可以加个警告:

function$translate(key, defaultValue = "") {
if (typeof key !== "string"return defaultValue;
const result = key.split(".").reduce((obj, k) => {
if (obj) return obj[k];
  }, messages[locale.value]);

if (result === undefined && import.meta.env.DEV) {
console.warn(`[i18n] Missing translation: ${key}`);
  }

return result !== undefined ? result : defaultValue;
}