Nuxt.js initialization

* This article is a translation of the Japanese article written on February 25, 2022.

Introduction

Hello! This is @tokuda109 from the Merpay Frontend Team.

In this article, which is part three of our series of articles on the efforts of the Merpay Frontend Team to improve performance, I cover the basics of initializing Nuxt.js applications on browsers.

Summary of previous article

In the previous article, I went over how JavaScript is processed on browsers.
Microtasks refer to callback functions implemented on a promise handler, such as "then."

During runtime, the JavaScript engine prioritizes the execution of microtasks in the microtask queue, until it becomes empty within a single cycle.
The "Run Microtasks" process block in Figure 1 indicates that promise processing is performed and other processing is blocked.

Activity at the bottleneck
Figure 1: Activity at the bottleneck

In order to improve this task, we need to improve both the "Evaluate Script" and "Run Microtasks" processes.

Assumptions

In order to lock down my investigation/measurement environment, I conducted my investigation based in the following environment.

  • Node.js: 16.13.1
  • Nuxt.js: 2.15.8
  • TypeScript: 4.5.5
  • Google Chrome: Latest version

I’ve also released the source code used during my investigation on GitHub.

It’s a very simple Nuxt.js demo application that just displays some text ("Hello world!").
The source code contains the .nuxt-build directory and the .nuxt-dev directory.

The .nuxt-build directory contains files generated by executing nuxt build, while the .nuxt-dev directory contains files generated by executing nuxt dev.

During my investigation, I used the files in the .nuxt-build directory to perform measurements, and the files in the .nuxt-dev directory for explanatory purposes in this article.

(These directories have been created for explanatory purposes. Note that files would actually be built in the .nuxt directory.)

Performance measurement

Starting the local server and measuring performance

First, download a local copy of the repository and install dependency packages.

$ git pull git@github.com:tokuda109/nuxt-client-investigation.git
$ cd nuxt-client-investigation
$ npm ci

Once installation is complete, start the development server.

$ npm run build
$ npm start

Accessing http://localhost:3000 from a browser shows a screen with only the text "Hello world!" displayed.
The HTML returned looks like this:

<!doctype html>
<html>
  <head>
    <link rel="preload" href="/_nuxt/8055793.js" as="script">
    <link rel="preload" href="/_nuxt/2ab1c10.js" as="script">
    <link rel="preload" href="/_nuxt/60982e3.js" as="script">
    <link rel="preload" href="/_nuxt/4fd6200.js" as="script">
  </head>
  <body>
    <div data-server-rendered="true" id="__nuxt">
      <div id="__layout">
        <p>Hello world!</p>
      </div>
    </div>
    <script>
      window.__NUXT__=(function(a,b){
        return {
          layout: "default",
          config: {}
        }
      }(null,"u002F"));
    </script>
    <script src="/_nuxt/8055793.js" defer></script>
    <script src="/_nuxt/2ab1c10.js" defer></script>
    <script src="/_nuxt/60982e3.js" defer></script>
    <script src="/_nuxt/4fd6200.js" defer></script>
  </body>
</html>

Start Chrome DevTools and switch to the "Performance" tab.
Measuring our demo application provides the following results (Figure 2).

Browser with Chrome DevTools started
Figure 2: Browser with Chrome DevTools started

This shows some information similar to that shown in Figure 1. There’s also a long blue horizontal block above that. This shows where processes are blocking other processes.
Any process blocked for more than 50 milliseconds is called a "long task." The blue hatched areas indicate a time exceeding 50 milliseconds, and suggest that performance indicators have dropped.

We’re getting some long tasks, even though our application is very simple and displays only a single line of text. This shows us that the user experience is suffering.

Build contents

Before investigating how the application is processed, I first investigated what kinds of files are being generated when building.

The following files are generated when npm run build is executed. They are then evaluated and executed when the screen is displayed in the browser. These files are located in .nuxt-build/dist/client in the source code.

File name File size Content (chunk name)
8055793.js 2.29 KB This contains the webpack implementation, along with the module download mechanism.
A dictionary for built files is defined separately for each page. The file names for pages required for routing when navigating to another page are resolved and downloaded, allowing for navigation to be performed.
2ab1c10.js 169 KB This contains the implementation for vue, vue-router, vue-meta, etc. This mainly contains dependency packages.
60982e3.js 50.9 KB This contains the main functionality of the Nuxt.js application.
client.js and middleware.js from the .nuxt-dev directory are also contained here. The content will change if nuxt.config.ts is changed or new middleware is added/implemented.
4fd6200.js 282 B This contains build results for src/pages/index.vue.

Anything other than modules for the accessed page are not downloaded, so there is only a single page for the demo application. Only 4fd6200.js is defined in 8055793.js. We can therefore understand the strategic modularization of the Nuxt.js application.

Hydration

When the demo application is accessed from within a browser, the server side of the demo application receives the request, and returns the HTML as its response.

The returned HTML is assembled by the server side. It is not assembled and rendered by Vue.js on the browser.

The HTML received as a response by the browser must be compared with the DOM structure (virtual DOM) retained internally by Vue.js.

This process is called hydration. By comparing the actual DOM and virtual DOM data and ensuring they match, we can process user operations and provide appropriate responses.

Demo application initialization

Let’s take a look at how the demo application is processed on the browser.

As mentioned earlier, the HTML returned by the server side is simply a static
document. In order to perform hydration and provide interactivity, we need to initialize the Nuxt.js application.

client.js

client.js initializes the Nuxt.js application on the browser.
This file is located in .nuxt-dev/client.js, and is contained within 60982e3.js when built.
I’ll use .nuxt-dev/client.js here, as it’s easier to explain while looking at the code.
The following code represents only what’s needed to explain .nuxt-dev/client.js.
The template file prior to building can be seen on this GitHub. The template format is such that only the required implementation is included during building, based on the settings in nuxt.config.ts.

import Vue from 'vue'
import middleware from './middleware.js'
import { applyAsyncData, ... } from './utils.js'
import { createApp } from './index.js'

const NUXT = window.__NUXT__ || {}

createApp(null, NUXT.config).then(mountApp)

async function mountApp (__app) {
}

.nuxt-dev/client.js imports the implementation required for initialization, and receives as settings the window.__NUXT__ object output from the server side to the HTML.
These settings are passed as parameters to createApp for execution. As suggested by the function name, this is used to create and initialize the application.
Once that’s complete, mountApp is executed as a handler, and the application that is created is mounted to DOM (again, as suggested by the function name).
I’ve shown these processes in Chrome DevTools in Figure 3 below. In order to make it easier to follow these processes, I executed npm run dev and measured what started.

Detailed initialization process for the Nuxt.js application
Figure 3: Detailed initialization process for the Nuxt.js application

(1) represents a series of tasks run from the period of time lasting from when .nuxt-dev/client.js is executed, until execution is complete.
(2) is when core-js, vue.runtime.esm.js, ./.nuxt-dev/middleware.js, ./.nuxt/utils.js, and ./.nuxt/index.js are imported.
The files imported in (2) are described below.

  • Import core-js
    • Required polyfill implementation (such as core-js/modules/es6.array.from.js and core-js/modules/es6.array.iterator.js) is imported.
  • Import Vue.js
    • vue/dist/vue.runtime.esm.js is imported.
  • Import files required for the Nuxt.js application
    • .nuxt/middleware.js is imported. This is skipped if middleware is not being used.
    • .nuxt/App.js is imported. The id attribute of the returned HTML is the component that will be mounted to the div element of #__nuxt.
    • .nuxt/utils.js is imported.
    • .nuxt/index.js is imported.
    • vue-meta/dist/vue-meta.esm.browser.js, .nuxt/router.js, etc. are imported.
    • If using store functionality, .nuxt/store.js is imported.
    • Plugins in the src/plugins directory are imported.

core-js and vue/dist/vue.runtime.esm.js form the core of the application and there’s essentially no room for improvement there. However, if the bottleneck is caused by .nuxt-dev/middleware.js or a plugin, this would represent a problem on the configuring side and could be improved.

There have been cases on the Merpay frontend where multiple libraries are imported, and some libraries require around 10 milliseconds just for this. I definitely recommend checking for any libraries with a high initialization cost.

createApp & mountApp

Let’s take a look at (3) in Figure 3.
createApp and mountApp are partially executed from (3) until (4) in Figure 3.
I’ve prepared some simplified code to explain what exactly createApp is doing. The original code can be found here.

async function createApp(ssrContext, config = {}) {
  const router = await createRouter(ssrContext, config)

  const app = {};

  return {
    app,
    router
  }
}

What’s happening here is simple. It creates an app object, and then creates and returns a router object. That’s it. If store functionality is being used, a store object is also created, and returned at the same time.
I’ll skip listing the relevant code here, but createApp also performs routing prior to returning the return value and associates a URL with the page to display, and then the module for the associated page is loaded.
Once the routing process is complete, createApp returns app and router objects, and then the mountApp process is started.
Simplified code for mountApp is shown below. The original code can be found here.

async function mountApp (__app) {
  app = __app.app
  router = __app.router

  const _app = new Vue(app)

  const mount = () => {
    _app.$mount('#__nuxt')
  }

  // Note: The top page is rendered by the server,
  //      so the root path returned from the server side and
  //      the root path evaluated on the browser will be the same.
  //      The following condition will therefore be TRUE, and mount will be called.

  if (
    NUXT.serverRendered &&
    isSamePath(NUXT.routePath, _app.context.route.path)
  ) {
    return mount()
  }
}

mountApp receives the app and router objects created by createApp. The app object received here is used to create an instance of Vue.js.
Then, the id attribute mounts this to the __nuxt element, and Vue.js is enabled.
(3) in Figure 3 represents up to the point where the Vue.js instance is mounted.

$mount(‘#__nuxt’)

Finally, let’s take a look at (4) in Figure 3.
This is the process started when mount() is executed from mountApp, and _app.$mount is called.
If the application is mounted for an element provided by the data-server-rendered attribute, the application will be mounted in hydration mode. This is not an operation on the actual DOM, but is calculated only to build a partial DOM structure for Vue.js. Hydration will take an amount of processing time proportionate to the overall number of elements, as it runs through and evaluates all elements.
Our demo application doesn’t have many elements, so the processing time isn’t extensive. In a standard application, this processing time would represent a cost significant enough that it cannot be ignored.

This process is executed as a microtask, so other processes will be blocked until the microtask queue becomes empty (i.e. when the hydration process is completed for all elements).
The processing time for hydration will increase linearly in proportion to the absolute number of DOMs, so something needs to be done.
There is a library called vue-lazy-hydration that can delay hydration, and I was able to use this to improve the blocking time score.
However, this library is no longer being maintained, so I use it only to verify performance improvements.

In conclusion

In this article, I discussed an issue that can cause a performance bottleneck in any Nuxt.js application.
This ended up being a pretty long article. Thanks for staying with me to the end. Spotted any mistakes or other issues with this article? Please feel free to DM me @tokuda109!

  • X
  • Facebook
  • linkedin
  • このエントリーをはてなブックマークに追加