Published on

Goodbye Webpack, Hello Vite!

Authors

Talking about build tools may not be among the more interesting topics in development, but there is a new generation of build tools that are worth getting excited about once you get to see how they improve the developer experience. When we talk about build tools, we're used to talking about build tools as bundlers. In the past, these terms were for the most part synonymous. Bundlers like webpack and rollup do exactly that - they bundle the source code and all of the dependencies in the node_modules folder, then runs this through the build process which may include transpiling, minification, and other transformations, then pushes the result to the browser as a single file. In large codebases, this the amount of time it takes to go through this process can be quite significant.

Vite does not follow this model. It is made possible by native JavaScript modules. Vite works by waiting for the browser to find an import statement, then makes an HTTP request for the module. Only then does it apply transforms to the requested module before serving it to the browser. This allows for instant server startup and super fast Hot Module Replacement. Vite also [https://vitejs.dev/guide/dep-pre-bundling.html](pre-bundles dependencies) using esbuild, which is up to 100x faster than JavaScript bundlers like webpack and rollup because it is written in GO.

Vite Setup

To add Vite to your project, you need to install it.

npm install vite --save-dev

Then, to enable React support, add the React plugin for Vite:

npm install @vitejs/plugin-react --save-dev

Vite Configuration

First, you'll need to create a vite.config.js file in your project root. This will hold any necessary configuration parameters for building with Vite. This is the base configuration I used for migrating my apps:

import {defineConfig} from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
import dns from 'dns';

dns.setDefaultResultOrder('verbatim');

export default ({mode}) =>
  defineConfig({
    server: {
      host: 'localhost',
      port: 3000,
      strictPort: true,
      open: '/admin',
      proxy: {
        '^.*/api': {
          target: getTarget(process.env.REACT_APP_API),
          changeOrigin: true,
        },
      },
    },
    plugins: [react()],
    css: {
      modules: {
        localsConvention: 'camelCase',
        generateScopedName: mode === 'production' ? '[hash:base64:5]' : '[local]_[hash:base64:5]',
      },
    },
    define: {
      process: {
        env: {
          REACT_APP_API: process?.env.REACT_APP_API || 'production',
        },
      },
    },
    resolve: {
      alias: [
        {find: 'components', replacement: path.join(__dirname, 'src', 'components')},
        {find: 'containers', replacement: path.join(__dirname, 'src', 'containers')},
        {find: 'contexts', replacement: path.join(__dirname, 'src', 'contexts')},
        {find: 'i18n', replacement: path.join(__dirname, 'src', 'i18n')},
      ],
    },
  });

Let's step through this configuration file and see what it does. The first line after the imports might look a bit odd. This code is a little bit of a hack to make localhost work. Node.js below v17 reorders the result of DNS-resolved addresses. dns.setDefaultResultOrder('verbatim') disables this reordering behavior so that Vite prints the address as localhost when the browser-resolved address address differs from the address which Vite is listening.

defineConfig is essentially a helper function that provides IDE intellisense for the configuration file. It takes an object as an argument containing the config parameters.

The server object

The server object contains - as you may have guessed - the configuration for the development server. The host and port parameters are self-explanatory. strictPort is a boolean that tells Vite whether or not to exit if the port is already in use, as opposed to using the next available. open as a boolean says whether or not to automatically open in the browser, but when it is set to a string it will use it as the pathname. proxy is an object that tells Vite to proxy requests to the specified URL to the specified target. This can be pulled from the process environment, allowing the target domain to be configured at runtime based on the current environment. This is useful for development when the backend API is not running on the same domain as the frontend. The target parameter is the URL of the backend API. The changeOrigin parameter is used to change the origin of the request to the backend API.

CSS Configuration

When using CSS modules, these options are passed on to postcss-modules. localsConvention tells Vite what the naming convention is for css filenames. Note that setting this to camelCaseOnly allows for the use of named imports of CSS classes.

The generateScopedName parameter is a function that takes the name of the CSS class and the filename of the CSS file and returns a string that will be used as the name of the CSS class in the compiled CSS. In production, this is set to [hash:base64:5] which will generate a unique hash for each CSS class. In development, this is set to [local]_[hash:base64:5] which will use the name of the CSS class as the name of the CSS class in the compiled CSS. This is useful for debugging CSS issues.

The define object

The define object is used to define global constants that can be used in the code. In this case, the process.env.REACT_APP_API is used to determine the target of the proxy

The resolve object

The final component of the Vite config is simply resolving a list of aliases for import statements.

TS Config Modifications

I eneded up making a number of changes to the tsconfig files for my applications, some of which were needed for Vite to work.

This is what the old tsconfig looked like:

{
  "compilerOptions": {
    "declaration": true,
    "declarationDir": "./types",
    "module": "esnext",
    "noImplicitAny": true,
    "outDir": "./types",
    "target": "es5",
    "jsx": "react",
    "moduleResolution": "node",
    "allowSyntheticDefaultImports": true,
    "lib": ["dom", "es2017"],
    "allowJs": true,
    "skipLibCheck": true,
    "esModuleInterop": true,
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": true,
    "baseUrl": "src",
    "noFallthroughCasesInSwitch": true
  },
  "types": ["node", "jest", "@testing-library/jest-dom"],
  "exclude": ["node_modules", "dist", "jest"],
  "include": ["src/**/*"]

And here is the updated version of it:

{
  "compilerOptions": {
    "target": "ESNext",
    "useDefineForClassFields": true,
    "lib": ["DOM", "DOM.Iterable", "ESNext"],
    "allowJs": false,
    "skipLibCheck": true,
    "esModuleInterop": false,
    "allowSyntheticDefaultImports": true,
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "module": "ESNext",
    "noImplicitAny": true,
    "jsx": "react",
    "moduleResolution": "node",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": true,
    "baseUrl": "src",
    "noFallthroughCasesInSwitch": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noImplicitReturns": true,
    "newLine": "LF",
    "declaration": true,
    "declarationDir": "./types",
    "plugins": [{"name": "typescript-plugin-css-modules"}]
  },
  "types": ["node", "jest", "@testing-library/jest-dom"],
  "exclude": ["node_modules"],
  "include": ["src/**/*"],
  "references": [{"path": "./tsconfig.node.json"}]
}

The first thing we needed to do was stop targeting es5, so we set "target": "ESNext". This will allow us to use modern JavaScript features like import and export statements. We also set "module": "ESNext" to allow for the use of ES modules.

Type definitions are automatically provided for the APIs that match the target config option in the tsconfig, and additional type definitions will be provided for the libraries specified in the lib definition, but lib is not needed for Vite to work.

Vite uses esbuild to transpile TypeScript into JavaScript. This makes it approximately 25x faster than using the vanilla TypeScript compiler, and it makes HMR updates happen in less than 50ms. However, esbuild only performs transpilation without type information, and it doesn't support some features. Setting isolatedModules to true enables TypeScript warnings for features that do not work with isolated transpilation.

We also set useDefineForClassFields to true, which is the standard ECMAScript runtime behavior. This value is true by default if the target is ESNext, but it is included here for brevity.

The last item of note is the reference to the second tsconfig file in the references array. This is a new feature in TypeScript 4.3 that allows for multiple tsconfig files to be used in a project. This is useful for projects that have multiple build targets, like a Node.js server and a React application. The second tsconfig file is used to build the Node.js server, and should be included in the main tsconfig file as a reference.

TS Config for Node

When your app runs, it uses the tsconfig.json file for your source code that runs in the browser environment. There's also a second TS config that is needed for the Node environment where Vite is running. This is a very different environment with its own set of API's and constraints, so we need to provide it with its own typescript configuration.

{
  "compilerOptions": {
    "composite": true,
    "module": "ESNext",
    "moduleResolution": "Node",
    "allowSyntheticDefaultImports": true
  },
  "include": ["vite.config.ts"]
}

As you can see, we provide a few compiler options to tell Typescript what module and module system to use for the environment. Most importantly, we tell it to include the Vite config file that we created earlier.

Vite Usage

The final thing you might want to do is set the start script in your package.json.

"scripts": {
    "vite:start:common": "vite",
    "vite:start": "cross-env REACT_APP_API=dev npm run vite:start:common",
    "vite:start:staging": "cross-env REACT_APP_API=staging npm run vite:start:common",
    "vite:start:mock": "cross-env REACT_APP_API=dev REACT_APP_MOCK_API=1 npm run vite:start:common",
  },

These start scripts are doing some extra stuff like setting the value for the REACT_APP_API environment variable. This is used to determine the target of the proxy, as you saw earlier. However, simply running npm run vite will suffice for most applications to start the Vite server for testing.

You can also use Vite to build your application for production, which essentially uses a modified version of Rollup for this task. The reason you use Vite is not for the build, but rather for the development experience that it provides.

For more information about Vite, check out the Vite documentation.