A Rubyist's Guide to Vite.js

Máximo Mussini
August 19, 2021 · 10 min read

One of the reasons Ruby is so enjoyable is that we can fire up a console and quickly try things out and iterate on ideas—the development experience is interactive.

Vite.js is a build tool that can provide a similar feedback loop when doing web development, but this time the changes we make in our app are reflected instantly in our browser.

An instant connection with our creations is incredibly valuable—whether we are using one of the latest frontend frameworks, or just standard JS and CSS :smiley:

If you would like to play first and read later, jump to Getting Started.

Why is Vite.js so fast? ⚡️

During development, instead of bundling the entire application it leverages native ESM imports, which are supported by all modern browsers.

Browsers will request files imported in ESM scripts as needed, which can then be transformed and served on demand by the Vite.js web server. Any code that is not imported in the current page will not be processed nor served.

Because it doesn’t need to bundle everything, the server starts really fast even on large applications, and changes are reflected instantly thanks to hot module replacement which will push updates to the browser using websockets.

As an optimization, Vite.js will pre-bundle large dependencies to reduce the amount of requests that the browser must make to the web server.

What about production?

To ensure optimal loading performance in production, Vite.js provides a build command to bundle our code, which provides many performance optimizations out of the box, with features such as asset-fingerprinting and tree-shaking.

As a result, Vite.js provides a great balance. Smooth and enjoyable experience during development. Best performance practices in production.

Covering the basics 📖

Vite.js enhances ESM imports to support various features that are typically seen in bundler-based setups, such as importing images or JSON files, or injecting styles.

import logoUrl from './logo.svg' // a URL to reference the icon

import packageInfo from './package.json' // a parsed JSON object

import { debounce } from 'lodash-es' // npm dependency

Configuration and Plugins 🛠

Configuration in Vite.js revolves around a vite.config.ts file, usually placed at the root of the project, with autocompletion support thanks to TypeScript.

Vite.js is designed for extensibility, and compatible with many rollup.js plugins. Because of the conventions in place, plugins are easy to install and easy to use.

The Plugin API is well documented, and enables the creation of new plugins.

import { defineConfig } from 'vite'
import EnvironmentPlugin from 'vite-plugin-environment'

export default defineConfig({
  plugins: [
    EnvironmentPlugin(['API_KEY', 'RACK_ENV']),
  ],
})

vite.config.ts

Styles 🎨

Importing CSS files will inject their content via a <style> tag with HMR support. Changes are reflected instantly in the browser without a full page reload, making it more enjoyable to iterate on a design.

import './styles.scss' // inject styles in the page
import classes from './theme.module.css' // CSS Modules

PostCSS is supported out of the box. If we prefer a CSS preprocessor like Sass all we need to do is add the relevant package and use the appropriate file extensions.

npm install -D sass

Now that we have a sense of the role of imports and plugins in Vite.js, let’s take a look and see how we can leverage all this goodness in a Ruby web app.

By using Vite.js we can leverage tools in the Node.js ecosystem—such as Sass, PostCSS and Autoprefixer—without the need for Ruby integrations.

Using it in Ruby

Vite Ruby is a library that provides Vite.js integration for Ruby applications, with first-party support for frameworks such as Rails and Jekyll.

It uses conventions similar to those in webpacker to infer the entrypoints of our application, and provides tag helpers to render script and style tags that reference these entrypoints which will be processed by Vite.js.

Vite Ruby

The following sections use Rails, but aside from different tag helper names, the concepts are the same for other frameworks such as Hanami and Padrino.

See the installation guide for more information.

Getting started

To get started let’s add Vite Ruby to our Gemfile and run bundle install.

gem 'vite_rails'

Once the gem is installed we can run bundle exec vite install to get a basic setup, including configuration files and installing npm packages.

The vite-plugin-ruby package will take care of detecting entrypoints in our app, and configure Vite.js based on the Ruby app structure.

import { defineConfig } from 'vite'
import RubyPlugin from 'vite-plugin-ruby'

export default defineConfig({
  plugins: [
    RubyPlugin(),
  ],
})

vite.config.ts

The install command will also add the following tag helpers to the app layout:

<html>
  <head>
    ...
    <%= vite_client_tag %>
    <%= vite_javascript_tag 'application' %>
  </head>

app/views/layouts/application.html.erb

vite_client_tag renders a script referencing the Vite.js client, which connects over websockets to the Vite.js development server to receive updates as we make changes to the imported files.

vite_javascript_tag renders a script referencing a file in the entrypoints directory, which is served by Vite.js during development. The sample file:

console.log('Vite ⚡️ Rails')

app/frontend/entrypoints/application.js

Playing with Vite.js

Now we can restart our Ruby web server to load Vite Ruby, start the Vite.js development server by running bin/vite dev, and visit any page.

If we inspect the browser console, we should see the following output:

[vite] connecting...
Vite ⚡️ Rails
[vite] connected.

Let’s experiment with styles by creating a new stylesheet:

html, body {
  background-color: #CC0000;
}

app/frontend/styles/background.css

We can import it directly from our JS entrypoint to see a colored background:

import '~/styles/background.css'

console.log('Vite ⚡️ Rails')

app/frontend/entrypoints/application.js

Now, let’s see how Vite.js performs updates by modifiying our styles and saving:

html, body {
  background-color: #FFE02E;
}

Damn, that’s fast! ⚡️

If we check the console logs, we can see more output from the Vite.js client:

[vite] hot updated: /styles/background.css

The HMR mechanism is at the heart of the development experience in Vite.js ❤️

An example with Stimulus

To register all of our Stimulus controllers, we can leverage glob imports:

const controllers = import.meta.globEager('./**/*_controller.js')

Vite.js will replace globEager with an object, where keys are relative file names and values are ES modules—one entry per file that matches the glob.

{
  './image/reveal_controller.js': { default: RevealController },
}

We can use a helper to register the controllers in our Stimulus application, which will infer the controller names from the file names by following the conventions.

import { Application } from 'stimulus'
import { registerControllers } from 'stimulus-vite-helpers'

const app = Application.start()

const controllers = import.meta.globEager('./**/*_controller.js')
registerControllers(app, controllers)

app/frontend/controllers/index.js

  import '~/styles/background.css'
+ import '~/controllers'

  console.log('Vite ⚡️ Rails')

app/frontend/entrypoints/application.js

We can get the benefit of HMR by adding the vite-plugin-stimulus-hmr plugin to our vite.config.ts file.

  import { defineConfig } from 'vite'
  import RubyPlugin from 'vite-plugin-ruby'
+ import StimulusHMR from 'vite-plugin-stimulus-hmr'

  export default defineConfig({
    plugins: [
      RubyPlugin(),
+     StimulusHMR(),
    ],
  })

Now changes to our Stimulus controllers will be pushed instantly to the browser, allowing us to iterate as needed without reloading the page.

Sidecar assets

When using a library such as ViewComponent, it can be convenient to group JS and CSS files within each component folder—sometimes called sidecar assets.

Although there are a few options to achieve this, the simplest option is to import all JS files as we saw earlier with Stimulus controllers.

import.meta.globEager('../components/**/*_component.js')

app/frontend/components.js

  import '~/styles/background.css'
+ import '~/components'

  console.log('Vite ⚡️ Rails')

app/frontend/entrypoints/application.js

Files matching the glob will be imported, as well as any of their dependencies, such as CSS files. For example, if we wanted to encapsulate a component using the Shadow DOM:

:host {
  display: flex;
  flex-direction: column;
}

.commenter {
  display: flex;
}

app/components/comment_component.css

import styles from './comment_component.css?inline'

class Comment extends HTMLElement {
  constructor() {
    super()
    const shadow = this.attachShadow({ mode: 'open' })
    shadow.innerHTML = `
      <style>
        ${styles}
      </style>
      <div class="commenter">
        <slot name="author"></slot>
      </div>
      <div class="body">
        <slot name="body"></slot>
      </div>
    `
  }
}
customElements.define('my-comment', Comment)

app/components/comment_component.js

Using a framework

Unlike other bundlers, adding a framework in Vite.js requires very little config. There are official plugins for Vue, React, and Svelte, which can be used to import .vue, .jsx, and .svelte files. For example, adding Vue support:

  import { defineConfig } from 'vite'
  import RubyPlugin from 'vite-plugin-ruby'
+ import VuePlugin from '@vitejs/plugin-vue'

  export default defineConfig({
    plugins: [
      RubyPlugin(),
+     VuePlugin(),
    ],
  })

We can now import .vue files and mount them as usual:

  import '~/styles/background.css'
+ import { createApp } from 'vue'  
+ import App from '~/components/App.vue' 
+  
+ createApp(App).mount('#app')

  console.log('Vite ⚡️ Rails')

As we saw earlier, Vite.js will detect changes we make in our components and push updates to the browser, causing Vue to re-render the updated components. To our delight this whole process happens within milliseconds 😃

Code-splitting

We can leverage glob and dynamic imports to split our bundle into separate files, which is useful to defer the load of components until they are needed.

Let’s see an example using Inertia.js:

import { createInertiaApp } from '@inertiajs/inertia-vue'

const pages = import.meta.glob('../Pages/**/*.vue')

createInertiaApp({
  async resolve (name) {
    return await pages[`../Pages/${name}.vue`]()
  },
  ...

Unlike we saw before for globEager, for glob the values of the returned object are functions that return a dynamic import of the corresponding module.

{
  '../Pages/Auth/Login.vue': () => import('/vite-dev/Pages/Auth/Login.vue'),
}

When building for production, each of the dynamic imports is bundled separately:

{
  '../Pages/Auth/Login.vue': () => import('./Login.3ee8f1b8.js'),
}

Additional Resources 📖

In this post we have barely scratched the surface of what’s possible with Vite.js, but hopefully we saw enough to understand the basics and start using it.

The following links should be helpful if you would like to learn more, are looking for a starter template, or need a working example to use as a configuration reference.

Docs

Plugins

Example Apps

Starter Templates


Until next time 👋🏼


You may also enjoy