一、背景与架构
1.1 业务场景
我参与过一个桌面端内容展示项目,技术栈是 Electron + Vue3。项目中有个核心需求:内容由多个独立开发的卡片组件构成,这些组件需要能够独立更新,而不需要重新打包整个桌面应用。
简单说就是:组件要能热更新,随时替换,不影响主程序。
基于这个需求,设计了一套四层架构:
text
yaml
┌─────────────────────────────────────────────────────────────┐
│ Layer 1: Electron桌面应用 │
│ 职责:展示内容,提供运行容器,渲染卡片 │
├─────────────────────────────────────────────────────────────┤
│ Layer 2: 内容编排系统 │
│ 职责:上传组件、编排布局、配置跳转、发布配置 │
├─────────────────────────────────────────────────────────────┤
│ Layer 3: 组件开发项目(本文重点) │
│ 职责:开发卡片组件 → 打包UMD → 生成ZIP → 上传组件仓库 │
├─────────────────────────────────────────────────────────────┤
│ Layer 4: 详情落地页 │
│ 职责:卡片点击后的跳转详情页面 │
└─────────────────────────────────────────────────────────────┘
这个架构的核心是 Layer 3:如何让每个组件独立开发、独立打包、独立部署。
1.2 核心问题
我们要解决三个问题:
- 如何打包? 一个项目里有多个组件,每个组件要能单独打包成JS文件
- 如何加载? 桌面应用在运行时动态加载这些JS文件并渲染
- 如何编排? 后台系统能够灵活配置哪些组件显示在什么位置
本文重点讲前两个问题。
二、组件打包: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 的完整流程如下:
build-component.js读取参数abc- 拼接入口路径
src/components/abc/index.js - 通过环境变量传递给 Vite
- Vite 读取
vite.component.config.js配置 - 打包输出
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
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报错
现象 :组件使用了 ref、reactive 等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 项目地址
这两项目是个demo,第2个展示如何打包单个组件,打包之后把生成的文件复制到项目1的public/libs中自动注册以文件名为命名的组件,可以直接使用文件名作为标签使用
本文首发于掘金,如有疑问欢迎评论区交流。