How to optimize Vue.js projects

Single-page applications (SPAs) can provide rich, interactive user experiences when processing real-time, asynchronous data. But they can also be heavy, bloated, and perform poorly. In this article, we’ll cover some front-end optimization tips to keep our Vue applications relatively lean and only provide necessary JS when needed.

Note: This assumes you have some familiarity with Vue and the Composition API, but no matter which framework you choose, you hope to gain something.

The author of this article is a front-end development engineer whose responsibility is to build Windscope applications. The following introduces a series of optimizations based on this program.

Select a frame

The JS framework we chose was Vue, partly because it’s the framework I’m most familiar with. Previously, Vue had a smaller overall package size compared to React. However, since the recent React updates, the balance seems to have shifted towards React. This doesn’t matter since in this article we will look at how to import only what we need. Both frameworks have excellent documentation and a large developer ecosystem, which is another consideration. Svelte is another possible option, but it requires a steeper learning curve due to unfamiliarity, and since it’s newer, its ecosystem is less developed.

Vue Composition API

Vue 3 introduces the Composition API, a new API for writing components as a replacement for the Options API. By specifically using the Composition API, we can import only the Vue functions we need, rather than the entire package. It also enables us to write more reusable code using composed functions. Code written using the Composition API is better suited to minification, and the overall application is more susceptible to tree-shaking.

Note: If you are using an older version of Vue, you can still use the Composition API: it has been patched to Vue 2.7, and there is an official plugin for older versions.

Import dependencies

A key goal is to reduce the size of the initial JS bundle downloaded via the client. Windscope makes extensive use of D3 for data visualization, which is a huge library with a wide scope. However, Windscope only needs to use part of D3.

One of the easiest ways to keep our dependencies as small as possible is to only import the modules we need.

Let’s take a look at D3’s selectAll function. We can not use the default import, but only import the functions we need from the d3-selection module:

// Previous:
import * as d3 from 'd3'

//Instead:
import { selectAll } from 'd3-selection'

Code splitting

There are some packages used in many places throughout Windscope, such as the AWS Amplify authentication library, specifically the Auth method. This is a big dependency and contributes significantly to the size of our JS bundle. Rather than statically importing modules at the top of the file, dynamic imports allow us to import modules exactly where they are needed in our code.

Compared to importing like this:

import { Auth } from '@aws-amplify/auth'

const user = Auth.currentAuthenticatedUser()

We can import the module wherever we want to use it:

import('@aws-amplify/auth').then(({ Auth }) => {
    const user = Auth.currentAuthenticatedUser()
})

This means that the module will be split into a separate JS package (or “chunk”), which will only be downloaded by the browser when the module is used.

In addition, the browser can cache these dependencies, and these modules will remain unchanged compared to other parts of the application code.

Lazy loading

Our application uses Vue Router as navigation route. Similar to dynamic imports, we can lazy load our route components so that they are only imported (along with their associated dependencies) when the user navigates to the route.

index/router.js file:

// Previously:
import Home from "../routes/Home.vue";
import About = "../routes/About.vue";

// Lazyload the route components instead:
const Home = () => import("../routes/Home.vue");
const About = () => import("../routes/About.vue");

const routes = [
  {
    name: "home",
    path: "/",
    component: Home,
  },
  {
    name: "about",
    path: "/about",
    component: About,
  },
];

When the user clicks the About link and navigates to the route, the code corresponding to the About route will be loaded.

Asynchronous component

In addition to lazily loading each route, we can also use Vue’s defineAsyncComponent method to lazily load a single component.

const KPIComponent = defineAsyncComponent(() => import('../components/KPI.vue'))

This means that the code for the KPI component is imported asynchronously, as we saw in the routing example. We can also provide a displayed component while the component is loading or in an error state (this is useful when loading particularly large files).

const KPIComponent = defineAsyncComponent({
  loader: () => import('../components/KPI.vue'),
  loadingComponent: Loader,
  errorComponent: Error,
  delay: 200,
  timeout: 5000,
});

Split API requests

Our application is mainly focused on data visualization and relies heavily on getting large amounts of data from the server. Some of these requests can be quite slow because the server has to do some calculations on the data. In the initial prototype, we made one request to the REST API for each route. Unfortunately, we found that this resulted in users having to wait for a long time.

We decided to split the API into several endpoints to make requests for each component. While this may increase overall response time, it means the application should be available faster because users will see part of the page rendered while they are still waiting for other parts. Additionally, any errors that may occur will be localized, while the rest of the page will still work.

Conditionally loading components

Now we can combine this with async components and only load a component when we receive a successful response from the server. In the following example we fetch the data and then import the component when the fetch function returns successfully:

<template>
  <div>
    <component :is="KPIComponent" :data="data"></component>
  </div>
</template>

<script>
import {
  defineComponent,
  ref,
  defineAsyncComponent,
} from "vue";
import Loader from "./Loader";
import Error from "./Error";

export default defineComponent({
    components: { Loader, Error },

    setup() {
        const data = ref(null);

        const loadComponent = () => {
          return fetch('<https://api.npoint.io/ec46e59905dc0011b7f4>')
            .then((response) => response.json())
            .then((response) => (data.value = response))
            .then(() => import("../components/KPI.vue") // Import the component
            .catch((e) => console.error(e));
        };

        const KPIComponent = defineAsyncComponent({
          loader: loadComponent,
          loadingComponent: Loader,
          errorComponent: Error,
          delay: 200,
          timeout: 5000,
        });

        return { data, KPIComponent };
    }
}

This pattern can be extended to anywhere in the application, with components being rendered following user interaction. For example, when the user clicks the Map tag, the map component and related dependencies are loaded.

CSS

In addition to dynamically importing JS modules, importing dependencies in the component’s