4

Vue.js Tutorial on Building an SPA with I18n Support

 2 years ago
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

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 and package.json.

Alright, let’s get started.

I18n Manager for Global Success
I18n Manager for Global Success

Learn how to find the best i18n manager and follow our best practices for making your business a global success.

Check out the guide

Scaffolding

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 before products. If products came first, it would match both products and products/: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.

Navbar | Phrase

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.

Look closer, Watson Meme | Phrase

🔎 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.

Language switcher | Phrase

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.

Alice in Wonderland Meme | Phrase

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 expose t 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 Dates, 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?

Willy Wonka Meme | Phrase

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.

Your Guide to Continuous Localization
Your Guide to Continuous Localization

Find out how continuous localization saves your agile development to grow your business on a global scale.

Check out the guide

The 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 direction 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!

Yattaaaa! Meme | Phrase

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.

Product card render | Phrase

Ok, we’re close to showing something to our clients.

I'm so excited Meme | Phrase

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 hrefs 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.

Completed demo app | Phrase

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!


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK