Vue shared-components

Across NuxtJS applications

Gionatan Lombardi
TUI MM Engineering Center

--

Foto by Engin Akyurt da Pexels

As a company, we sell experiences on many different sales channels, gotui.com, musement.com, travel agencies platforms, onsite tour operator web-applications, etc. All of these platforms consume REST APIs and share common business logic: product pages, product listings, search pages, checkout, orders listings, login, account, etc.

Rewriting and maintaining this logic for every project requires a lot of effort so we faced a big challenge: how do we develop blocks that are easy to plug-in but flexible enough to satisfy the different business requirements of our platforms? And how do we sync all these blocks?

The story

It was an exciting road to reach our current architecture.

It started after delivering our second web-application: we noticed a lot of copy-paste work and difficulties syncing new features and bug fixes on both applications at the same time.

In front of the coffee machine, we started our first meetings and the paper board became the place where we shared ideas and concepts.

During Tech-Fridays, we developed the first PoCs, and finally, step by step, the architecture came out naturally and, more important, every member of the team contributed to it.

We ended up with a NuxtJS application that consumes Vue components imported as npm packages. Like LEGO® Technic™ kits, given a set of customizable blocks (shared-components), we can build many different vehicles (our platforms).

Building with Rollup

Having customizable components means that not all features and blocks are always used, so we need to be sure that only the necessary parts are imported. To do that we rely on the tree-shaking feature that removes unused code from the final bundled files.

Since NuxtJS uses Webpack as the final bundling tool, the only way to allow Webpack to efficiently perform the tree-shaking is to provide ESM modules. Given that, we choose to build our shared-components with Rollup: Webpack will provide that type of output only with Version 5. Moreover, it’s very easy to understand what Rollup is doing under the hood and thanks to that writing custom plugins was really straightforward.

Another benefit of Rollup t is that in our context the bundled files are 30–40% smaller compared to the Webpack ones.

Communication via store

Our shared-components expose different blocks so that the consuming application (we call it ‘orchestrator’) can choose to use only the ones required.

Imagine a search-component: it exposes a listing, a filters block, and a search bar; in our orchestrator, we can decide to use only the listing and the input:

import { SearchListing, SearchBar } from ‘@musement/search-component’;

But if they’re different components how do they sync the data? The search-component uses a vuex store module that is registered inside the orchestrator thanks to the registerModule method.

orchestratorStore.registerModule(‘searchStoreNamespace’, searchComponentStoreModule, { preserveState: false });

We set the preserveState to false because the search-component has its own store default state and the consumer knows nothing about it.

After the registration the SearchBar dispatches an action that fetches the data and stores the response:

<template>
<form v-on:submit.prevent="onSubmit">
<input v-model="text" placeholder="Search for activities…" />
<button type="submit">Search</button>
</form>
</template>
...export default Vue.extend({
name: 'SearchBar',
data() {
return {
text: ''
}
},
methods: {
onSubmit() {
this.$store.dispatch(
'searchStoreNamespace/fetchActivities',
{ this.text }
);
},
},
})

After the API response the SearchListing is able to show the fetched activities:

<template>
<ul>
<li v-for="activity in activities" :key="activity.uuid">
{activity.name}
</li>
</ul>
</template>
...

export default Vue.extend({
name: 'SearchListing',
computed: {
...mapGetters('searchStoreNamespace', [ 'activities' ]),
},
})

Setup

Since our shared-components can be composed by different blocks that communicate via a vuex-store module, passing Vue props directly from the orchestrator to the components would lead us to a lot of code duplication. For that reason, we decided to expose a setup method where we set customizable data directly inside the store, avoiding all the parent-child tree traversing.

import { setup } from '@musement/search-component';

setup(consumerStoreInstance, {
apiBaseURL: 'https://api.musement.com',
language: 'en-GB',
currency: 'GBP',
...
})

We need the consumerStoreInstance because the store module registration is performed inside the shared-component itself so that the consumer doesn’t need to be aware of that.

Server-Side Rendering with NuxtJs

Due to SEO requirements, most of our applications use Server-Side Rendering provided by NuxtJS.

In those cases, we need to display crawlers relevant data after an API call so we expose an initialDispatch function that is called inside the NuxtJS fetch method.

After that, we need to ensure that the vuex-store gets hydrated browser side with the same new data. We do that with a hydrate method that registers the store module on the client too.

import { initialDispatch, hydrate } from '@musement/activity-component';export default {
name: 'ActivityPage',
async fetch({ store }) {
const activityUuid = store.state.activityPage.uuid;
initialDispatch(store, activityUuid);
},
beforeCreate() {
if (process.client) {
hydrate(this.$store);
}
}
}

Event-bus

As we said before our shared-components are black-boxes, but imagine we have our imported SearchListing, how do we inform the orchestrator that client-side navigation needs to be performed?

The way we communicate from the shared-components to the consumers is via an event-bus with a documented API. We create and expose it inside the shared-component:

import Vue from 'vue';

const eventBus = new Vue();

export default {
$emit(eventName, eventPayload) {
eventBus.$emit(eventName, eventPayload);
},
$on(eventName, eventHandler) {
eventBus.$on(eventName, eventHandler);
},
$off(eventName, eventHandler) {
eventBus.$off(eventName, eventHandler);
},
};

Inside the listing, we $emit a Vue event.

<template>
<ul>
<li v-for="activity in activities" :key="activity.uuid">
<h3>
<a
:href="activity.url"
@click.prevent="onActivityClick(activity.uuid)">
{activity.name}
</a>
</h3>
</li>
</ul>
</template>

...

import EventBusSearch from './eventBus';
export default Vue.extend({
name: 'SearchListing',
computed: {
...mapGetters('searchStoreNamespace', [ 'activities' ]),
},
methods: {
onActivityClick(uuid: string) {
EventBusSearch.$emit('onNavigation', { uuid });
}
},
})

And finally, we listen to that event inside the orchestrator and remove it when the page will be destroyed.

import { SearchListing, EventBusSearch } from '@musement/search-component';export default {
name: 'SearchPage',
...
beforeMount() {
EventBusSearch.$on('onNavigation', this.onNavigation);
},
beforeDestroy() {
EventBusSearch.$off('onNavigation', this.onNavigation);
},
methods: {
onNavigation({ uuid }) {
this.$router.push({ name: 'activity', params: { uuid } });
}
}
}

CSS theming

As blocks plugged inside different applications, our shared-components need to adapt to the overall look-and-feel of the context, especially to the colours palette.

To achieve that, we accept a theme property inside the config argument of the setup function:

import { setup } from '@musement/search-component';

setup(consumerStoreInstance, {
theme: {
name: 'musement',
vars: {
'--fontPrimary': 'Gill Sans, sans-serif;',
},
},
...
})

With the name property, the consumer can use one of the premade themes, and with the vars prop, it can override specific CSS variables; both are optional.

Then, inside the shared-component, we set the theme in the store via a mutation, with a default fallback (in this case the theme ‘musement’):

const themes = {
musement: {
'--primaryColor': 'red',
'--fontPrimary': 'Arial, sans-serif',
},
blueTheme: {
'--primaryColor': 'blue',
'--fontPrimary': 'Tahoma, sans-serif',
}
}

export default {
[SET_THEME](state, theme) {
state.theme = {
...themes[theme.name || 'musement'],
...(theme.vars || {}),
}
},
}

Finally, we apply the CSS variables directly to the root element of the shared-component:

<template>
<div class="activityComponent" :style="theme">
<h1>{ activity.title }</h1>
...
</div>
</template>

...

export default Vue.extend({
name: 'activityComponent',
computed: {
...mapGetters('searchStoreNamespace', [ 'theme', 'activity' ]),
},
})
...<style lang="scss">.activityComponent {
font-family: var(--fontPrimary);
h1 {
color: var(--primaryColor);
}
}
</style>

Conclusion

As you’ve probably noticed here we’ve only scraped the surface of the possibilities and we know, as a frontend team, that we’re only at the beginning of a long and thrilling journey; we are still faced with many challenges, like:

  • Efficiently manage shared-components inside shared-components
  • Dynamic import based on feature flags
  • Properly take in sync dependencies across the different platforms
  • Develop a light CSS library to reduce styles duplication and obtain layout consistency between our applications

But these challenges are there to let us learn day after day better ways to provide valuable applications for our customers and, hopefully, contribute to the developer’s community with shareable solutions.

See you soon with the next chapter of this story, stay tuned!

--

--