Build a Hot-Swappable Vue Component System with Vite's Lib Mode
How to Implement On-Demand Packaging and Remote Loading of Vue Components with Vite
1. Background and Architecture
1.1 Business Scenario
I participated in a desktop content display project with the tech stack Electron + Vue3. The core requirement was: Content consists of multiple independently developed card components that need to be updated independently without repackaging the entire desktop application.
Simply put: components need hot updates, can be replaced at any time, without affecting the main program.
Based on this requirement, a four-layer architecture was designed:
┌─────────────────────────────────────────────────────────────┐
│ Layer 1: Electron Desktop Application │
│ Responsibility: Display content, provide runtime container, render cards │
├─────────────────────────────────────────────────────────────┤
│ Layer 2: Content Orchestration System │
│ Responsibility: Upload components, orchestrate layout, configure navigation, publish configuration │
├─────────────────────────────────────────────────────────────┤
│ Layer 3: Component Development Project (Focus of this article) │
│ Responsibility: Develop card components → Package UMD → Generate ZIP → Upload to component repository │
├─────────────────────────────────────────────────────────────┤
│ Layer 4: Detail Landing Page │
│ Responsibility: Detail page navigated to after clicking a card │
└─────────────────────────────────────────────────────────────┘
The core of this architecture is Layer 3: how to make each component independently developed, independently packaged, and independently deployed.
1.2 Core Problems
We need to solve three problems:
- How to package? A project has multiple components, each component needs to be packaged into a separate JS file.
- How to load? The desktop application dynamically loads these JS files at runtime and renders them.
- How to orchestrate? The backend system can flexibly configure which components are displayed where.
This article focuses on the first two problems.
2. Component Packaging: Vite Lib Mode
2.1 Design Goals
We want this effect: in the component development project, executing a command packages the specified component.
npm run component abc # Package abc component
npm run component newsList # Package newsList component
npm run component chart # Package chart component
Each component is packaged independently without interfering with each other. The output is a UMD file that can be run directly in the browser.
2.2 Project Structure
The directory structure of the component development project is as follows:
component-project/
├── src/
│ └── components/
│ ├── abc/
│ │ ├── index.js # Component entry
│ │ ├── abc.vue # Component code
│ │ └── config.json # Component metadata
│ ├── newsList/
│ │ ├── index.js
│ │ ├── newsList.vue
│ │ └── config.json
│ └── chart/
│ ├── index.js
│ ├── chart.vue
│ └── config.json
├── scripts/
│ └── build-component.js # Build script
├── vite.component.config.js # Vite build configuration
└── package.json
2.3 How Components Export
Each component directory must have an index.js as the entry file:
// src/components/abc/index.js
import abc from './abc.vue'
export default {
// install method: supports registration via app.use()
install(app) {
app.component('abc', abc)
},
// Direct export of component: supports on-demand import
abc
}
Why have an install method? Because we need to support two usage scenarios:
- Consumer registers all components at once with
app.use() - Consumer imports a single component individually
2.4 Core Configuration: One Configuration Serves All Components
Key point: We don't want to write a configuration file for each component, but hope to dynamically handle all components with one configuration file.
// vite.component.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js'
const componentName = process.env.COMPONENT_NAME
const entryFile = process.env.COMPONENT_ENTRY
export default defineConfig({
plugins: [
vue(),
cssInjectedByJsPlugin()
],
build: {
lib: {
entry: entryFile,
name: componentName + 'Component',
fileName: (format) => `${componentName}.${format}.js`
},
rollupOptions: {
external: ['vue'],
output: {
globals: {
vue: 'Vue'
}
}
},
cssCodeSplit: false
}
})
Here are several key design decisions:
| Configuration Item | Role |
|---|---|
process.env.COMPONENT_NAME |
Pass component name via environment variable |
process.env.COMPONENT_ENTRY |
Pass entry file path via environment variable |
external: ['vue'] |
Vue is not bundled; provided by the host environment |
globals: { vue: 'Vue' } |
The packaged output expects window.Vue to exist globally |
cssCodeSplit: false |
Do not split CSS |
cssInjectedByJsPlugin() |
Inject CSS into JS |
2.5 Build Script
// scripts/build-component.js
import { execSync } from 'child_process'
import fs from 'fs'
import path from 'path'
// Get command line arguments
const componentName = process.argv[2]
if (!componentName) {
console.error('Please specify a component name')
process.exit(1)
}
// Check if the component's entry file exists
const entryFile = path.join('src/components', componentName, 'index.js')
if (!fs.existsSync(entryFile)) {
console.error(`Component entry file does not exist: ${entryFile}`)
process.exit(1)
}
// Set environment variables
process.env.COMPONENT_NAME = componentName
process.env.COMPONENT_ENTRY = entryFile
// Execute the build
execSync('npx vite build --config vite.component.config.js', {
stdio: 'inherit',
env: process.env
})
2.6 package.json Script Configuration
{
"scripts": {
"component": "node scripts/build-component.js"
}
}
Now, the complete flow of executing npm run component abc is as follows:
build-component.jsreads the parameterabc- Concatenates the entry path
src/components/abc/index.js - Passes it to Vite via environment variables
- Vite reads the
vite.component.config.jsconfiguration - Builds and outputs
dist/abc/abc.umd.jsanddist/abc/abc.es.js
The packaged output exposes window.abcComponent globally, and consumers access the component through this global variable.
2.7 Style Handling
By default, Vite extracts CSS into separate files. But our goal is one JS file containing everything; the user shouldn't need to worry about CSS files.
Solution: Use vite-plugin-css-injected-by-js
npm install --save-dev vite-plugin-css-injected-by-js
This plugin automatically creates <style> tags and inserts them into the page when the component loads. Users only need to import the JS file, and the styles take effect automatically.
3. Component Loading: Dynamic Registration
3.1 Overall Flow
1. App starts → 2. Fetch configuration → 3. Get component list → 4. Dynamically load JS → 5. Register components → 6. Render page
3.2 Loading a Single Component
function loadComponent(name) {
return new Promise((resolve) => {
const script = document.createElement('script')
script.src = `/libs/${name}/${name}.umd.js`
script.onload = () => {
// The name configured during packaging is componentName + 'Component'
const component = window[`${name}Component`]
resolve(component ? component[name] : null)
}
script.onerror = () => {
console.warn(`Component ${name} failed to load`)
resolve(null)
}
document.head.appendChild(script)
})
}
3.3 Loading All Components and Starting the App
Key point: The app must be mounted only after all components have finished loading, otherwise the page renders before the components are registered.
// main.js
import * as Vue from 'vue'
import { createApp } from 'vue'
import App from './App.vue'
// Mount Vue globally (components externalized Vue during packaging)
window.Vue = Vue
// Component list (in a real project, fetched from a configuration API)
const components = ['abc', 'newsList']
// Load all components
const loaders = components.map(name => loadComponent(name))
// Wait for all to finish loading
Promise.all(loaders).then(results => {
const app = createApp(App)
// Register all components
results.forEach((component, index) => {
if (component) {
app.component(components[index], component)
console.log(`✅ Registered: ${components[index]}`)
}
})
app.mount('#app')
})
3.4 Using Components
After registration, components can be used directly in any Vue file:
<template>
<div>
<!-- Use like local components -->
<abc title="Title" content="Content" />
<news-list :data="newsData" />
</div>
</template>
<script>
export default {
data() {
return {
newsData: ['News 1', 'News 2']
}
}
}
</script>
4. Problems Encountered and Solutions
4.1 Problem 1: CSS Not Bundled into JS
Phenomenon: cssCodeSplit: false was configured, but Vite still generated separate CSS files, and components displayed without styles.
Cause: cssCodeSplit: false only prevents splitting CSS into multiple files (i.e., merges all CSS into one file), but Vite still outputs a separate CSS file.
Solution: Use the vite-plugin-css-injected-by-js plugin.
import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js'
export default defineConfig({
plugins: [
vue(),
cssInjectedByJsPlugin()
]
})
This way, style code is injected into the JS, and when the component loads, <style> tags are automatically inserted into the page.
4.2 Problem 2: Components Disappear on Page Refresh
Phenomenon: The page loads correctly on the first visit, but after refreshing, components become empty tags like <abc></abc>.
Cause: Dynamically loading scripts is asynchronous, but app.mount('#app') executes synchronously. On refresh, the scripts haven't finished loading yet when the app mounts, resulting in unregistered components.
Solution: Use Promise.all to wait for all components to finish loading before executing app.mount().
// ❌ Incorrect approach
components.forEach(name => loadComponent(name))
const app = createApp(App)
app.mount('#app')
// ✅ Correct approach
Promise.all(components.map(name => loadComponent(name)))
.then(() => {
const app = createApp(App)
app.mount('#app')
})
4.3 Problem 3: ref Errors
Phenomenon: Components using Vue APIs like ref, reactive throw errors:
Uncaught TypeError: Cannot read properties of undefined (reading 'ref')
Cause: The component packaging configured external: ['vue'], meaning import { ref } from 'vue' in the component code needs to find the global Vue object at runtime. However, by default, the Vue application does not expose Vue on window.
Solution: Mount Vue globally in the application entry point.
// main.js
import * as Vue from 'vue'
window.Vue = Vue
This is an implicit contract: when packaging components, it is agreed that the host environment must provide window.Vue, and the consumer must abide by this agreement.
5. Summary
5.1 Tech Stack
| Technology | Role |
|---|---|
| Vite | Build tool, lib mode supports library packaging |
| Vue3 | Component framework |
| UMD | Module format, compatible with script tag loading |
| vite-plugin-css-injected-by-js | Inject CSS into JS |
5.2 Core Mechanism
Packaging Phase:
- Pass component name via command line argument
- Environment variables dynamically configure Vite entry and output
- UMD format exposes the component globally on
window
Loading Phase:
- Dynamically create
scripttags to load JS files Promise.allcontrols loading timing- After loading, register as Vue global components
5.3 Key Decisions
| Decision | Reason |
|---|---|
| Externalize Vue | Avoid duplicate packaging, reduce file size |
| Inject CSS into JS | One file handles everything, reduces usage cost |
| UMD format | Best compatibility with script tags |
| Promise.all controls timing | Solve the problem of components disappearing on page refresh |
5.4 Applicable Scenarios
- Scenarios where components need to be reused across projects
- Scenarios where components need to be dynamically loaded at runtime
- Scenarios where content needs flexible orchestration
- Component sharing scenarios in micro-frontend architectures
5.5 Project Address
1, Display Component Project
2, Write and Package Single Component
These two projects are demos. The second shows how to package a single component. After packaging, copy the generated files to the public/libs directory of project 1. Components are automatically registered with the filename as the component name, and the filename can be used directly as a tag.
This article was first published on Juejin. If you have any questions, feel free to discuss in the comments.