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

63 阅读6分钟

一、背景与架构

1.1 业务场景

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

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

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

text

┌─────────────────────────────────────────────────────────────┐
  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

npm run component abc        # 打包abc组件
npm run component newsList    # 打包newsList组件
npm run component chart       # 打包chart组件

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

2.2 项目结构

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

text

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

// 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

// 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

// 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

{
  "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.js 和 dist/abc/abc.es.js

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

2.7 样式处理

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

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

bash

npm install --save-dev vite-plugin-css-injected-by-js

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

三、组件加载:动态注册

3.1 整体流程

text

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

3.2 加载单个组件

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

// 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

<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

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

// ❌ 错误写法
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

Uncaught TypeError: Cannot read properties of undefined (reading 'ref')

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

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

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中自动注册以文件名为命名的组件,可以直接使用文件名作为标签使用


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