Vue.js Tutorial on Building an SPA with I18n Support
source link: https://phrase.com/blog/posts/how-to-build-spa-vue-js-i18n/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
Vue.js Tutorial on Building an SPA with I18n Support
The elegant and refreshingly approachable Vue.js is no slouch when it comes to JavaScript view frameworks. Created be ex-Google engineer Evan You, Vue.js is very well suited to progressively enhance existing traditional web applications. And with first-party router and state management implementations, Vue is perfectly capable of running full-on single page applications (SPAs). In this article we'll build a demo SPA with Vue, focusing on what it takes to i18n-ize and localize on the browser.
Update » We’ve updated this article to use Vue 2.6 and i18next 15.1. We've also upgraded all the other libraries. Very little application code had to be changed for the upgrade. However, production build scripts did receive some significant changes. Check below for more details.
Let’s hypothesize that Kiki and Akira Fudo are a Japanese couple with a passion for retro electronics, especially computers and video game consoles. They run a small refurbished electronics store in Tokyo called Gawa, and they want a web presence. To begin with, they’re looking for an online catalog to showcase to international leads so they can grow their market. They know they need their site content to be in Japanese as well as English. They’ve also been getting quite a few orders from Tel Aviv, where there is an active community of retro console collectors. So they would like to localize their content in Hebrew as well. They brought the project to us, and would like to see a rough demo.
We decided it would be best to go with the Vue.js view framework for this app, as George, one of the junior front-end engineers on the team, has never used a JavaScript view framework. Vue is a great choice for a newcomer, since its components are built with good old HTML, CSS, and JavaScript, and don’t look as intimidating as Angular or React’s counterparts.
The App
After a brief conversation with Akira and Kiki, we agree that the demo should focus on a simple product catalogue with l10n in English, Hebrew, and Japanese. Here’s our laundry list:
- Home page with featured products
- Product index with catalogue
- Product details page
- Locale switching between English, Hebrew, and Japanese
Our Framework
Note » I will assume that you are familiar with the Vue.js framework. Knowledge of vue-router and Bootstrap is helpful, but not necessary.
We’ll need a few libraries to help us out. Here they are (with versions in parentheses):
- Vue.js (2.6) — view framework
- vue-router (3.0) — for in-browser routing
- i18next (15.1) — for UI i18n
- lodash (4.17) — for general JavaScript utilities
Bootstrap (4.3) — for CSS styling, along with its dependencies:
- country-flags (1.2) — SVG flag images for our language switcher
Note » We upgraded our packages to the ones above. We also upgraded our development dependencies, and when doing so production build scripts had to be updated. Here’s the commit diff so you can see all the changes. The most important ones are in the
/build
directory andpackage.json.
Alright, let’s get started.
Learn how to find the best i18n manager and follow our best practices for making your business a global success.
Check out the guideScaffolding
Once we initialize the project with the Vue CLI, we can set up our project directory structure. Thankfully the Vue CLI creates reasonable defaults for us. We’ll just add src/config
and src/services
directories to house our i18n-specific configuration and libraries, respectively. Our directory structure can then look like this.
/ ├── build/ ├── config/ ├── dist/ ├── src/ │ ├── assets/ │ ├── components/ │ ├── config/ │ ├── routers/ │ ├── services/ │ ├── App.vue │ └── main.js └── static/ ├── api/ ├── product-images/ ├── styles/ └── translations/
Note » You can find the entire codebase for this app on GitHub. You can also see the app running live on Heroku.
Let’s get scaffolding. We know we’re i18n-izing this puppy, so let’s add a single source of truth for our supported locales and the default locale.
/src/config/i18n.js
export const defaultLocale = 'en' export const locales = [ { code: 'en', name: 'English', dir: 'ltr', }, { code: 'he', name: 'עִברִית', dir: 'rtl', }, { code: 'ja', name: '日本語', dir: 'ltr', }, ]
We setup each supported locale with its ISO 639-1 code, e.g. ja
for Japanese, along with its name in its own language, and its language direction (left-to-right or right-to-left). Sweet and simple.
Now let’s get to our root App
component.
/src/App.vue
<template> <div id="app" class="app-wrapper"> <navbar /> <main class="container" role="main"> <router-view /> </main> </div> </template> <script> import 'bootstrap' import 'bootstrap/dist/css/bootstrap.min.css' import Navbar from './components/Navbar' export default { name: 'App', components: { Navbar, }, } </script> <style scoped> .app-wrapper { margin-top: 85px; } </style>
Our master layout lives our in App
’s view <template>
. We will be using vue-router to handle our routing, so we’ll preemptively add its <router-view>
component. <router-view>
houses one of our components that matches a route. So if we hit the /about
route, the About
component’s contents will render in the <router-view>
. We’ll get to routing details in a moment.
After pulling in the Bootstrap JavaScript and CSS, we import our custom Navbar
. We then add a bit of our own CSS to lay things out a bit better, making sure that it’s scoped
to our component to avoid accidental style leakage.
So, what about that Navbar
? To answer that, we’ll detour to our routing, and come back to navigation a bit later.
Routing & Locale Determination
The Vue CLI created a route configuration for us when we setup our project. Let’s take a look at it.
/src/router/index.js (excerpt)
import Vue from 'vue' import Router from 'vue-router' /* ... */ Vue.use(Router) export default new Router({ routes: [ /* ... */ ], })
The official vue-router
plugin is added to Vue via the static Vue.use()
method. A Router
instance is then instantiated and given our route configuration. This instance is pulled in to our start main.js
file during instantiation of our root Vue instance.
/src/main.js
import Vue from 'vue' import router from './router' import App from './App' Vue.config.productionTip = false new Vue({ el: '#app', router, components: { App }, template: '<App/>', })
This was largely scaffolded for us by the Vue CLI when we created the project. That’s one cool thing of having a first-party router: it was built from the ground up to play well with the framework.
Note » Once we add our router to our root Vue instance, a few components become available globally to our own custom components. The
<router-view>
we saw earlier is one of those.
Back to our route configuration. We’ll determine our current locale via the first segment in the active URI, so if we’re currently on /he/foo
then we know to display our content in he
, Hebrew. With that in mind, let’s add our routes.
/src/router/index.js
import Vue from 'vue' import Router from 'vue-router' import { defaultLocale } from '../config/i18n' Vue.use(Router) export default new Router({ routes: [ { path: '/', redirect: `/${defaultLocale}`, }, { path: '/:locale', component: { template: '<router-view />', }, children: [ { path: '', component: { template: '<div>Home</div>', }, }, { path: 'products/:id', component: { template: '<div>ProductShow</div>', }, }, { path: 'products', component: { template: '<div>ProductIndex</div>', }, }, { path: 'about', component: { template: '<div>About</div>', }, }, ], }, ], })
To ensure that that we always have a locale set for us, we redirect our root route to one with our default locale, so /
will redirect to /en
in our case. We configure a dynamic /:locale
route that accepts a locale
route parameter. This parameter will come in handy whenever we need to know our current locale.
We render out a simple component for the top /:locale
path. It houses a <router-view />
to render its sub-routes’ components. vue-router’s handy children
route property helps us nest those sub-routes easily. We create one sub-route for each of our main pages, stubbing out their components with templates that we can use for testing our routing.
Once all this is in place, when we hit /ja/about
our mock About component will render—and the route locale
parameter will be set to ja
.
Note » Setting a child route’s
path
to an empty string''
designates the child route as the default child of its parent. In our case, hitting/ja
will cause our Home component to render in the parent’s<router-view />
.Note » Be careful of route shadowing. Notice that in the above code
products/:id
comes beforeproducts
. Ifproducts
came first, it would match bothproducts
andproducts/:id
, so our:id
component would never get rendered.
Navigation & Locale Switching
With our routes in place, let’s tackle navigation. Our navbar will look like this.
Let’s code it up.
/src/components/Navbar.vue
<template> <nav class="navbar navbar-expand-md navbar-dark fixed-top"> <router-link class="navbar-brand" to="/"> <img width="36" height="36" src="../assets/logo.png" class="d-inline-block align-top mr-2" /> <span class="mr-2">Gawa</span> </router-link> <button type="button" aria-expanded="false" data-toggle="collapse" class="navbar-toggler" data-target="#navbarCollapse" aria-controls="navbarCollapse" aria-label="Toggle navigation" > <span class="navbar-toggler-icon"></span> </button> <div class="collapse navbar-collapse" id="navbarCollapse"> <ul class="navbar-nav mr-auto"> <router-link tag="li" to="products" class="nav-item" active-class="active" > <a class="nav-link"> Products </a> </router-link> <router-link tag="li" to="about" class="nav-item" active-class="active" > <a class="nav-link"> About </a> </router-link> </ul> <ul class="navbar-nav ml-auto"> <locale-switcher /> </ul> </div> </nav> </template> <script> import LocaleSwitcher from './LocaleSwitcher' export default { computed: { currentLocale() { return this.$route.params.locale }, }, components: { LocaleSwitcher, }, } </script> <style scoped> /* ... */ </style>
A lot of the HTML in our Navbar
is just Bootstrap Navbar markup. Notice, however, that we’re using <router-link>
s to render out .navbar-brand
’s <a>
tag. <router-link>
is another vue-router component that is now available to all our app’s components. It allows us to link to any of our configured routes to get their components to render in a <router-view>
(which we defined in our master layout and the /:locale
route component).
Note » The vue-router documentation does a beautiful job of detailing how router-link works.
You may have noticed that the app name and link text in our Navbar
is hard-coded, which isn’t exactly i18n. Once we implement UI translation, this can be easy to update.
🔎 Of special interest to us is the <locale-switcher />
.
Locale Switching
/src/components/LocaleSwitcher.vue
<template> <li class="nav-item dropdown"> <a href="#" role="button" id="navbarDropdown" aria-haspopup="true" aria-expanded="false" data-toggle="dropdown" class="nav-link dropdown-toggle" > <img :src="icons[currentLocale]" class="country-icon as-toggle" /> </a> <div aria-labelledby="navbarDropdown" class="dropdown-menu dropdown-menu-right" > <router-link v-for="locale in locales" :key="locale.code" :to="`/${locale.code}`" class="dropdown-item" > <img :src="icons[locale.code]" class="country-icon" /> <span class="locale-name">{{locale.name}}</span> </router-link> </div> </li> </template> <script> import enIcon from 'svg-country-flags/svg/gb.svg' import heIcon from 'svg-country-flags/svg/il.svg' import jaIcon from 'svg-country-flags/svg/jp.svg' import { locales } from '../config/i18n' export default { data() { return { icons: { en: enIcon, he: heIcon, ja: jaIcon, }, locales, } }, computed: { currentLocale() { return this.$route.params.locale }, }, } </script> <style scoped> .country-icon { width: 20px; height: auto; display: inline-block; vertical-align: baseline; border: 1px solid #dee2e6; box-shadow: 0px 1px 3px rgba(24, 29, 38, 0.1); } .country-icon.as-toggle { margin-top: 5px; } .locale-name { display: inline-block; vertical-align: baseline; } </style>
Let’s start with our data.
/src/components/LocaleSwitcher.vue (excerpt)
<script> import enIcon from 'svg-country-flags/svg/gb.svg' import heIcon from 'svg-country-flags/svg/il.svg' import jaIcon from 'svg-country-flags/svg/jp.svg' import { locales } from '../config/i18n' export default { data() { return { icons: { en: enIcon, he: heIcon, ja: jaIcon, }, locales, } }, computed: { currentLocale() { return this.$route.params.locale }, }, } </script>
We pull in the relevant SVG flag images and map them to our locales for easy access in our views. We also make our configured locales
available to our view by including the object in our data. A convenience computed property, currentLocale
, is provided to our view, and it simply returns the /:locale
route param that we configured in our routes file.
Note » The
$route
object is available to all our app’s components because we included vue-router when we bootstrapped our root Vue instance. Read all about the route object in the vue-router docs.
With our data in place, our LocaleSwitcher
’s view logic becomes near trivial.
/src/components/LocaleSwticher.vue (excerpt)
<template> <li class="nav-item dropdown"> <a href="#" role="button" id="navbarDropdown" aria-haspopup="true" aria-expanded="false" data-toggle="dropdown" class="nav-link dropdown-toggle" > <img :src="icons[currentLocale]" class="country-icon as-toggle" /> </a> <div aria-labelledby="navbarDropdown" class="dropdown-menu dropdown-menu-right" > <router-link v-for="locale in locales" :key="locale.code" :to="`/${locale.code}`" class="dropdown-item" > <img :src="icons[locale.code]" class="country-icon" /> <span class="locale-name">{{locale.name}}</span> </router-link> </div> </li> </template>
Notice that we use our flag map, icons
, along with the computed currentLocale
, to show the flag of the current locale in the dropdown toggle. This makes the current locale clear to the site vistor.
Locale switching itself is handled in the unfolding dropdown items. We use a simple v-for
directive to iterate over our configured locales, and display the flag and name of each one.
Our switcher looks a bit like this.
Ok, looking good. Now let’s tackle the next piece of our i18n puzzle: the UI.
UI i18n with i18next
We’ve been working outside in for a bit. Let’s switch it up and work inside out for our UI i18n.
That will be quite enough of that, cat. For UI i18n, we need the following functionality:
- Translation file loading and parsing (one file per locale)
- Key-based retrieval from translation files e.g.
t('products')
becomes'Products'
in English and'製品'
in Japanese - Locale-specific date formatting
- And, because we’re dealing with products, locale-specific price formatting
Note » My Japanese and Hebrew are “not all there”, as Wonderland’s Cheshire Cat would say. I’m using Google Translate to generate the Japanese and Hebrew in the examples here. My apologies if they’re a bit upside-down.
The popular i18next library covers a lot of our UI i18n needs. We can write a simple wrapper around i18next and expose this wrapper to our components.
Note » We’ll be covering the basics of i18next here. We have an article that goes into i18next in much more detail.
/src/services/i18n/index.js
import _ from 'lodash' import i18next from 'i18next' import { locales } from '../../config/i18n' export const setUiLocale = (locale) => { if (!_.find(locales, supported => supported.code === locale)) { return Promise.reject(new Error(`Locale ${locale} is not supported.`)) } return fetch(`/static/translations/${locale}.json`) .then(response => response.json()) .then(loadedResources => ( new Promise((resolve, reject) => { i18next.init({ lng: locale, debug: true, resources: { [locale]: loadedResources }, }, (err) => { if (err) { reject(err) return } resolve() }) }) )) .catch(err => Promise.reject(err)) } export const t = (key, opt) => i18next.t(key, opt)
Loading Translation Files
Our main workhorse here is the exported setUiLocale(locale)
. This function wraps i18next’s intializer and exposes a simple Promise-based API. The first thing setUiLocale
does is to make sure the given locale
is supported by our app. It does so using lodash’s handy _.find
function.
It then pulls in the UI translation file for the given locale. setUiLocale
assumes that we’re placing our translation files in /static/translations/
. The JSON for these files is pretty straightforward.
/static/translations/ja.json (excerpt)
{ "translation": { "app_name": "ガワ", "slogan": "改装されたレトロ商品", "featured": "特集", "products": "製品", /* ... */ } }
i18next namespaces its translations under a translation
key by default, so our translation files adhere to that convention. Our translations are, in general, just key/value pairs.
Once our translation file is loaded, we initialize i18next with the file’s JSON. From that point on we can use our t()
wrapper, which we also export, to return translation values by key from the currently loaded locale file.
In our views…
{{t('app_name')}} {{t('added_by', { admin: 'Kiki Fudo' })}}
We can also interpolate values using i18next. Notice that we’re passing in a map with an admin
key in our second call to t
above. Our translation copy can have a placeholder that corresponds to this key.
/static/translations/fr.json (excerpt)
"added_by": "によって追加 {{admin}}"
The {{admin}}
placeholder will be replaced by "Kiki Fudo"
before t
outputs the value of added_by
. i18next really simplifies our UI i18n and l10n.
Note » We won’t use the exported
t
directly in our views. If we did this, we would have to add it to our components’data
every time we wanted to use it. The code above is just for example. We’ll get to how we can exposet
to all our components in one shot a bit later.
Formatting Dates
i18next doesn’t support formatting dates itself. Instead the library provides a way for us to inject a date formatting interpolator via an interpolation
option when we initialize it. Let’s update our setUiLocale
function to make use of that.
/src/services/i18n/index.js (excerpt)
import _ from 'lodash' import i18next from 'i18next' import { locales } from '../../config/i18n' import { formatDate } from './util' export const setUiLocale = (locale) => { /* ... */ return fetch(`/static/translations/${locale}.json`) .then(response => response.json()) .then(loadedResources => ( new Promise((resolve, reject) => { i18next.init({ lng: locale, debug: true, resources: { [locale]: loadedResources }, interpolation: { format(value, format, _locale) { if (value instanceof Date) { return formatDate(value, format, _locale) } return value }, }, }, // ...
Notice that our interpolation.format
function checks to see if the given value is a Date
, and delegates to formatDate
if it is.
We’ll jump into our date formatter in a minute. First let’s see how we want to use it.
/public/translations/en.json (excerpt)
"added_on": "Added in {{date, year:numeric;month:long}}",
i18next allows us to control the parameters we pass to our format interpolator. Given the above, if we were to call t('added_on', new Date('2018-02'))
, interpolation.format
would receive "year:numeric;month:long"
as its second parameter.
It’s up to us to handle this format. We could pull in a library like Moment.js for date formatting, but for a proof of concept like this app Moment is overkill. Instead, we’ll use the Intl
API built into most modern browsers.
const format = new Intl.DateTimeFormat('en', { year: 'numeric', month: 'short' }).format const value = new Date('2018-03-12') console.log(format(value)) // → "March 2018"
The Intl.DateTimeFormat
constructor accepts a variety of formatting options which are well-documented. We can simply pass these along in our date formats when we write our translation files.
An example in Hebrew
"published_on": "פורסם ב {{date, year:numeric;month:short}}", "published_on_date_only": "{{date, year:2-digit;month:long}}"
All we have to do now is take these format strings and convert them to objects that Intl.DateTimeFormat
understands. That’s exactly what our custom date interpolate formatDate
does (with a bit of help).
/src/services/i18n/util.js (excerpt)
function parseOptions(format) { const options = {} format.split(';').forEach((part) => { const [key, val] = part.split(':') options[key.trim()] = val.trim() }) return options } export function formatDate(value, format, locale) { try { return new Intl.DateTimeFormat(locale, parseOptions(format)).format(value) } catch (err) { console.error(err) } return value }
We use the utility function parseOptions(format)
to break up the format options along ;
. parseOptions
further breaks each individual segment up into its key
and value
, and uses those to build its options
object.
With that, formatDate
can do its Intl.DateTimeFormat
thing, gracefully handling any errors that could be caused by invalid user options.
Formatting Prices
Formatting prices is very similar to formatting dates. i18next doesn’t handle it out of the box, but we can use its interpolation.format
option to provide our own formatter.
import _ from 'lodash' import i18next from 'i18next' import { locales } from '../../config/i18n' import { formatDate, formatPrice } from './util' export const setUiLocale = (locale) => { /* ... */ return fetch(`/static/translations/${locale}.json`) .then(response => response.json()) .then(loadedResources => ( new Promise((resolve, reject) => { i18next.init({ lng: locale, debug: true, resources: { [locale]: loadedResources }, interpolation: { format(value, format, _locale) { if (value instanceof Date) { return formatDate(value, format, _locale) } if (_.startsWith(format, 'style:currency')) { return formatPrice(value, format, _locale) } return value }, }, }, // ...
Unlike Date
s, we can’t rely on the number
type to determine if our value is a price: a number
can represent many things. Instead, we look at the format string itself and see if it starts with 'style:currency'
. If it does, we decide that it’s a price. 'style:currency'
comes from the Intl
API again—this time from Intl.NumberFormat
.
const format = new Intl.NumberFormat('he', { style: 'currency', currency: 'ILS' }).format const value = 78.445 console.log(format(value)) // → "78.45 ₪"
Given the Intl.NumberFormat
constructor signature, we can use it to compose our formatPrice
.
/src/services/i18n/util.js (excerpt)
export function formatPrice(value, format, locale) { try { return new Intl.NumberFormat(locale, parseOptions(format)).format(value) } catch (err) { console.error(err) } return value }
We use parseOptions
to parse our formating options, passing them to Intl.NumberFormat
to get the localized price with currency. Not too shabby.
Note » You can delve into all the
Intl.NumberFormat
options on its MDN page.
Alright, let’s take a look at how all this comes together to help us i18n-ize and localize our app.
Translation Files
Our three locales can now have UI translation files that look a bit like this.
/static/translations/en.json (excerpt)
{ "translation": { "app_name": "Gawa", "slogan": "Refurbished Retro Products", /* ... */ "added_on": "Added in {{date, year:numeric;month:long}}", "product_price": "{{price, style:currency;currency:USD}}" } }
/static/translations/he.json (excerpt)
{ "translation": { "app_name": "גאווה", "slogan": "מוצרי רטרו משופצים", /* ... */ "added_on": "נוסף ב {{date, year:numeric;month:long}}", "product_price": "{{price, style:currency;currency:ILS}}" } }
/static/translations/ja.json (excerpt)
{ "translation": { "app_name": "ガワ", "slogan": "改装されたレトロ商品", /* ... */ "added_on": "{{date, year:numeric;month:long}}に追加されました", "product_price": "{{price, style:currency;currency:JPY}}" } }
That’s all good, but how do we keep the loaded translation file in i18next in sync with the locale in our URI?
You’ll remember that we determine our current locale based on the parameter in the first segment of our URIs. So /en/products
loads English products and /ja/products
shows Japanese products.
So we have to watch our URI updates and reinitialize i18next with the new translations when the locale in the URI changes. Let’s build a component that does that, and takes care of a few other related matters as well.
Find out how continuous localization saves your agile development to grow your business on a global scale.
Check out the guideThe Localizer Component
Here’s what a component concerned with in-browser l10n might look like.
/src/components/Localizer.vue
<template> <div v-if="uiTranslationsLoaded"> <slot /> </div> </template> <script> import Vue from 'vue' import _ from 'lodash' import { locales } from '../config/i18n' import { switchDocumentLocale } from '../services/i18n/util' import { setUiLocale, t, currentLocale } from '../services/i18n' export default { name: 'Localizer', data() { return { uiTranslationsLoaded: false, } }, methods: { set(locale) { this.uiTranslationsLoaded = false setUiLocale(locale) .then(() => { Vue.prototype.$t = t this.uiTranslationsLoaded = true const dir = _.find(locales, l => l.code === locale).dir switchDocumentLocale( locale, dir, { withRTL: [ '/static/styles/vendor/GhalamborM/bootstrap4-rtl.css', '/static/styles/rtl.css', ], }, ) }) .catch(err => console.error(err)) }, }, mounted() { this.set(this.$route.params.locale) }, watch: { '$route'(to) { if (to.params.locale !== currentLocale()) { this.set(to.params.locale) } }, }, } </script>
Let’s take a look at this component’s view template.
/src/components/Localizer.vue (excerpt)
<template> <div v-if="uiTranslationsLoaded"> <slot /> </div> </template>
Our Localizer
wraps other components that are rendered into its <slot>
. Before it does so, it checks to see if we’ve loaded the current UI translations and initialized i18next. It completely removes its root container <div>
while UI translations are loading, and adds it back in when translations have finished loading.
This ensures that Localizer
’s child components will re-render when we load a new locale. Otherwise, since our components’ templates will use the t
function and not data state to get their translations, they won’t see the locale change and won’t react and re-render.
Let’s look at our Localizer
’s logic.
/src/components/Localizer.vue (excerpt)
import Vue from 'vue' import _ from 'lodash' import { locales } from '../config/i18n' import { switchDocumentLocale } from '../services/i18n/util' import { setUiLocale, t, currentLocale } from '../services/i18n' /* ... */ export default { /* ... */ methods: { set(locale) { this.uiTranslationsLoaded = false setUiLocale(locale) .then(() => { Vue.prototype.$t = t this.uiTranslationsLoaded = true const dir = _.find(locales, l => l.code === locale).dir switchDocumentLocale( locale, dir, { withRTL: [ '/static/styles/vendor/GhalamborM/bootstrap4-rtl.css', '/static/styles/rtl.css', ], }, ) }) .catch(err => console.error(err)) }, }, // ...
Our component only has one method: set(locale)
. In the method, we switch our uiTranslationsLoaded
flag to false
, so that we remove our component’s root element while we load the current locale
’s translation file. Using our i18next wrapper, setUiLocale(locale)
, we load the translation file and initialize i18next.
We then
make our i18n library’s t
function available to all our components by adding it as a plugin to Vue.prototype
. Vue’s convention for plugins is to precede them with a $
, so we expose t
to our components as $t
.
And, of course, once i18next has re-initialized with the new translation file contents, we render out our Localizer
’s children to display their content in the new locale. We do this by toggling our uiTranslationsLoaded
flag back to true
.
Our current locale’s direction (right-to-left or left-to-right) is easily retrieved from the configured locales
array. We need that dir
ection to pass to switchDocumentLocale
. This utility function takes care of setting the lang
and dir
attributes on the <html>
document element. switchDocumentLocale
also injects stylesheets that are loaded if we pass it a right-to-left locale, and removed when the current locale’s direction is left-to-right.
Note » Check out
switchDocumentLocale
’s internals on the GitHub repo.
Now let’s see how our set
method works for us.
/src/components/Localizer.vue (excerpt)
mounted() { this.set(this.$route.params.locale) }, watch: { '$route'(to) { if (to.params.locale !== currentLocale()) { this.set(to.params.locale) } }, },
Once our Localizer
mounts, we call set
to make sure that we have an initial locale. We also watch
any changes in this.$route
, which is provided by vue-router. Whenever the locale parameter in the current URI is different than the previous one, we re-set
our current locale. This check is important because otherwise we’d unnecessarily slow down our app by re-rendering our Localizer
‘s children on every URI change, whether the change is within the same locale or not.
Now we can wrap our Localizer
around all our app’s components:
/src/App.vue (excerpt)
<template> <div id="app" class="app-wrapper"> <localizer> <navbar /> <main class="container" role="main"> <router-view/> </main> </localizer> </div> </template>
With our Localizer
in place we can be sure that i18next’s locale, our HTML document, and our i18n-ized components will always stay in sync with the locale in the current URI.
And that’s our UI i18n done!
Ok, let’s use this i18n setup to build an actual page we can show Kiki and Akira.
Building a Localized Vertical: The Product Index
First, we need some products. Let’s mock our back-end API with JSON files. They can look like this.
/static/api/en/products.json (excerpt)
[ { "id": 1, "title": "Sega Saturn", "description": "A vintage game collector's dream, the Sega Saturn, and…", "price": 78.99 "image_uri": "sega-saturn.png", "added_on": "2018-03-13", "featured": true }, { "id": 2, "title": "Nintendo Virtual Boy", "description": "It's rare to see a Virtual Boy in the wild these days…", "price": 139.99, "image_uri": "virtual-boy.jpg", "added_on": "2018-03-13", "featured": true }, /* ... */ ]
/static/api/he/products.json (excerpt)
[ { "id": 1, "title": "סגה סטורן", "description": "חלום אספן המשחק וינטאג, סגה סטורן, ואת הספרייה של אבני חן…", "price": 270.90, "image_uri": "sega-saturn.png", "added_on": "2018-03-13", "featured": true }, { "id": 2, "title": "נינטנדו וירטואלי ילד", "description": "זה נדיר לראות ילד וירטואלי בטבע בימים אלה. נינטנדו של…", "price": 480.10, "image_uri": "virtual-boy.jpg", "added_on": "2018-03-13", "featured": true }, /* ... */ ]
/static/api/ja/products.json (excerpt)
[ { "id": 1, "title": "セガサターン", "description": "ヴィンテージゲームコレクターの夢であるSega Saturnとそ…", "price": 8421.44, "image_uri": "sega-saturn.png", "added_on": "2018-03-13", "featured": true }, { "id": 2, "title": "任天堂バーチャルボーイ", "description": "最近、野生のVirtual Boyを見るのはまれです。 任天堂の不運…", "price": 14924.89, "image_uri": "virtual-boy.jpg", "added_on": "2018-03-13", "featured": true }, /* ... */ ]
This JSON would be similar to the response from a real API request, e.g. GET /api/ja/products.json
Let’s pull this into a ProductIndex
component so that we can render these products out to the screen.
/src/components/ProductIndex.vue
<template> <page :header-border="false"> <template slot="header"> <h1>{{$t('our_products')}}</h1> </template> <p class="lead">{{$t('product_index_lead')}}</p> <div class="card-columns mb-4"> <product-card v-for="product in products" :key="product.id" :product="product" /> </div> </page> </template> <script> import Page from './Page' import ProductCard from './ProductCard' export default { data() { return { products: [], } }, mounted() { fetch(`/static/api/${this.$route.params.locale}/products.json`) .then(response => response.json()) .then((products) => { this.products = products }) }, components: { Page, ProductCard, }, } </script>
Page
is just a wrapper component that gives us a nice page header and a decorative footer. You’ll notice that we’re using our plugin $t
function to output a localized header and lead copy.
Note » You can check out
Page
’s code on the GitHub repo.
When our component mounts to the DOM we pull our products’ JSON and feed it to our view. Using the handy dandy this.$route
object to get the locale in the current URI, we use this locale to fetch
the appropriately localized products file.
Note »
fetch
has excellent, if incomplete, modern browser support. If you need to support IE 11 for example, you may want to polyfill fetch, or use a library like axios for your HTTP requests.
When our component’s products
array is populated we iterate over it via v-for
and output each product in a ProductCard
. Let’s see what that component looks like.
Note » If you’re coding along, don’t forget to swap in the
ProductIndex
component above for the mock component we had in our/src/router/index.js
route configuration.
/src/components/ProductCard.vue
<template> <div class="card"> <img class="card-img-top" :alt="product.title" :src="`/static/product-images/${product.image_uri}`" /> <div class="card-body"> <h5 class="card-title">{{product.title}}</h5> <p class="card-text small text-muted"> {{$t('added_on', { date: new Date(product.added_on) })}} </p> <p class="card-text"> {{product.description}} </p> <p class="card-text lead"> {{$t('product_price', { price: product.price })}} </p> <localized-link class="btn btn-primary" :to="`products/${product.id}`" > {{$t('view_product_details')}} </localized-link> </div> </div> </template> <script> import LocalizedLink from './LocalizedLink' export default { props: ['product'], components: { LocalizedLink, }, } </script>
ProductCard
accepts a product
object prop, and uses it to display the product’s image, title, added-on date, description and price. The added-on date is formatted using our now date-ready $t
function. And the price, assumed to be converted to the correct locale currency on the “server”, is similarly formatted using $t
.
Here’s what the translations used by $t
in our component above look like in English:
/static/translations/en.json (excerpt)
"view_product_details": "View product details", "added_on": "Added in {{date, year:numeric;month:long}}", "product_price": "{{price, style:currency;currency:USD}}"
With that, we get a render that looks like this.
Ok, we’re close to showing something to our clients.
Let’s just tackle one last thing. You may have noticed that the View product details button above is actually a <localized-link>
. This is a convenience component that prefixes our link href
s with the current locale. So, if we write <localized-link to="foo">
, and our current locale is Hebrew, underneath the hood we’d get an <a href="/he/foo">
.
A quick peek at the code that makes this happen:
/src/components/LocalizedLink.vue
<template> <router-link :tag="tag" :to="uri()" :active-class="activeClass" > <slot /> </router-link> </template> <script> export default { props: [ 'to', 'tag', 'activeClass', ], methods: { uri() { const locale = this.$route.params.locale if (this.to === '/') return `/${locale}` // we strip leading and trailing slashes and prefix // the current locale return `/${locale}/${this.to.replace(/^\/|\/$/g, '')}` }, }, } </script>
We’re wrapping a <router-link>
here. You may remember that this component is provided to all our components when we use the view-router plugin. <router-link>
allows us to display the components we defined in our configured routes. We expose its tag
and active-class
properties in our own LocalizedLink
component to facilitate inner HTML and styling flexiblity.
Note » The vue-router documenation covers all of
<router-link>
‘s props.
The real bit of magic happens when we pass down the to
prop. Notice that we dynamically generate the URI we pass to <router-link>
using a uri
method. uri
prefixes the URI we’ve been given with the current locale, which it gets from the configured router parameter.
We can now just import LocalizedLink
in to our components and write <localized-link to="products/1">View product details</localized-link>
—without worrying about fiddling with the locale URI prefix in every link.
That pretty well covers our product index.
Note » I won’t bore you by drilling through the show product details feature, since it sheds no new light on i18n and l10 in Vue. You can peruse all of the app’s code on GitHub.
We email Kiki and Akira Fudo and send them a link to our demo, which now works like so.
Note » You can see this app running live on Heroku.
The Fudos are thrilled! They tell us they love the work and want us to keep working on the project. When we ask them for their ongoing budget, they tell us that they will send us a refurbished Sega Dreamcast if we finish the website for them. Startups, eh? ¯\_(ツ)_/¯
In Closing
Writing code to localize your app is one task, but working with translations is a completely different story. Many translations for multiple languages may quickly overwhelm you which will lead to the user’s confusion. Fortunately, Phrase can make your life as a developer easier! Feel free to learn more about Phrase, referring to the Getting Started guide.
When i18n-izing and localizing our Vue.js apps, we can use the framework’s elegant component architecture to keep our i18n logic as readable and reusable as possible. We can also leverage Vue’s first-party vue-router for locale determination. And when it comes to general i18n for the UI, a wrapper around i18next can be just what the doctor ordered.
I hope this Vue.js tutorial on i18n has gotten you started with i18n-izing and localizing your Vue.js apps. Til next time, sayonara and shalom!
Recommend
About Joyk
Aggregate valuable and interesting links.
Joyk means Joy of geeK