Software localization
Vue 2 Localization with Vue I18n: A Step-by-Step Guide
It's always a pleasure to work with the Vue JavaScript framework. The elegance of its design, coupled with the robust first-party additions like Vue Router for SPA routing and Vuex for state management, make it a delight to use for building modern browser apps.
Of course, if you're here, you probably know this already. You might have an app built with Vue, and you might be wanting to reap the benefits of internationalizing and localizing your app to reach a wider, global market. Well, have no fear.
This tutorial will walk you through everything you need to dive into Vue localization with the extremely popular Vue I18n library. Vue I18n plugs into your Vue apps and provides you with translation file management, message formatting, date and number formatting, and more to boot. We'll fill in the gaps that the library leaves so that you can have a robust i18n cookbook for your Vue apps.
✋ Heads up » This article covers Vue 2 localization. If you’re interested in Vue 3, check out our guide to Vue localization.
🔗 Resource » We're using the Vue I18n library in this article. If you would rather use i18next, our tutorial on Vue Translation with vue-i18next might be useful to you.
🔗 Resource » Explore the possibilities other frameworks have to offer and learn everything you need to make your JS applications accessible to international users with our ultimate guide to JavaScript localization.
Library Versions
We'll be using the following libraries (with versions at the time of writing) to work through localizing our Vue app:
- Vue vue (2.6.11)
- Vue Router vue-router (3.1.3)
- Vue I18n vue-i18n (8.15.3)
- Vue CLI @vue/cli (4.1.2) — We use this to install all the libraries above, but you don't strictly have to
Our Demo App
During the course of this article, we'll build a localized, small Vue SPA. We'll call it International Gourmet Coffee, and it will be a mock ecommerce storefront specializing in gourmet coffee from around the world. Of course, the point is to showcase Vue I18n and localization, so we won't cover features like adding to a cart or checking out. We'll just have a couple of pages that demo what we need to get cooking quickly.
This beauty of an app is what we'll have at the end of this article
🔗 Resource » You can grab the code for the entire, completed app from GitHub.
Installation
We'll start off by installing the Vue CLI, which makes quick work of spinning up new Vue SPA projects.
npm install -g @vue/cli
With the CLI installed, we can use the global vue
command to create our demo project.
vue create vue-i18n-demo
When asked for the preset by the Vue CLI, we'll select "Manually select features" and select the following ones.
The most important option is adding the Router. Everything else is optional here.
Installing Vue I18n
Ok, now let's install Vue I18n. If we spun up our project with the Vue CLI, installing Vue I18n is a breeze. We simply run one command from the command line.
vue add i18n
This command will install the Vue I18n CLI plugin and will ask us a few questions about our project so it can create some i18n boilerplate for us.
We'll go with all the defaults
The CLI will create and update some files in our Vue project. Let's go through these changes.
.env
The default and fallback locales are added as environment variables to the .env
file in our project.
VUE_APP_I18N_LOCALE=en VUE_APP_I18N_FALLBACK_LOCALE=en
src/i18n.js
A new src/i18n.js
file is added that registers Vue I18n as a plugin to our Vue instance via the Vue.use()
function.
import Vue from "vue"; import VueI18n from "vue-i18n"; Vue.use(VueI18n); function loadLocaleMessages() { const locales = require.context( "./locales", true, /[A-Za-z0-9-_,\s]+\.json$/i ); const messages = {}; locales.keys().forEach(key => { const matched = key.match(/([A-Za-z0-9-_]+)\./i); if (matched && matched.length > 1) { const locale = matched[1]; messages[locale] = locales(key); } }); return messages; } export default new VueI18n({ locale: process.env.VUE_APP_I18N_LOCALE || "en", fallbackLocale: process.env.VUE_APP_I18N_FALLBACK_LOCALE || "en", messages: loadLocaleMessages() });
The new file also houses a function, loadLocaleMessages()
, that scans the src/locales
directory for JSON files, and loads them in as translations messages. For example, a file called "fr.json" will have its contents loaded as the French (fr) translation messages. Finally, i18n.js
constructs the VueI8n
instance we'll use in our app, and exports it.
src/locales/en.json
The Vue I18n CLI will also have placed our first translation message file, based on the options we selected during installation. Since we selected English (en) as our default locale, and locales
as the directory to store our message files, the CLI will have created a src/locales/en.json
file to contain our English translation messages.
{ "message": "hello i18n !!" }
src/main.js
Our entry src/main.js
file has the VueI8n
instance created and added to the Vue
constructor.
import Vue from "vue"; import App from "./App.vue"; import router from "./router"; import store from "./store"; import i18n from "./i18n"; Vue.config.productionTip = false; new Vue({ router, store, i18n, render: h => h(App) }).$mount("#app");
vue.config.js
The CLI will have added the i18n
entry under pluginOptions
in our vue.config.js
file as well.
module.exports = { pluginOptions: { i18n: { locale: "en", fallbackLocale: "en", localeDir: "locales", enableInSFC: false } } };
Of course, the vue-i18n
package will also have been added to our package.json
file.
🗒 Note » If you have an existing Vue project, and don't want to install the Vue CLI, you can recreate the automated installation above by adding and modifying the files that the Vue I18n CLI plugin does for you. You'll, of course, need to install the package manually via npm install --save vue-i18n
. Here's a convenient one-stop shop PR for you to see all the file changes in one place.
Creating our Demo
With Vue and Vue I18n installed, let's build the demo app that we'll localize using Vue I18n. We'll add a simple navigation bar, some coffee data for our home page, and placeholder text in our about page.
<template> <div id="nav"> <img alt="Vue logo" src="../assets/logo-circle-sm.png" /> <router-link to="/">Home</router-link> <router-link to="/about">About</router-link> </div> </template> <style> #nav { display: flex; align-items: center; text-align: left; padding: 1rem; color: #42b983; background-color: #3d536a; } #nav img { margin-right: 1rem; } #nav a { margin-right: 1.5rem; font-weight: bold; color: #fff; text-decoration: none; } </style>
Notice that the <router-link>
s that Vue gives us as boilerplate are now in our own Nav
component. The rest is visual flare.
<template> <div id="app"> <Nav /> <div class="container"> <router-view /> </div> </div> </template> <script> import Nav from "@/components/Nav" export default { components: { Nav } } </script> <style> body { margin: 0; } #app { font-family: "Avenir", Helvetica, Arial, sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; color: #2c3e50; } #app .container { padding: 1rem; } </style>
Our new Nav
component is pulled into our main App
component. We've also wrapped the App
's <router-view>
in a #container
for styling. Now let's take care of our home page content. We'll simulate a backend by adding a JSON file with our data.
[ { "id": 1, "title": "Battlecreek Columbia Coldono", "imgUrl": "/img/battlecreek-coffee-roasters-_1wDmr4dtuk-unsplash.jpg", "addedOn": "2020-01-02" }, { "id": 2, "title": "Battlecreek Yirgacheffee", "imgUrl": "/img/battlecreek-coffee-roasters-HvzR2yXtii4-unsplash.jpg", "addedOn": "2020-01-05" }, { "id": 3, "title": "Primo Passo", "imgUrl": "/img/jon-tyson-KRedbshBxEk-unsplash.jpg", "addedOn": "2020-01-05" }, { "id": 4, "title": "Little Nap Brazil", "imgUrl": "/img/lex-sirikiat-QouiCn7u6kw-unsplash.jpg", "addedOn": "2020-01-06" }, { "id": 5, "title": "Little Nap Blend", "imgUrl": "/img/manki-kim-mv7kxYh5Rko-unsplash.jpg", "addedOn": "2020-01-07" }, { "id": 6, "title": "French Truck Peru Cajamarca", "imgUrl": "/img/ryan-spaulding-_uncFvtOC-4-unsplash.jpg", "addedOn": "2020-01-08" } ]
Let's fetch this data in a Cards
component and display it.
<template> <div class="cards"> <Card v-bind="card" v-for="card in cards" :key="card.id" /> </div> </template> <script> import Card from "./Card" export default { data: () => ({ cards: [] }), created() { fetch("/data.json") .then(response => response.json()) .then(data => (this.cards = data)) }, components: { Card } } </script> <style scoped> .cards { display: flex; justify-content: space-between; flex-wrap: wrap; } </style>
We can use a presentational Card
component to display each coffee item as a card.
<template> <div class="card"> <h3>{{ title }}</h3> <img :src="imgUrl" /> <p>Added {{ addedOn }}</p> </div> </template> <script> export default { props: { id: Number, title: String, imgUrl: String, addedOn: String } } </script> <style scoped> .card { width: 30%; margin-bottom: 2rem; border: 1px solid #f2f2f2; border-radius: 4px; box-shadow: 0 2px 2px rgba(0, 0, 0, 0.1); } h3 { margin: 0; padding: 0.5em; text-align: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } img { width: 100%; } p { text-align: center; margin: 0; padding: 0.5em; } </style>
Now we just need to pull our Cards
component into our Home
view.
<template> <div class="home"> <h1>International Gourmet Coffee</h1> <Cards /> </div> </template> <script> import Cards from "@/components/Cards.vue" export default { name: "home", components: { Cards } } </script>
With these changes in place, we should have something that looks like the following:
Our little demo is ready for i18n
We now have a testbed to work with, and we can use it to localize our app using Vue I18n.
🔗 Resource » If you want to skip all the setup and get the demo in its current state (before we localize it), check out this tagged commit on GitHub.
Getting the Active Locale
Retrieving the active locale from Vue I18n is pretty easy. How we retrieve it depends on whether we're in a Vue component or not.
Getting the Active Locale Inside a Vue Component
Let's mock a quick language switcher component in our demo app. We won't build out the language switching functionality just yet, but we'll show the currently active language in our new component.
<template> <div class="locale-switcher">🌐 {{$i18n.locale}}</div> </template>
As a Vue plugin registered in our Vue instance, the VueI18n
object is available to all our components via the $i18n
variable in our templates. The same object is, of course, available in our component scripts via this.$i18n
. You may remember that the Vue i18n CLI plugin setup the VueI8n
object for us in the i18n.js
file. One of the several properties that can be accessed on VueI8n
is the locale
property, a string that corresponds to the currently active locale.
🔗 Resource » Check out all of the VueI8n
properties and methods in the official Vue I18n API documentation.
Let's add our LocaleSwitcher
to our Nav
component.
<template> <div class="nav"> <div class="nav__start"> <img alt="Vue logo" src="../assets/logo-circle-sm.png" /> <router-link to="/">Home</router-link> <router-link to="/about">About</router-link> </div> <div class="nav__end"> <LocaleSwitcher /> </div> </div> </template> <script> import LocaleSwitcher from "@/components/LocaleSwitcher" export default { components: { LocaleSwitcher } } </script> <style scoped> .nav { display: flex; justify-content: space-between; align-items: center; text-align: left; padding: 1rem; color: #fff; background-color: #3d536a; } .nav__start, .nav__end { display: flex; align-items: center; } .nav img { margin-right: 1rem; } .nav a { margin-right: 1.5rem; font-weight: bold; color: #fff; text-decoration: none; } </style>
With these changes in place, we should see something like the following in our browser.
Rendering the active locale
We'll build out the rest of the LocaleSwitcher
soon. First, let's check out how we can access the Vue I18n instance outside of our components.
Getting the Active Locale Outside of Vue Components
Whenever we want to access the VueI18n
object (i18n
) outside of our Vue components, we just import the instance from our i18n.js
file. This grants us access not just to the locale
property, of course, but to all of the properties and methods of the VueI8n
instance.
import i18n from "@/i18n" console.log("Active locale: ", i18n.locale)
Manually Setting the Active Locale
The nice thing about VueI18n
's locale
property is that it's read/write and reactive. So if we set a new value to the property, the components in our app that are showing translated messages will re-render to show their messages in the newly set locale.
<script> export default { methods: { setLocale(locale) { this.$i18n.locale = locale } } } </script>
Now that we know how to get and set the active locale, we can get our LocaleSwitcher
component working.
Building a Locale/Language Switcher
Let's update our LocaleSwitcher
so that it displays a drop-down for selecting the active locale.
<template> <div class="locale-switcher"> <select v-model="$i18n.locale"> <option value="en">English</option> <option value="ar">Arabic</option> </select> </div> </template>
The v-model
Vue directive creates a two-way binding between the <select>
's current value and the active Vue I18n locale: the <select>
will both show the active locale as the currently selected one and update the active locale if the user selects a different one. To test our LocaleSwitcher
, let's place some translated messages in our app by localizing our Nav
component. First, we'll add the messages to our message files.
🗒 Note » We'll cover translation messages in more detail a bit later. For now, we just want a way to see if our locale actually changes.
{ "nav": { "home": "Home", "about": "About" } }
{ "nav": { "home": "الرئيسية", "about": "نبذة عنا" } }
With these messages in place, let's update our Nav
component so that it uses them instead of hard-coded values.
<template> <div class="nav"> <div class="nav__start"> <img alt="Vue logo" src="../assets/logo-circle-sm.png" /> <router-link to="/">{{ $t("nav.home") }}</router-link> <router-link to="/about">{{ $t("nav.about") }}</router-link> </div> <div class="nav__end"> <LocaleSwitcher /> </div> </div> </template> <script> import LocaleSwitcher from "@/components/LocaleSwitcher" export default { components: { LocaleSwitcher } } </script> <style scoped> /* ... */ </style>
We use the $t()
function that Vue I18n provides to our components for outputting translation messages. More on $t()
a bit later. With the Nav
component localized and the LocaleSwitcher
working, we can now see and select the active locale from the nav bar.
Our components react and re-render when our locale changes
Supported Locales
Vue I18n doesn't have a strict notion of supported locales: a list of locales that we are expected to have translations for. We can add this list ourselves, however.
🗒 Note » The VueI18n
does have a read-only property called availableLocales
, which is an array of locale keys that have messages loaded into the VueI8n
instance. availableLocales
can be helpful, but is somewhat limited: it doesn't have the human-readable names of locales. Worse yet, availableLocales
will be inaccurate if we use it as a way to determine supported locales if we lazy load these locales in our app. This is because the list will be empty when we first load our app, and before we load any message files. We cover lazy loading later in this article.
A simple config file with locale codes mapped to human-readable names can do the trick.
export default { en: "English", ar: "عربي (Arabic)" }
We can also write a utility function that provides these values in a way that makes them easily consumable by our views.
import supportedLocales from "@/config/supported-locales" export function getSupportedLocales() { let annotatedLocales = [] for (const code of Object.keys(supportedLocales)) { annotatedLocales.push({ code, name: supportedLocales[code] }) } return annotatedLocales }
getSupportedLocales()
uses our configuration object to create an array of the form [{ code: "en", name: "English"}]
. We can use this array in the LocaleSwitcher
we built earlier so that we have a single source of truth for our supported locales.
<template> <div class="locale-switcher"> <select v-model="$i18n.locale"> <option :value="locale.code" v-for="locale in locales" :key="locale.code"> {{locale.name}} </option> </select> </div> </template> <script> import { getSupportedLocales } from "@/util/i18n/supported-locales" export default { data: () => ({ locales: getSupportedLocales() }) } </script>
Instead of hard-coding the locales in our switcher, we iterate over the supported locales and display each as an <option>
. This is simply a refactor, and our LocaleSwitcher
should behave in exactly the same way it did before. Another helpful feature is being able to check whether a given locale is in our list of supported locales. In fact, we'll want this feature in a moment when we retrieve the user's locale from the browser. As you can imagine, the logic for this check is trivial.
import supportedLocales from "@/config/supported-locales" // ... export function supportedLocalesInclude(locale) { return Object.keys(supportedLocales).includes(locale) }
✋🏽 Heads Up » It might be tempting to use Webpack's require.context()
to check the locales
directory and infer the supported locales based on the message files there, e.g. if /src/locales/fr.json
exists, then we assume that we support French. However, I've noticed that using require.context()
this way can interfere with Webpack's async/lazy loading of translations, which we'll cover a bit later.
Detecting the User's Preferred Locale in the Browser
While not strictly related to Vue I18n per se, detecting the user's locale is often a good idea when it comes to providing a friendly UX. We can use the browser's navigator.languages
array to try to detect the user's preferred locale.
export default function getBrowserLocale(options = {}) { const defaultOptions = { countryCodeOnly: false } const opt = { ...defaultOptions, ...options } const navigatorLocale = navigator.languages !== undefined ? navigator.languages[0] : navigator.language if (!navigatorLocale) { return undefined } const trimmedLocale = opt.countryCodeOnly ? navigatorLocale.trim().split(/-|_/)[0] : navigatorLocale.trim() return trimmedLocale }
In getBrowserLocale()
, we first check the global navigator.languages
array to see if it has any entries. This array will contain the languages that the user has selected in her browser preferences, in priority order.
This user prefers to see pages in Canadian English, then in Egyptian Arabic
This array might be empty in the browser preferences, in which case it will be undefined
when we try to access it via navigator.languages
. If this happens, we fall back to the navigator.language
property. In some browsers, navigator.language
will be the first element in navigator.languages
, and it will be an empty string if the array is undefined
. In other browsers, navigator.language
will be the language of the UI, which is not necessarily navigator.languages[0]
. If we couldn't infer the user's preferred language from either navigator.languages
or navigator.language
, we return undefined
. In normal cases, however, the navigator
will usually give us a standard language tag like "fr-FR"
. When our app covers language variants, like French (France) and French (Canada), this can be exactly what we want. However, sometimes we're only interested in the language code. Our getBrowserLocale()
provides a countryCodeOnly
option that gives us exactly that.
// navigator.languages == ["ar-SA", "en-US"] getBrowserLocale() // => "ar-SA" getBrowserLocale({ countryCodeOnly: true }) // => "ar
We can use our new function to set the initial locale of our app based on our user's preferences.
import Vue from "vue" import VueI18n from "vue-i18n" import getBrowserLocale from "@/util/i18n/get-browser-locale" import { supportedLocalesInclude } from "./util/i18n/supported-locales" Vue.use(VueI18n) //... function getStartingLocale() { const browserLocale = getBrowserLocale({ countryCodeOnly: true }) if (supportedLocalesInclude(browserLocale)) { return browserLocale } else { return process.env.VUE_APP_I18N_LOCALE || "en" } } export default new VueI18n({ locale: getStartingLocale(), fallbackLocale: process.env.VUE_APP_I18N_FALLBACK_LOCALE || "en", messages: loadLocaleMessages() })
We retrieve the browser locale, check if our app supports it, and if it does we use it as our starting locale. If our app doesn't support the browser locale we fall back to the default locale we set in our .env
file. Now, if the user has set a language that our app supports, we'll show the app in that language first.
Basic Translation Messages
We've already seen how to display basic, reactive translation messages via Vue I18n's t()
function.
{ "hello": "Hello There!" } // 👆🏽in en.json { "hello": "Bonjour!" } // 👆🏽in fr.json // 👇🏽 in Vue component <h2> {{ $t("hello") }} </h2>
In the above example, the <h2>
will contain "Hello There!"
when the active locale is English, and "Bonjour!"
when the active locale is French. If the active locale were to change via i18n.locale = newLocale
, our Vue component would re-render and attempt to show the <h2>
with newLocale
's translation. In addition to the t()
function, Vue I18n provides a Vue directive, v-t
, that can be used to display translated messages. Let's use the directive to localize our app's header.
{ "app": { "title": "International Gourmet Coffee" }, "nav": { "home": "Home", "about": "About" } }
{ "app": { "title": "قهوة الذواقة الدولية" }, "nav": { "home": "الرئيسية", "about": "نبذة عنا" } }
<template> <div class="home"> <h1 v-t="'app.title'" /> <Cards /> </div> </template> <script> // ... </script>
Notice that when using v-t
above, we wrapped its value in two sets of quotes. This is to provide the literal string 'app.title'
to the directive, which causes it to behave like t('app.title')
. Also, note that whether using t()
or v-t
, we can access messages that are nested in our JSON files via dot (.
) notation.
🔗 Resource » The t()
function is versatile, and has a few variants. Check out the official API documentation to get more details. While you're there, you may want to take a look at the v-t
documentation as well. You'll find the directive has a few tricks up its sleeve.
🔗 Resource » You can completely override the way messages are formatted with Vue I18n. Read the official documentation on custom formatting for more info.
Interpolation
We often want to inject dynamic values into our translation messages. For example, we may have a greeting in our nav bar that we show to a logged-in user.
{ "user_greeting": "Hello, {name}" }
{ "user_greeting": "مرحبا {name}" }
Vue I18n will replace the {name}
placeholder if we give a t()
object parameter that provides the name
's value.
<template> <div class="nav"> <!-- ... --> <div class="nav__end"> <p class="user-greeting"> {{ $t("user_greeting", { name: "Adam" }) }} </p> <LocaleSwitcher /> </div> </div> </template> <script> // ... </script> <style scoped> /* ... */ </style>
Given the messages above, we get {name}
replaced with "Adam"
.
Hey there, Adam
🔗 Resource » Vue I18n also supports list formatting e.g. "Hello {0}"
. Check out the official documentation on formatting for more details.
Using HTML in Translation Messages
Sometimes it's much more convenient for translators to have HTML in translation messages, rather than breaking up translation messages into smaller parts. Let's add a footer to our app to explore this.
{ "footer": "Built with Vue and Vue I18n<br />Powered by an excessive amount of coffee" }
{ "footer": "بنيت بواسطة Vue و Vue I18n<br />مدعوم من كمية قهوة مبالغ فيها" }
<template> <p class="footer">{{ $t("footer") }}</p> </template> <style scoped> .footer { text-align: center; margin-bottom: 1rem; } </style>
As it is, if we were to pull our new Footer
component into our App
root component, we would get less than desirable results.
Our HTML is escaped, which isn't what we want
Now we could use Vue's generally unsafe v-html
directive to solve this problem.
<template> <p class="footer" v-html="$t('footer')" /> </template>
This certainly works: our Footer
will now render two lines in the browser instead of one. The issue here is that v-html
opens up our app to XSS injection attacks. Of course, we would generally trust our translators not to inject malicious code into our app. Still, if we can avoid v-html
entirely, we can sleep better at night. Enter: component interpolation. We can use Vue I18n's <i18n>
component to avoid v-html
. First, let's update our translation messages.
{ "footer": "Built with Vue and Vue I18n{0}Powered by an excessive amount of coffee" }
{ "footer": "بنيت بواسطة Vue و Vue I18n{0}مدعوم من كمية قهوة مبالغ فيها" }
Instead of having the <br />
directly in our message, we have a {0}
placeholder. Now let's update our Footer
to use the <i18n>
component.
<template> <i18n path="footer" tag="p" class="footer"> <br /> </i18n> </template>
The "footer"
key corresponds to the one in our translation files and is provided as the path
prop to <i18n>
. We also give the component a tag
prop, which instructs it to render our translated message in a <p>
tag. Notice that we've included regular HTML, namely our <br />
, within the opening and closing tags of the <i18n>
component. We could place any HTML we like here, and Vue I18n will inject it where the {0}
placeholder is in our message. With this in place, our footer renders with two lines, exactly like we want.
No escape: We have HTML without exposing ourselves to XSS
🔗 Resource » Read more about component interpolation in Vue I18n's official documentation.
Plurals
Let's add a counter that shows the number of likes on each variety of coffee we have. First, we'll update our JSON data to provide the number of likes.
[ { "id": 1, "title": "Battlecreek Columbia Coldono", "imgUrl": "/img/battlecreek-coffee-roasters-_1wDmr4dtuk-unsplash.jpg", "addedOn": "2020-01-02", "likes": 3 }, { "id": 2, "title": "Battlecreek Yirgacheffee", "imgUrl": "/img/battlecreek-coffee-roasters-HvzR2yXtii4-unsplash.jpg", "addedOn": "2020-01-05", "likes": 0 }, // ... ]
Now we can update our Card
component to bring in the likes.
<template> <div class="card"> <h3>{{ title }}</h3> <img :src="imgUrl" /> <div class="card__footer"> <p>Added {{ addedOn }}</p> <p>{{likes}} people ❤️ this</p> </div> </div> </template> <script> export default { props: { id: Number, title: String, imgUrl: String, addedOn: String, likes: Number } } </script> <style scoped> .card { width: 30%; margin-bottom: 2rem; border: 1px solid #f2f2f2; border-radius: 4px; box-shadow: 0 2px 2px rgba(0, 0, 0, 0.1); } h3 { margin: 0; padding: 0.5em; text-align: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } img { width: 100%; } .card__footer { text-align: center; margin: 0; padding: 0.5em; } p { margin: 0; font-size: 14px; } p:first-child { margin-bottom: 0.5rem; } </style>
This works, but our likes message is currently hard-coded. We could use the $t()
function to localize it as usual, but this won't take into account the various plural forms our message can have. In English, for example, we want "No people ♥️ this", "1 person ♥️ this", and "3 people ♥️ like this" to display depending on the number of likes. Vue I18n's $tc()
function can help with displaying plurals. To use it, we need to adopt a special syntax in our pluralized message. Let's update our English message to see this.
{ "card": { "likes": "Nobody ❤️ this yet 😕 | {n} person ❤️ this | {n} people ❤️ this" } }
English has three plural forms: zero, one, and many. We write all three forms in our message, separated by the |
. We also use the special {n}
placeholder to display the number $tc()
receives. We can now use the function to dynamically display the correct plural form.
<template> <div class="card"> <h3>{{ title }}</h3> <img :src="imgUrl" /> <div class="card__footer"> <p>Added {{ addedOn }}</p> <p>{{$tc("card.likes", likes)}}</p> </div> </div> </template> <script> // ... </script> <style scoped> /* ... */ </style>
$tc()
is similar to $t()
, except it takes a second, number argument that determines how it chooses between the plural forms we provided in the respective message. With the above changes in place, we get dynamic plural output.
🗒 Note » We can use {count}
instead of {n}
. Vue I18n treats either of them as the special count characrer in plural messages.
So much ❤️
Notice that we haven't included our Arabic messages yet. That's because we have a bit of a problem with Arabic, and we'll need custom logic to solve it.
Custom Pluralization
Vue I18n works with languages that have three plural forms out of the box. However, some languages have different plural rules. Arabic, for example, has six plural forms. For these scenarios, we'll need to override VueI18n
's getChoiceIndex()
method.
let defaultChoiceIndex export function setDefaultChoiceIndexGet(fn) { defaultChoiceIndex = fn } /** * @param choice {number} a choice index given by the input to * $tc: `$tc('path.to.rule', choiceIndex)` * @param choicesLength {number} an overall amount of available choices * @returns a final choice index to select plural word by **/ export function getChoiceIndex(choice, choicesLength) { if (defaultChoiceIndex === undefined) { return choice } // this === VueI18n instance, so the locale property also exists here if (this.locale !== "ar") { return defaultChoiceIndex.apply(this, [choice, choicesLength]) } if ([0, 1, 2].includes(choice)) { return choice } if (3 <= choice && choice <= 10) { return 3 } if (11 <= choice && choice <= 99) { return 4 } return 5 }
We create a little utility file for ourselves that houses two functions, setDefaultChoiceIndexGet()
and getChoiceIndex()
. We'll use getChoiceIndex()
to override the VueI18n
instance's method by the same name. getChoiceIndex()
does the work of determining which plural form to choose in a given message. We will sometimes want to defer to VueI18n
's original implementation of getChoiceIndex()
, so we'll provide setDefaultChoiceIndexGet()
so that calling code can provide a reference to that original function for us. getChoiceIndex()
receives two arguments, choice: number
and choicesLength: number
and is expected to return a number
that it determines to be the index of the plural form to use in a message. The choice
parameter is the given value of n
or count
. The choicesLength
is the number of choices in the plural message. For example, the message "foo | bar | man"
would give a choiceLength
of 3
. In our code above, we've decided to keep things simple and ignore choiceLength
, assuming that all Arabic plural messages will provide all 6 plural forms that are common to Arabic:
- 0
- 1
- 2
- 3-10
- 11-99
- 100+
Let's use this logic by wiring it up to VueI8n
's prototype.
import Vue from "vue" import VueI18n from "vue-i18n" import getBrowserLocale from "@/util/i18n/get-browser-locale" import { supportedLocalesInclude } from "./util/i18n/supported-locales" import { getChoiceIndex, setDefaultChoiceIndexGet } from "./util/i18n/choice-index-for-plural" Vue.use(VueI18n) // ... setDefaultChoiceIndexGet(VueI18n.prototype.getChoiceIndex) VueI18n.prototype.getChoiceIndex = getChoiceIndex export default new VueI18n({ locale: getStartingLocale(), fallbackLocale: process.env.VUE_APP_I18N_FALLBACK_LOCALE || "en", messages: loadLocaleMessages() })
We make sure to call setDefaultChoiceIndexGet()
with the original getChoiceIndex()
before overriding with our own. This is because our getChoiceIndex()
logic defers to the original implementation for non-Arabic locales. With that in place, we now have proper Arabic pluralization.
{ // ... "card": { "likes": "لا توجد ❤️ إلى الآن 😕 | شخص {n} ❤️ هذا | شخصان ❤️ هذا | {n} أشخاص ❤️ هذا | {n} شخص ❤️ هذا | {n} شخص ❤️ هذا " } }
Our Arabic plurals are taking their many, correct forms
Formatting Dates
As it is, our "Added on" dates for our products are displayed as they are in our JSON data.
Not the prettiest date
Luckily, Vue I18n taps into the standard Intl.DateTimeFormat to allow for flexible date formatting. First, let's create a dateTimeFormats
object to pass to the VueI8n
constructor.
const dateTimeFormats = { en: { short: { year: "numeric", month: "short", day: "numeric" } }, ar: { short: { year: "numeric", month: "long", day: "numeric" } } } export default dateTimeFormats
We key our object by our supported locale codes. Under each locale key we can have any number of formats we want. In the above code, we provide a "short"
date format, which differs subtly across locales. Notice that the format is defined as an object: this object is passed as the options
parameter to the Intl.DateTimeFormat constructor, which handles the formatting. Now we can pass this dateTimeFormats
object to the Vue I18n constructor for it to take effect.
import Vue from "vue" import VueI18n from "vue-i18n" import getBrowserLocale from "@/util/i18n/get-browser-locale" import { supportedLocalesInclude } from "./util/i18n/supported-locales" import { getChoiceIndex, setDefaultChoiceIndexGet } from "./util/i18n/choice-index-for-plural" import dateTimeFormats from "@/locales/date-time-formats" Vue.use(VueI18n) // ... export default new VueI18n({ locale: getStartingLocale(), fallbackLocale: process.env.VUE_APP_I18N_FALLBACK_LOCALE || "en", messages: loadLocaleMessages(), dateTimeFormats })
With our wiring done, we can now use Vue I18n's $d()
function to display the formatted date.
<template> <div class="card"> <h3>{{ title }}</h3> <img :src="imgUrl" /> <div class="card__footer"> <p> {{ $t("card.added") }} {{ $d(new Date(addedOn), "short") }} </p> <p>{{$tc("card.likes", likes)}}</p> </div> </div> </template> <script> // ... </script> <style scoped> /* ... */ </style>
The d()
function is another that the Vue I18n plugin provides to our components and is responsible for formatting dates. Note that the first parameter to d()
must be a Date
object, so we parse our addedOn
string to a Date
when we pass it to d()
. Now our dates are much more presentable.
Our dates are now formatted per-locale and per our liking
Formatting Numbers
Much like it handles date formatting, Vue I18n delegates its general number formatting to the standard Intl.NumberFormat. And much like date formatting, we provide a numberFormats
object to the VueI8n
constructor to determine our localized number formats.
const numberFormats = { en: { currency: { style: "currency", currency: "USD" } }, ar: { currency: { style: "currency", currency: "USD", currencyDisplay: "code" } } } export default numberFormats
We use our locale codes as keys. Below each key, we place as many formats as we like. Each format is an object that will be passed to the Intl.Numberformat constructor as an options
parameter. In the above, we specify currency formats for each of our two supported locales. We assume our currency is US dollars, and ensure that the ISO code ("USD", not the "$" symbol) will be shown in Arabic. Let's wire up this object to our VueI18n
constructor.
import Vue from "vue" import VueI18n from "vue-i18n" import getBrowserLocale from "@/util/i18n/get-browser-locale" import { supportedLocalesInclude } from "./util/i18n/supported-locales" import { getChoiceIndex, setDefaultChoiceIndexGet } from "./util/i18n/choice-index-for-plural" import dateTimeFormats from "@/locales/date-time-formats" import numberFormats from "@/locales/number-formats" Vue.use(VueI18n) // ... export default new VueI18n({ locale: getStartingLocale(), fallbackLocale: process.env.VUE_APP_I18N_FALLBACK_LOCALE || "en", messages: loadLocaleMessages(), dateTimeFormats, numberFormats })
With the numberFormats
option in place, we can now use our formats in our Vue components. Let's add a price to our data to demonstrate.
[ { "id": 1, "title": "Battlecreek Columbia Coldono", "price": 29.99, "imgUrl": "/img/battlecreek-coffee-roasters-_1wDmr4dtuk-unsplash.jpg", "addedOn": "2020-01-02", "likes": 3 }, // ... ]
We can reference this price in our Card
component and use Vue I18n's $n()
to display it.
<template> <div class="card"> <h3>{{ title }}</h3> <img :src="imgUrl" /> <div class="card__footer"> <div class="card__meta"> <p class="price">{{$n(price, "currency")}}</p> <p>{{ $d(new Date(addedOn), "short") }}</p> </div> <p class="likes">{{$tc("card.likes", likes)}}</p> </div> </div> </template> <script> export default { props: { id: Number, title: String, price: Number, imgUrl: String, addedOn: String, likes: Number } } </script> <style scoped> .card { width: 30%; margin-bottom: 2rem; border: 1px solid #f2f2f2; border-radius: 4px; box-shadow: 0 2px 2px rgba(0, 0, 0, 0.1); } h3 { margin: 0; padding: 0.5em; text-align: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } img { width: 100%; } .card__footer { text-align: center; margin: 0; padding: 0.5em; } .card__meta { display: flex; justify-content: space-between; align-items: baseline; } p { margin: 0; font-size: 14px; } .price { margin-left: 0.5rem; font-size: 1.25rem; font-weight: bold; } .likes { text-align: end; margin-top: 0.5rem; } </style>
A lot like the d()
function, n()
takes a number
and the key of the format we specified as parameters. We now have prices formatted differently per locale.
Now accepting payments from multiple locales
Changing the Document Language & Layout Direction
Arabic, Hebrew, and other languages are written from right to left, and pages presented in those locales need to be laid out in that direction as well. To accomplish this, we can listen for locale changes and update the document.dir
property in the DOM. While we're at it, let's update the lang
property of our document to reflect the currently active locale.
export function setDocumentDirectionPerLocale(locale) { document.dir = locale === "ar" ? "rtl" : "ltr" } export function setDocumentLang(lang) { document.documentElement.lang = lang }
We've added a helper function that takes a locale and sets the document direction. Another sets the lang
attribute on the HTML
element for correct meta. We can use these functions in our root App
component.
<template> <div id="app"> <Nav /> <div class="container"> <router-view /> </div> <Footer /> </div> </template> <script> import Nav from "@/components/Nav" import Footer from "@/components/Footer" import { setDocumentDirectionPerLocale, setDocumentLang } from "@/util/i18n/document" export default { components: { Nav, Footer }, mounted() { this.$watch( "$i18n.locale", (newLocale, oldLocale) => { if (newLocale === oldLocale) { return } setDocumentLang(newLocale) setDocumentDirectionPerLocale(newLocale) }, { immediate: true } ) } } </script> <style> /* ... */ </style>
When the App
component mounts to the DOM, we start watching the active locale for changes.
🗒 Note » We use the imperative this.$watch
, and not the watch
property of the component, so that we can provide the immediate
option. When true
, the immediate
property causes our watcher to fire once during setup with the current value of the active locale. Otherwise, our watcher won't fire on app start, and we won't be able to set our document properties on initialization.
With the logic above, we both initialize and sync our document language and direction to the active locale's direction at all times.
English is displaying in left-to-right, Arabic in right-to-left
Localizing the Document Title
In a localized SPA we might want to change the document title, which appears in browser tabs, depending on the active locale. We already have an "app.title"
in our message files. We also have a watcher from the previous section that allows us to run logic when the active locale changes. Let's add a utility function and that helps wire these things up to localize our document title.
export function setDocumentDirectionPerLocale(locale) { document.dir = locale === "ar" ? "rtl" : "ltr" } export function setDocumentLang(lang) { document.documentElement.lang = lang } export function setDocumentTitle(newTitle) { document.title = newTitle }
Now we can use our new function in our App
component, much like we did with document language & layout direction.
<template> <div id="app"> <Nav /> <div class="container"> <router-view /> </div> <Footer /> </div> </template> <script> import Nav from "@/components/Nav" import Footer from "@/components/Footer" import { setDocumentDirectionPerLocale, setDocumentTitle, setDocumentLang } from "@/util/i18n/document" export default { components: { Nav, Footer }, mounted() { this.$watch( "$i18n.locale", (newLocale, oldLocale) => { if (newLocale === oldLocale) { return } setDocumentLang(newLocale) setDocumentDirectionPerLocale(newLocale) setDocumentTitle(this.$t("app.title")) }, { immediate: true } ) } } </script> <style> /* ... */ </style>
The browser tab's title will now show our app's title translated in the currently active locale.
Async (Lazy) Loading of Translation Files
If our app's message files get past a certain size, and if we bundle them along with our main app bundle, as we're currently doing, we'll be making our app unnecessarily heavy for our users' first load. To counter this, we can use Webpack's code-splitting feature to lazy load translation files when they're needed. We'll need to update our i18n.js
file, and the way we load message files.
import Vue from "vue" import VueI18n from "vue-i18n" import getBrowserLocale from "@/util/i18n/get-browser-locale" import { supportedLocalesInclude } from "./util/i18n/supported-locales" import { getChoiceIndex, setDefaultChoiceIndexGet } from "./util/i18n/choice-index-for-plural" import dateTimeFormats from "@/locales/date-time-formats" import numberFormats from "@/locales/number-formats" Vue.use(VueI18n) function getStartingLocale() { const browserLocale = getBrowserLocale({ countryCodeOnly: true }) if (supportedLocalesInclude(browserLocale)) { return browserLocale } else { return process.env.VUE_APP_I18N_LOCALE || "en" } } setDefaultChoiceIndexGet(VueI18n.prototype.getChoiceIndex) VueI18n.prototype.getChoiceIndex = getChoiceIndex const startingLocale = getStartingLocale() const i18n = new VueI18n({ locale: startingLocale, fallbackLocale: process.env.VUE_APP_I18N_FALLBACK_LOCALE || "en", messages: {}, dateTimeFormats, numberFormats }) const loadedLanguages = [] export function loadLocaleMessagesAsync(locale) { if (loadedLanguages.length > 0 && i18n.locale === locale) { return Promise.resolve(locale) } // If the language was already loaded if (loadedLanguages.includes(locale)) { i18n.locale = locale return Promise.resolve(locale) } // If the language hasn't been loaded yet return import( /* webpackChunkName: "locale-[request]" */ `@/locales/${locale}.json` ).then(messages => { i18n.setLocaleMessage(locale, messages.default) loadedLanguages.push(locale) i18n.locale = locale return Promise.resolve(locale) }) } loadLocaleMessagesAsync(startingLocale) export default i18n
First, we remove the loadLocaleMessages()
function that Vue I18n installed for us; we won't need it since we're implementing a different way to load translation messages. We grab the starting locale as usual, but we keep it in a variable called startingLocale
, since we'll need that a bit later. Also, instead of export default
ing the i18n
instance as soon as we create it, we hold onto it in an i18n
variable, which we'll need to access in our loading function.
To cache the message files we've already loaded, we maintain a loadedLanguages
array. In our new function, loadLocaleMessagesAsync()
, we first check if this array has any values. If it does, then we've loaded messages before, so we check if the requested locale is the same as the previous one. If that's true as well, we don't need to do anything, since presumably we've already loaded and cached the messages for the requested locale.
Otherwise, we check if the requested locale's messages have been loaded before. If they have, we simply set the requested locale as the active locale and we're done. If we haven't loaded messages for the requested locale, however, we need to do that. We perform a dynamic import for the locale message file with import().then()
. This will cause Webpack to code-split our app bundle so that our locale message files are separated into individual files, and not included in the main app bundle.
The /* webpackChunkName: "locale-[request]" */
comment on the same line as the path to the message file instructs Webpack to give our message files special names when it code-splits them. The Arabic message file will be called locale-ar.json
, for example, making it easier to track when the file is loaded. Before we export the i18n
object, we call our new function loadLocaleMessagesAsync
with the startingLocale
to ensure that our app is initialized with a locale.
✋🏽 Heads up » By default, the Vue Webpack config will make Webpack prefetch all the async modules, including our locale message files. This kind of defeats the purpose of lazy loading, and we can turn it off by deleting the prefetch plugin using the vue.config.js
file.
module.exports = { chainWebpack: config => { config.plugins.delete("prefetch") }, pluginOptions: { i18n: { locale: "en", fallbackLocale: "en", localeDir: "locales", enableInSFC: false } } }
Now let's update our App.vue
component to handle the new, asynchronous loading of message files.
<template> <div id="app"> <div v-if="isLoading">Loading...</div> <div v-else> <Nav v-on:localeChange="loadLocaleMessages" /> <div class="container"> <router-view /> </div> <Footer /> </div> </div> </template> <script> import Nav from "@/components/Nav" import Footer from "@/components/Footer" import { setDocumentDirectionPerLocale, setDocumentTitle, setDocumentLang } from "@/util/i18n/document" import { loadLocaleMessagesAsync } from "@/i18n" export default { data: () => ({ isLoading: true }), mounted() { this.loadLocaleMessages(this.$i18n.locale) }, methods: { loadLocaleMessages(locale) { this.isLoading = true loadLocaleMessagesAsync(locale).then(() => { setDocumentLang(locale) setDocumentDirectionPerLocale(locale) setDocumentTitle(this.$t("app.title")) this.isLoading = false }) } }, components: { Nav, Footer } } </script> <style> /* ... */ </style>
We need a loading state while a given message file is coming down the pipe. Otherwise, we'll attempt to display component UI before any messages are available. Notice that we're also subscribed to a localeChange
event on the Nav
component. We'll get to that in a minute. Suffice it to say for now that we'll simply be informed when a new locale is selected from the LocaleSwitcher
via this event. When our App
mounts, and again whenever the user selects a new locale, we call the new loadLocaleMessages()
component method, which in turn calls the loadLocaleMessagesAsync()
function in our i18n module, and sets the document attributes per the active locale once the message file has loaded.
<template> <div class="nav"> <div class="nav__start"> <!-- ... --> </div> <div class="nav__end"> <p class="user-greeting">{{ $t("user_greeting", { name: "Adam" }) }}</p> <LocaleSwitcher v-on:change="$emit('localeChange', $event)" /> </div> </div> </template> <script> import LocaleSwitcher from "@/components/LocaleSwitcher" export default { components: { LocaleSwitcher } } </script> <style scoped> /* ... */ </style>
Our Nav
component passes through a localeChange
event to its parent (our App
in this case). To do so, it subscribes to a change
event on the LocaleSwitcher
component.
<template> <div class="locale-switcher"> <select :value="$i18n.locale" @change.prevent="$emit('change', $event.target.value)" > <option :value="locale.code" v-for="locale in locales" :key="locale.code"> {{locale.name}} </option> </select> </div> </template> <script> import { getSupportedLocales } from "@/util/i18n/supported-locales" import { loadLocaleMessagesAsync } from "@/i18n" export default { data: () => ({ locales: getSupportedLocales() }) } </script>
No longer setting i18n.locale
directly, our LocaleSwitcher
simply emits a change
event whenever the user selects a new locale. And with that in place, our app is now code-split so that locale message files are lazy-loaded on request. On apps with larger message files and/or many locales, lazy loading should yield significant improvements to the initial app load, which is great for UX.
Localized Routes
In an SPA we may well want our routes to be localized. Instead of /foo
, we would have /en/foo
and /ar/foo
. The locale code in the URI would determine the active locale of our app. This isn't too hard to do with Vue's first party, Vue Router, which we're already using in our app (albeit without localization).
import Vue from "vue" import VueRouter from "vue-router" import Home from "../views/Home.vue" Vue.use(VueRouter) const routes = [ { path: "/", name: "home", component: Home }, { path: "/about", name: "about", // route level code-splitting // this generates a separate chunk (about.[hash].js) for this route // which is lazy-loaded when the route is visited. component: () => import(/* webpackChunkName: "about" */ "../views/About.vue") } ] const router = new VueRouter({ mode: "history", base: process.env.BASE_URL, routes }) export default router
This is how the Vue CLI sets up our app when we select the router option during installation. We'll need to reorganize these routes to introduce a :locale
route parameter.
import Vue from "vue" import VueRouter from "vue-router" import Home from "../views/Home.vue" Vue.use(VueRouter) const routes = [ { path: "/", name: "home", component: Home }, { path: "/about", name: "about", // route level code-splitting // this generates a separate chunk (about.[hash].js) for this route // which is lazy-loaded when the route is visited. component: () => import(/* webpackChunkName: "about" */ "../views/About.vue") } ] const router = new VueRouter({ mode: "history", base: process.env.BASE_URL, routes }) export default router
We've made our root /
route redirect to /en
or /ar
, depending on locale resolution in our i18n
module. All our app's routes are now nested under this latter, :/locale
, route. This means that our routes are always localized, e.g. our about page route is either /en/about
or /ar/about
. We use the router.beforeEach()
navigation guard to catch any route changes, and watch for the case when a route change means that we've changed locales. When this happens, we call our usual locale loading logic, starting with loadLocaleMessagesAsync()
, which was previously in our App
component. Notice that the top /:locale
route is associated with a Root
component. This is just a container with a <router-view>
to render the component's children.
<template> <router-view /> </template>
Our locale switching effectively happens in the router.beforEach()
navigation guard now, so we'll need to update our other modules to accommodate that. First, let's add a simple global event bus. This will allow any module in our app to listen for async locale message file loading events, one for begin loading and another for end loading, which we'll add in a moment.
import Vue from "vue" const EventBus = new Vue() export default EventBus
A separate Vue
instance can serve as a perfect event bus. Now we can use this to emit our events in our i18n
module.
import Vue from "vue" import VueI18n from "vue-i18n" import getBrowserLocale from "@/util/i18n/get-browser-locale" import { supportedLocalesInclude } from "./util/i18n/supported-locales" import { getChoiceIndex, setDefaultChoiceIndexGet } from "./util/i18n/choice-index-for-plural" import dateTimeFormats from "@/locales/date-time-formats" import numberFormats from "@/locales/number-formats" import EventBus from "@/EventBus" Vue.use(VueI18n) // ... const loadedLanguages = [] export function loadLocaleMessagesAsync(locale) { EventBus.$emit("i18n-load-start") if (loadedLanguages.length > 0 && i18n.locale === locale) { EventBus.$emit("i18n-load-complete") return Promise.resolve(locale) } // If the language was already loaded if (loadedLanguages.includes(locale)) { i18n.locale = locale EventBus.$emit("i18n-load-complete") return Promise.resolve(locale) } // If the language hasn't been loaded yet return import( /* webpackChunkName: "locale-[request]" */ `@/locales/${locale}.json` ).then(messages => { i18n.setLocaleMessage(locale, messages.default) loadedLanguages.push(locale) i18n.locale = locale EventBus.$emit("i18n-load-complete") return Promise.resolve(locale) }) } export default i18n
We simply $emit()
an "i18n-load-start"
event at the beginning of our loadLocaleMessagesAsync()
function. Whenever we resolve our locale successfully, we emit an "i18n-load-complete"
event. Now we have a global notification whenever we start and complete a locale message file load.
<template> <div id="app"> <div v-if="isLoading">Loading...</div> <div v-else> <Nav /> <div class="container"> <router-view /> </div> <Footer /> </div> </div> </template> <script> import Nav from "@/components/Nav" import Footer from "@/components/Footer" import EventBus from "@/EventBus" export default { data: () => ({ isLoading: true }), mounted() { EventBus.$on("i18n-load-start", () => (this.isLoading = true)) EventBus.$on("i18n-load-complete", () => (this.isLoading = false)) }, components: { Nav, Footer } } </script> <style> /* ... */ </style>
We pull our EventBus
into our App
and use it to determine whether we should show our UI or our loading state. Our App
component is no longer responsible for loading message files. That responsibility has moved over to our routing. All the App
component is doing now making sure that the UI doesn't look broken while we're loading message files. To round out our localized routing logic, let's update our LocaleSwitcher
to redirect to a localized route when the user changes her language.
<template> <div class="locale-switcher"> <select :value="$i18n.locale" @change.prevent="changeLocale"> <option :value="locale.code" v-for="locale in locales" :key="locale.code"> {{locale.name}} </option> </select> </div> </template> <script> import { getSupportedLocales } from "@/util/i18n/supported-locales" export default { data: () => ({ locales: getSupportedLocales() }), methods: { changeLocale(e) { const locale = e.target.value this.$router.push(`/${locale}`) } } } </script>
We use the $router
object—which Vue makes available to our components when we register the Vue Router plugin—to programmatically navigate to /ar
if the user selects Arabic. That about does it. We now have localized routes in our Vue SPA.
Localized Router Links
Continuing our localized routing from the last section, let's take a look at the current state of our Nav
component.
<template> <div class="nav"> <div class="nav__start"> <img alt="Vue logo" src="../assets/logo-circle-sm.png" /> <router-link to="/">{{ $t("nav.home") }}</router-link> <router-link to="/about">{{ $t("nav.about") }}</router-link> </div> <div class="nav__end"> <p class="user-greeting">{{ $t("user_greeting", { name: "Adam" }) }}</p> <LocaleSwitcher /> </div> </div> </template> <script> import LocaleSwitcher from "@/components/LocaleSwitcher" export default { components: { LocaleSwitcher } } </script> <style scoped> /* ... */ </style>
Notice that our <router-link>
s aren't really localized. The "/about"
link will currently cause our app to error out since we only support "/en/about"
and "/ar/about"
. Let's write a quick LocalizedLink
component that we can use to solve this.
<template> <router-link :to="getTo()"> <slot /> </router-link> </template> <script> export default { props: ["to"], methods: { getTo() { if (typeof this.to !== "string") { return this.to } const locale = this.$route.params.locale // we strip leading and trailing slashes and prefix // the current locale return `/${locale}/${this.to.replace(/^\/|\/$/g, "")}` } } } </script>
We wrap a <router-link>
with our own custom logic, transforming any to
string parameter we receive into one with a locale prefix. So if we get to="about"
, and the current locale is Arabic, we pass to="ar/about"
to <router-link>
. We can now use our LocalizedLink
to fix the links in our Nav
component.
<template> <div class="nav"> <div class="nav__start"> <img alt="Vue logo" src="../assets/logo-circle-sm.png" /> <LocalizedLink to="/">{{ $t("nav.home") }}</LocalizedLink> <LocalizedLink to="/about">{{ $t("nav.about") }}</LocalizedLink> </div> <div class="nav__end"> <p class="user-greeting">{{ $t("user_greeting", { name: "Adam" }) }}</p> <LocaleSwitcher /> </div> </div> </template> <script> import LocaleSwitcher from "@/components/LocaleSwitcher" import LocalizedLink from "@/components/LocalizedLink" export default { components: { LocaleSwitcher, LocalizedLink } } </script> <style scoped> /* ... */ </style>
Our links are working after this change. Now we can just pull in and use LocalizedLink
without worrying about locales.
🔗 Resource » You can grab the code for the entire, completed app from GitHub.
Closing Up
That's it for this one, folks. We hope you enjoyed it, and that you learned a thing or two about Vue localization with the Vue I18n library. And if you want to give your team the ultimate translation experience, check out the Vue I18n Phrase in-context editor.
Installed with a few lines of code, the Phrase in-context editor will power up your Vue app with a powerful toolset, allowing your translators to work on app localization within the context of your app! This means no going back and forth between your app and the translation tooling: it’s all in one place. And don't worry, you can control whether translations happen in a sandbox or in the production version of your app.
The Vue I18n in-context editor comes with Phrase, a comprehensive localization solution with powerful features like a CLI, translation syncing, a beautiful web console for translators, and much more. Take a look at all Phrase features for developers, and see for yourself how it can make your life easier.