Create your own
Lesson illustration

Optimizing Vite Builds: Externalizing Peer Dependencies with Rollup

Hello! Welcome back.

In our last lesson, we successfully configured Vite to bundle our TypeScript project into a dual-format library, producing both ESM and CommonJS outputs. We also updated package.json to ensure that consumers of our library can correctly resolve these different module formats.

Today, we'll address a crucial aspect of library development: dependency management. Currently, if our library were to use an external package, Vite would bundle that package's code directly into our output files. This can lead to bloated bundles and version conflicts.

Our goal is to refine our build process to prevent this. By the end of this lesson, you will be able to refine the Vite build by configuring Rollup options to externalize peer dependencies.

1. The Problem: Bundled Dependencies

When building an application, bundling all dependencies into a single file is often desirable. For a library, however, it's an anti-pattern.

Imagine our observable library uses a utility package like lodash-es. If we bundle lodash-es into our library, and a developer using our library also uses lodash-es in their application, the final application bundle will contain the lodash-es code twice. This unnecessarily increases the application's size.

Worse, what if the developer uses a different version of lodash-es? Having two different versions of the same library active at once can lead to unpredictable behavior and hard-to-debug errors.

The solution is to treat these as peer dependencies.

2. Peer Dependencies and Externalization

A peer dependency is a dependency that your library expects the host environment or consuming project to provide. By declaring a package as a peer dependency, you are stating: "My library is compatible with this version of the package, but it's your responsibility to install and manage it."

This solves both problems:

  • No Bloat: The dependency is only included once in the final application bundle.
  • No Version Conflicts: The consumer controls the exact version of the dependency, ensuring a single, consistent version is used throughout their project.

To implement this, we need to do two things:

  1. Declare the peer dependencies in package.json.
  2. Tell our bundler (Vite/Rollup) to not bundle these dependencies. This is called externalizing.

3. Configuring package.json

Let's use a common example. Imagine we're building a React component library. react would be a classic peer dependency. The consuming application will always have React installed, so we should never bundle it with our components.

The first step is to modify package.json.

Create a Component Library Fast🚀(using Vite's library mode)

This article provides a clear example of how to manage dependencies for a component library. We'll focus on the section that explains how to configure peerDependencies.

Please read the 'Dependencies' subsection within the 'A few last steps before you can publish the package' section. Focus on how react and react-dom are moved from dependencies to peerDependencies.

As the article shows, you would move the package from dependencies to a new peerDependencies object in your package.json.

For our project, we don't have any dependencies yet. But if we were to add one, say some-dependency, our package.json would look like this:

{
  "name": "my-observable-framework",
  // ...
  "devDependencies": {
    // ...
  },
  "peerDependencies": {
    "some-dependency": "^1.0.0"
  }
}

This tells npm that any project installing my-observable-framework must also have some-dependency installed.

4. Configuring Vite with rollupOptions

Declaring a peer dependency in package.json is a signal to the package manager. It does not automatically tell Vite to stop bundling it. For that, we need to configure Vite's build options.

Since Vite uses Rollup under the hood, it exposes a build.rollupOptions object in vite.config.ts that allows us to pass configuration directly to Rollup. The specific option we need is external.

Publish a Vue Component to NPM // Vite and Vue 3

This video demonstrates how to externalize dependencies in a Vite project. Although it uses Vue as an example, the concept and configuration for rollupOptions.external are identical for any framework or library.

Watch from 02:01 to 02:29. Pay close attention to how the build.rollupOptions object is added to the Vite config, specifically the external property.

As you saw in the video, we can provide an array of package names to the external property. Rollup will then treat any import of these packages as external and will not include them in the final bundle.

Here is how you would modify your vite.config.ts:

import { defineConfig } from 'vite'
import { resolve } from 'path'
import dts from 'vite-plugin-dts'

export default defineConfig({
  plugins: [dts()],
  build: {
    lib: {
      entry: resolve(__dirname, 'src/index.ts'),
      name: 'MyObservableFramework',
      fileName: (format) => `my-observable-framework.${format}.js`,
      formats: ['es', 'cjs'],
    },
    rollupOptions: {
      // make sure to externalize deps that shouldn't be bundled
      // into your library
      external: ['some-dependency'], // Our hypothetical dependency
    },
  },
})

5. Automating Externalization

Manually keeping the external array in vite.config.ts in sync with the peerDependencies in package.json is inefficient and prone to error. We can automate this.

Since vite.config.ts is a standard ES module, we can import our package.json file and dynamically generate the external array from its peerDependencies.

First, you may need to adjust your tsconfig.json to allow importing JSON files. Add "resolveJsonModule": true to your compilerOptions:

// tsconfig.json
{
  "compilerOptions": {
    // ... other options
    "resolveJsonModule": true,
    "isolatedModules": true
  }
}

Now, update your vite.config.ts to read directly from package.json:

import { defineConfig } from 'vite'
import { resolve } from 'path'
import dts from 'vite-plugin-dts'
import pkg from './package.json' // Import package.json

export default defineConfig({
  plugins: [dts()],
  build: {
    lib: {
      entry: resolve(__dirname, 'src/index.ts'),
      name: 'MyObservableFramework',
      fileName: (format) => `my-observable-framework.${format}.js`,
      formats: ['es', 'cjs'],
    },
    rollupOptions: {
      external: Object.keys(pkg.peerDependencies || {}),
    },
  },
})

With this setup, any package you add to peerDependencies in package.json will automatically be externalized during the build process. This is a robust and maintainable approach.

A Note on UMD Builds and globals

If you were also building a UMD bundle (for direct use in a <script> tag in a browser), externalizing isn't enough. You also need to tell Rollup what global variable the external dependency corresponds to. This is done via the output.globals option inside rollupOptions.

For example, if you externalized react, you would add:

// ...
rollupOptions: {
  external: ['react'],
  output: {
    // Provide global variables to use in the UMD build
    // for externalized deps
    globals: {
      react: 'React',
    },
  },
},
// ...

This tells Rollup that when it sees import React from 'react', it should replace it with code that accesses the global window.React variable in the UMD bundle. We are not building a UMD module for now, but it's a valuable concept to be aware of when working with rollupOptions.

Conclusion

You have now refined your library's build process to be more efficient and robust. By externalizing peer dependencies, you ensure your library is lightweight and plays well with the consumer's project environment, preventing bloat and versioning nightmares.

Key Takeaways:

  • Bundling dependencies into a library is generally an anti-pattern.
  • peerDependencies in package.json are used to declare dependencies that the consuming project must provide.
  • The build.rollupOptions.external property in vite.config.ts instructs Vite/Rollup not to include specified packages in the bundle.
  • Automating the external array by reading peerDependencies from package.json is a professional and maintainable strategy.

Next Lesson Preview:
Our build configuration is now solid. The next logical step is to ensure the code we're building is correct and reliable. In the next lesson, we will set up Vitest for unit testing and configure code coverage reporting, laying the foundation for a high-quality, well-tested library.

Can't find a good explanation? Sign up and we'll make it for you

Sign up