跪拜 Guibai
← Back to the summary

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:

  1. How to package? A project has multiple components, each component needs to be packaged into a separate JS file.
  2. How to load? The desktop application dynamically loads these JS files at runtime and renders them.
  3. 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:

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:

  1. build-component.js reads the parameter abc
  2. Concatenates the entry path src/components/abc/index.js
  3. Passes it to Vite via environment variables
  4. Vite reads the vite.component.config.js configuration
  5. Builds and outputs dist/abc/abc.umd.js and dist/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:

Loading Phase:

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

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.