如何用Vite实现Vue组件的按需打包和远程加载

一、背景与架构

1.1 业务场景

我参与过一个桌面端内容展示项目,技术栈是 Electron + Vue3。项目中有个核心需求:内容由多个独立开发的卡片组件构成,这些组件需要能够独立更新,而不需要重新打包整个桌面应用。

简单说就是:组件要能热更新,随时替换,不影响主程序。

基于这个需求,设计了一套四层架构:

text

yaml 复制代码
┌─────────────────────────────────────────────────────────────┐
│  Layer 1: Electron桌面应用                                 │
│  职责:展示内容,提供运行容器,渲染卡片                   │
├─────────────────────────────────────────────────────────────┤
│  Layer 2: 内容编排系统                                     │
│  职责:上传组件、编排布局、配置跳转、发布配置              │
├─────────────────────────────────────────────────────────────┤
│  Layer 3: 组件开发项目(本文重点)                         │
│  职责:开发卡片组件 → 打包UMD → 生成ZIP → 上传组件仓库   │
├─────────────────────────────────────────────────────────────┤
│  Layer 4: 详情落地页                                      │
│  职责:卡片点击后的跳转详情页面                           │
└─────────────────────────────────────────────────────────────┘

这个架构的核心是 Layer 3:如何让每个组件独立开发、独立打包、独立部署。

1.2 核心问题

我们要解决三个问题:

  1. 如何打包? 一个项目里有多个组件,每个组件要能单独打包成JS文件
  2. 如何加载? 桌面应用在运行时动态加载这些JS文件并渲染
  3. 如何编排? 后台系统能够灵活配置哪些组件显示在什么位置

本文重点讲前两个问题。

二、组件打包:Vite Lib模式

2.1 设计目标

我们想要这样的效果:在组件开发项目中,执行命令就能打包指定组件。

bash

arduino 复制代码
npm run component abc        # 打包abc组件
npm run component newsList    # 打包newsList组件
npm run component chart       # 打包chart组件

每个组件独立打包,互不干扰。打包产物是一个可直接在浏览器中运行的UMD文件。

2.2 项目结构

组件开发项目的目录结构如下:

text

bash 复制代码
component-project/
├── src/
│   └── components/
│       ├── abc/
│       │   ├── index.js       # 组件入口
│       │   ├── abc.vue        # 组件代码
│       │   └── config.json    # 组件元信息
│       ├── newsList/
│       │   ├── index.js
│       │   ├── newsList.vue
│       │   └── config.json
│       └── chart/
│           ├── index.js
│           ├── chart.vue
│           └── config.json
├── scripts/
│   └── build-component.js     # 打包脚本
├── vite.component.config.js   # Vite打包配置
└── package.json

2.3 组件如何导出

每个组件目录下必须有一个 index.js 作为入口文件:

javascript

javascript 复制代码
// src/components/abc/index.js
import abc from './abc.vue'

export default {
  // install方法:支持 app.use() 方式注册
  install(app) {
    app.component('abc', abc)
  },
  // 直接导出组件:支持按需引入
  abc
}

为什么要有 install 方法?因为我们要支持两种使用场景:

  • 消费者用 app.use() 一次性注册所有组件
  • 消费者单独引入某个组件

2.4 核心配置:一个配置服务所有组件

关键点来了:我们不想为每个组件写一个配置文件,而是希望一个配置文件动态处理所有组件

javascript

javascript 复制代码
// 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
  }
})

这里有几个关键设计:

配置项 作用
process.env.COMPONENT_NAME 通过环境变量传入组件名
process.env.COMPONENT_ENTRY 通过环境变量传入入口文件路径
external: ['vue'] Vue不打包进去,由宿主环境提供
globals: { vue: 'Vue' } 打包产物期望全局有 window.Vue
cssCodeSplit: false 不拆分CSS
cssInjectedByJsPlugin() 将CSS注入到JS中

2.5 打包脚本

javascript

arduino 复制代码
// scripts/build-component.js
import { execSync } from 'child_process'
import fs from 'fs'
import path from 'path'

// 获取命令行参数
const componentName = process.argv[2]

if (!componentName) {
  console.error('请指定组件名称')
  process.exit(1)
}

// 检查组件的入口文件是否存在
const entryFile = path.join('src/components', componentName, 'index.js')
if (!fs.existsSync(entryFile)) {
  console.error(`组件入口文件不存在: ${entryFile}`)
  process.exit(1)
}

// 设置环境变量
process.env.COMPONENT_NAME = componentName
process.env.COMPONENT_ENTRY = entryFile

// 执行打包
execSync('npx vite build --config vite.component.config.js', {
  stdio: 'inherit',
  env: process.env
})

2.6 package.json脚本配置

json

json 复制代码
{
  "scripts": {
    "component": "node scripts/build-component.js"
  }
}

现在,执行 npm run component abc 的完整流程如下:

  1. build-component.js 读取参数 abc
  2. 拼接入口路径 src/components/abc/index.js
  3. 通过环境变量传递给 Vite
  4. Vite 读取 vite.component.config.js 配置
  5. 打包输出 dist/abc/abc.umd.jsdist/abc/abc.es.js

打包产物会在全局暴露 window.abcComponent,消费者通过这个全局变量获取组件。

2.7 样式处理

Vite 默认会将 CSS 提取为独立文件。但我们的目标是一个JS文件包含所有内容,用户不需要关心CSS文件。

解决方案:使用 vite-plugin-css-injected-by-js

bash

csharp 复制代码
npm install --save-dev vite-plugin-css-injected-by-js

这个插件会在组件加载时自动创建 <style> 标签并插入到页面中。用户只需要引入JS文件,样式会自动生效。

三、组件加载:动态注册

3.1 整体流程

text

markdown 复制代码
1. 应用启动 → 2. 拉取配置 → 3. 获取组件列表 → 4. 动态加载JS → 5. 注册组件 → 6. 渲染页面

3.2 加载单个组件

javascript

javascript 复制代码
function loadComponent(name) {
  return new Promise((resolve) => {
    const script = document.createElement('script')
    script.src = `/libs/${name}/${name}.umd.js`
    
    script.onload = () => {
      // 打包时配置的 name 是 componentName + 'Component'
      const component = window[`${name}Component`]
      resolve(component ? component[name] : null)
    }
    
    script.onerror = () => {
      console.warn(`组件 ${name} 加载失败`)
      resolve(null)
    }
    
    document.head.appendChild(script)
  })
}

3.3 加载所有组件并启动应用

关键点:必须等所有组件加载完成后再挂载应用,否则会出现组件还没注册但页面已经渲染的情况。

javascript

javascript 复制代码
// main.js
import * as Vue from 'vue'
import { createApp } from 'vue'
import App from './App.vue'

// 将Vue挂载到全局(组件打包时external了Vue)
window.Vue = Vue

// 组件列表(实际项目中从配置接口获取)
const components = ['abc', 'newsList']

// 加载所有组件
const loaders = components.map(name => loadComponent(name))

// 等待全部加载完成
Promise.all(loaders).then(results => {
  const app = createApp(App)
  
  // 注册所有组件
  results.forEach((component, index) => {
    if (component) {
      app.component(components[index], component)
      console.log(`✅ 已注册: ${components[index]}`)
    }
  })
  
  app.mount('#app')
})

3.4 使用组件

组件注册后,就可以在任何Vue文件中直接使用了:

vue

xml 复制代码
<template>
  <div>
    <!-- 就像使用本地组件一样 -->
    <abc title="标题" content="内容" />
    <news-list :data="newsData" />
  </div>
</template>

<script>
export default {
  data() {
    return {
      newsData: ['新闻1', '新闻2']
    }
  }
}
</script>

四、遇到的问题与解决方案

4.1 问题一:CSS没有被打包进JS

现象 :配置了 cssCodeSplit: false,但Vite仍然生成了独立的CSS文件,组件显示时没有样式。

原因cssCodeSplit: false 只是不拆分CSS文件(即所有CSS合并到一个文件),但Vite仍然会输出独立的CSS文件。

解决方案 :使用 vite-plugin-css-injected-by-js 插件。

javascript

javascript 复制代码
import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js'

export default defineConfig({
  plugins: [
    vue(),
    cssInjectedByJsPlugin()
  ]
})

这样样式代码会被注入到JS中,组件加载时自动插入 <style> 标签到页面。

4.2 问题二:刷新页面组件消失

现象 :首次加载页面正常,刷新后组件变成空标签,如 <abc></abc>

原因 :动态加载脚本是异步操作,但 app.mount('#app') 是同步执行的。刷新时脚本还没加载完,应用就已经挂载了,导致组件未注册。

解决方案 :用 Promise.all 等待所有组件加载完成后再执行 app.mount()

javascript

scss 复制代码
// ❌ 错误写法
components.forEach(name => loadComponent(name))
const app = createApp(App)
app.mount('#app')

// ✅ 正确写法
Promise.all(components.map(name => loadComponent(name)))
  .then(() => {
    const app = createApp(App)
    app.mount('#app')
  })

4.3 问题三:ref报错

现象 :组件使用了 refreactive 等Vue API,报错:

text

javascript 复制代码
Uncaught TypeError: Cannot read properties of undefined (reading 'ref')

原因 :组件打包时配置了 external: ['vue'],这意味着组件代码中的 import { ref } from 'vue' 在运行时需要在全局找到 Vue 对象。但默认情况下,Vue应用没有把 Vue 暴露到 window 上。

解决方案:在应用入口将Vue挂载到全局。

javascript

javascript 复制代码
// main.js
import * as Vue from 'vue'

window.Vue = Vue

这是一个隐式契约:组件打包时约定宿主环境必须提供 window.Vue,消费者必须遵守这个约定。

五、总结

5.1 技术栈

技术 作用
Vite 打包工具,lib模式支持库打包
Vue3 组件框架
UMD 模块格式,兼容script标签加载
vite-plugin-css-injected-by-js 将CSS注入到JS中

5.2 核心机制

打包阶段

  • 通过命令行参数传递组件名
  • 环境变量动态配置Vite入口和输出
  • UMD格式将组件暴露到全局 window

加载阶段

  • 动态创建 script 标签加载JS文件
  • Promise.all 控制加载时序
  • 加载完成后注册为Vue全局组件

5.3 关键决策

决策 理由
外部化Vue 避免重复打包,减小文件体积
CSS注入JS 一个文件搞定所有,降低使用成本
UMD格式 script标签兼容性最好
Promise.all控制时序 解决刷新页面组件消失的问题

5.4 适用场景

  • 组件需要跨项目复用的场景
  • 组件需要运行时动态加载的场景
  • 内容需要灵活编排的场景
  • 微前端架构中的组件共享场景

5.5 项目地址

1,显示组件项目

2,编写打包单个组件

这两项目是个demo,第2个展示如何打包单个组件,打包之后把生成的文件复制到项目1的public/libs中自动注册以文件名为命名的组件,可以直接使用文件名作为标签使用


本文首发于掘金,如有疑问欢迎评论区交流。

相关推荐
光影少年2 小时前
原生DOM操作在React 中的注意事项
前端·javascript·react.js
用户900463370402 小时前
用Gemini搞定Vue报错和语法异常的问题
vue.js
禅思院4 小时前
前端部署“三层漏斗”完全指南:从CI/CD到自动回滚的工程化实战【开题】
前端·架构·前端框架
快乐肚皮4 小时前
深入理解Loop Engineering
前端·后端
风骏时光牛马5 小时前
VHDL十大经典基础功能设计实例代码合集
前端
小兔崽子去哪了5 小时前
Vue3 + Pinia 集成 IGV.js 实现 BAM 文件在线浏览
javascript·vue.js·后端
hunterandroid5 小时前
Notification 通知:从基础到渠道适配
前端
孟陬5 小时前
Claude Code 巧思 `Ctrl+S` 暂存键
前端·后端
PedroQue995 小时前
V1.6.1性能优化:高频路径提速与代码精简
前端·uni-app