The Simplest Method to Create a Vue.js Component Library
TL;DR: vue-library Continue reading to grasp the process behind it and understand the reasons for my choices.
A component library is a collection of reusable pieces that can be employed in various projects. It facilitates sharing resources among different projects and teams. These components may be low-level and generic like buttons, inputs, and modals, or more business-specific modules. Ultimately, it's a method to share code across projects, a crucial skill that can significantly save time.
However, building a component library with Vue.js is harder than it seems because of the Single File Components (SFC). In a standard TypeScript project, you usually transpile to JavaScript and bundle your files with a tool like tsup or Vite.
Note
Remember to deliver package code as native as possible and let userland tools handle transpiling and optimization. It's a principle I always keep in mind when crafting libraries, streamlining the process significantly. Each case is unique, but this is a solid guideline to follow.
The use of Vue.js SFC complicates this process. Let's examine why by exploring the challenges you'll encounter when constructing a Vue.js component library.
Consider the following component:
<script lang="ts" setup>
import { useUser } from '../composables'
const { user } = useUser()
</script>
<template>
<div v-if="user">
{{ user.name }}
</div>
</template>
The composable is as follows:
import { ref } from 'vue'
export function useUser() {
const user = ref({ name: 'John Doe' })
return {
user
}
}
The component employs a composable imported from another file. It may seem trivial, but this poses a significant issue.
All of this follows the architecture below:
src/
components/
User.vue
composables/
useUser.ts
index.ts
The index.ts
file acts as the library's entry point and it looks like this:
import User from './components/User.vue'
export { User }
export { useUser } from './composables/useUser'
Plus, the package.json
includes some exports
:
{
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs"
}
},
"main": "dist/index.mjs",
"types": "dist/index.d.ts"
}
Similar to a Standard TypeScript Project
Note
A bundle is a single file encapsulating all your project's code. It optimizes project loading by minimizing the number of files to load. This bundle isn't transpiled (except TypeScript to JavaScript) or minified when creating an npm package.
Now, if we attempt to bundle this project, our dist
folder will appear like this:
dist/
index.cjs
Upon inspecting index.cjs
, you'll find a surprise: the useUser
composable is inlined, but the component is entirely ignored. If it functions, the bundler might throw an error like No loader is configured for ".vue" files
. Essentially, tools like esbuild or tsc don't know how to handle Vue files. It's logical since Vue files aren't JavaScript files.
The problem becomes evident. We need some configuration to handle Vue files. Let's quickly browse the internet for something like vue loader
, as we're utilizing Vue files and need to transpile them to JavaScript.
The top result is Vue Loader, which is a Webpack loader. No. I refuse to use Webpack. Vite is now a standard, and there's no justification for employing Webpack in this context.
But before delving further, let's revisit our mantra: deliver package code as native as possible and keep it simple, stupid! Transpiling Vue SFC files using Webpack seems contrary to this principle.
Ignoring Vue Files
To deliver .vue
files without transpiling them, we instruct our bundler to disregard them. This is achieved using a tool called unbuild from the UnJS ecosystem.
Unbuild is a straightforward tool yet highly configurable since it's constructed atop rollup.
For our small project, we can directly experiment with it:
npx unbuild
Unfortunately, it fails for the same reasons as before:
src/components/ShowGitHubUser.vue (1:0): Expression expected (Note that you need plugins to import files that are not JavaScript)
But unbuild has the potential for much more.
To tackle this problem, let's attempt the obvious solution. In the index.ts
file, remove the component export and solely export TypeScript files:
export { useUser } from './composables/useUser'
Now, the command npx unbuild
functions properly.
➜ npx unbuild
ℹ Automatically detected entries: src/index [esm] [dts]
ℹ Building vue-library
ℹ Cleaning dist directory: ./dist
✔ Build succeeded for vue-library
dist/index.mjs (total size: 139 B, chunk size: 139 B, exports: useUser)
Σ Total dist size (byte size): 531 B
This is a good start, but our components remain in the src
folder and no matter how diligently you search, they're missing from the dist
folder.
Copying Vue Files
Now that .ts
files are processed correctly, we can attempt to copy the .vue
files to the dist
folder using a simple cp
command.
cp -r src/components/ dist/components/
Subsequently, we add an exports
field in the package.json
to inform the user where to locate the components:
{
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs"
},
"./components/*": {
"import": "./dist/components/*.vue"
}
},
"main": "dist/index.mjs",
"types": "dist/index.d.ts"
}
Looks promising, doesn't it? But it isn't. 😔
The Import Path Dilemma
In our Vue component, the useUser
composable is imported like this:
<script lang="ts" setup>
import { useUser } from '../composables'
</script>
Meanwhile, our dist
folder is structured as follows:
dist/
components/
User.vue
index.d.mts
index.d.ts
index.mjs
Can you identify the problem? 👀
The component is unusable and will produce an error like Cannot find module '../composables' or its corresponding type declarations
, because the path leads nowhere. We've bundled all of our TypeScript files into a single file index.mjs
, losing the project's structure entirely.
To Bundle or Not to Bundle?
From here, we have two choices:
- Bundling the Vue files using Vite, plugins, and extensive configuration.
- Maintaining the directory structure and using a tool like mkdist for file-to-file transpiling (bundleless build) on TypeScript files.
The decision largely hinges on your specific needs, but I'm convinced that simplicity is the best approach. So, let's explore the second option.
The goal is to transform the TypeScript files into JavaScript files, preserving the original structure of the src
folder in the dist
folder and ignoring the Vue files.
Note
Using a <script setup lang="ts">
block, mkdist will ignore Vue files to enable the Vue compiler to generate runtime props. If not, it will generate a .vue.d.ts
file to offer types for the Vue component. Remember that the @vitejs/plugin-vue and Vite inherently understand TypeScript script blocks. For more details, refer to issue mkdist#14.
Configuring Unbuild
Yes, we will configure unbuild since it incorporates mkdist, making it exceptionally user-friendly.
First, create a build.config.ts
file at the project's root. Unbuild will read this configuration file to understand how to process the project. It assumes defaults and infers many aspects from the package.json
, but we need to tell it to utilize mkdist.
import { defineBuildConfig } from 'unbuild'
export default defineBuildConfig({
entries: ['./src/'],
declaration: true,
})
It's a very straightforward configuration file. And I adore it. The entries
key informs unbuild of the initial file to begin transpiling. However, ./src/
is a directory, indicated by the /
at the end. With this hint, unbuild bypasses the default bundler rollup
in favor of mkdist
. The declaration
key prompts unbuild to create TypeScript declaration files (.d.ts
).
We can also remove the exports
field in the package.json
since we won't be bundling files anymore and restore the component export in the index.ts
file.
Let's rerun the npx unbuild
command and witness the magic.
➜ npx unbuild
ℹ Building vue-library
ℹ Cleaning dist directory: ./dist
✔ Build succeeded for vue-library
dist (total size: 600 B)
└─ dist/index.d.ts (108 B)
└─ dist/index.mjs (112 B)
└─ dist/composables/useUser.d.ts (79 B)
└─ dist/composables/useUser.mjs (124 B)
└─ dist/components/User.vue (177 B)
Σ Total dist size (byte size): 600 B
Now, the dist
folder appears like this:
dist/
components/
User.vue
composables/
useUser.d.ts
useUser.mjs
index.d.ts
index.mjs
This mirrors the structure of the src
folder, and the User.vue
component is now usable in the dist
folder. The relative import path remains intact, and the component can be utilized in any Vue project. 🥳
Local Development
Many tutorials halt here, suggesting you're ready to publish your package to npm. But how can you create a complex library if you can't see the outcome of your work? If you can't test it while building it?
Basically, you can't.
Let's examine how to use the library in a local project, within the same repository for simplicity.
I'll demonstrate the simplest method using a pnpm workspace.
Start by creating a pnpm-workspace.yaml
file at the project's root:
packages:
- .
- playground
Then, create a new Vite project in a playground
folder:
npx create-vite playground --template vue-ts
Since we're using a pnpm workspace, install the dependencies from the project's root:
pnpm install
Note
This step isn't mandatory. You can create a new Vite project and install dependencies within it. Pnpm simplifies this by installing dependencies for all workspaces in the root. A simple pnpm install
installs dependencies from all workspaces.
Now, we can use this playground
library within a "real" project to test our library.
For instance, open the src/App.vue
file and import the User
component:
<script setup lang="ts">
import { User } from '../../src'
</script>
<template>
<User />
</template>
And run the project:
cd playground && pnpm dev
You'll see the User
component displayed in the browser. 🎉 So simple, yet so powerful. I use this method for virtually every library I build, and it works brilliantly.
Publishing to npm
This part is straightforward once you grasp the workflow.
Begin by naming your library. I'll use @barbapapazes/vue-library
for this example.
Next, create an account on npm and log in using the npm login
command.
Then, install changelogen to generate a changelog, update the version number following semantic versioning and commit convention, and to pre-fill the GitHub release.
pnpm i -D changelogen
Next, add two scripts to your package.json
:
{
"scripts": {
"prepack": "unbuild",
"release": "changelogen --release && npm publish --access public && git push --follow-tags"
}
}
The prepack
script runs the unbuild
command before publishing the package to npm. The release
script generates a changelog, publishes the package to npm, and pushes the tags to GitHub.
Note
Your project should be on GitHub for the --release
option to work. If not, omit it and create the release manually on your repository.
Now, publish your package to npm using the following command:
npm run release
And that's it! You've just published your first Vue.js component library to npm. 🚀
Everything is Ready
We're done for today. We've covered numerous topics:
- How to build a Vue.js component library with TypeScript.
- How to manage Vue files in a TypeScript project.
- How to use unbuild to transpile TypeScript files without bundling Vue files.
- How to leverage mkdist to maintain the project structure.
- How to utilize a pnpm workspace to test the library in a local project.
- How to publish the library to npm.
For a more detailed and complex example, check GitHub: vue-library but everything explained here stems from my real-world experience.
I hope you enjoyed this tutorial and that it helps you build your own Vue.js component library. If you have questions, feel free to reach out to me on X (Twitter).