CSS 优先 + 组件薄封装:一个 25KB 组件库的极简实践
📚 系列导航
本系列共五篇,覆盖从设计令牌到 npm 发布的 Vue 3 组件库开发全流程:
设计令牌 vs 原子化 CSS:失败整合与融合之道(理念篇) —— 用 UnoCSS 映射设计令牌的失败经历,量化对比后得出设计令牌优先的结论。
CSS 优先 + 组件薄封装:一个 25KB 组件库的极简实践(架构篇) —— 四层 CSS 架构、极简 Vue 组件、Vite 多入口构建、体积预算验证,单组件极简实现。
Vue 3 简单组件开发实战:从 Button 组件看 API 设计(简单组件篇) —— Props 定义、变体系统、尺寸取舍、插槽设计、状态管理、无障碍支持及与主流 UI 库对比。
Vue 3 复杂组件开发实战:Select 与 Pagination 的 API 设计(复杂组件篇) —— 数据格式适配、类型回溯、可搜索/多选、ARIA 键盘导航、组合式函数抽离及 SSR 适配,揭示工业级细节。
从代码到 npm:Vue 3 组件库发布实战与避坑指南(发布篇) —— nrm 源管理、2FA 配置、WebAuthn 网络代理避坑、本地链接测试、自动化脚本及工业级发布检查清单。
回顾:第一篇文章的结论
在上一篇文章《design-tokens-vs-atomic-css》中,我分享了尝试用 UnoCSS 映射已有设计令牌的失败经历。核心结论是:
- 设计令牌是地基,原子化只是涂料
- 强行映射只会增加维护成本,得不偿失
- 对于已有成熟设计令牌的项目,原子化 CSS 不是必需品
那么,不用原子化 CSS,组件库应该怎么写?
这篇文章给出答案——以及 v1.5.0 在初版方案之上的工程进化。
最终架构:四层 CSS 架构
整个样式系统分为多个层级,职责清晰、层层依赖:
┌──────────────────────────────────────────────────────────────┐
│ 设计令牌层(自动生成) │
│ ┌─────────────────────┐ ┌──────────────────────────────┐ │
│ │ tokens/colors.css │ │ tokens/layout.css │ │
│ │ 颜色令牌(浅/深各 │ │ 间距/字体/动效/z-index 令牌 │ │
│ │ 68 个变量) │ │ 【组件库的核心 API 层】 │ │
│ └──────────┬──────────┘ └─────────────┬────────────────┘ │
│ └──────────────┬───────────┘ │
│ ↓ │
│ 组件样式层(手写) │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ components/ 各组件独立样式文件 │ │
│ │ (Button.css, Card.css, ... 共 20+ 文件) │ │
│ │ 引用 var(--ui-*) 令牌 │ │
│ └──────────────────────────┬─────────────────────────────┘ │
│ ↓ │
│ 工具层(手写) │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ utilities/ 极简语义工具类(颜色/文本/契约变量) │ │
│ └──────────────────────────┬─────────────────────────────┘ │
│ ↓ │
│ 入口层(手写) │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ index.css 导入令牌 + 组件样式 + 工具类 │ │
│ └────────────────────────────────────────────────────────┘ │
│ │
│ reset.css(可选,独立导出,不属于层级链) │
└──────────────────────────────────────────────────────────────┘
各文件/文件夹职责
| 文件/文件夹 | 职责 | 生成方式 |
|---|---|---|
tokens/colors.css |
浅色/深色模式颜色令牌(各 68 个变量) | 主题脚本自动生成 |
tokens/layout.css |
间距、字体、动效、断点、z-index 令牌 | 主题脚本自动生成 |
components/ |
各组件独立样式文件(Button.css 等) | 手写 |
utilities/ |
极简工具类 | 手写 |
reset.css |
可选全局重置(box-sizing),独立导出 | 手写,不属于层级链 |
index.css |
总入口,导入令牌 + 组件 + 工具 | 手写 |
设计令牌即 API
在这种模式下,colors.css 不仅仅是样式,它更像是组件库的 Configuration API。用户通过修改这些 CSS 变量(如 --ui-primary、--ui-spacing-md),就能在不触碰任何 JS 逻辑的情况下,完成整套 UI 的换肤。这是设计令牌最核心的价值——样式配置与代码逻辑彻底分离。
工程红利:多框架复用
这种解耦意味着,如果明天我想把项目从 Vue 迁移到 React 或 Svelte,我只需要重写一遍 ~50 行的逻辑组件,而那套核心样式可以原地复用,无需任何改动。这是”样式绑定逻辑”的原子化方案永远无法做到的。
极简组件:Button.vue 为例
有了全局 CSS 类,Vue 组件只需要做三件事:
- 组合正确的类名
- 处理交互逻辑(click、disabled、loading)
- 透传插槽
以 v1.5.0 的实际代码为例(已精简注释):
<script setup lang="ts">
import { useSlots, computed } from "vue"
import type { Component } from "vue"
import type { Size, AddonColor } from "../types/components"
defineOptions({ name: "Button", inheritAttrs: false })
type Variant = "filled" | "outline"
type ButtonType = "button" | "submit" | "reset"
interface Props {
label?: string
variant?: Variant
color?: AddonColor
size?: Size
type?: ButtonType
disabled?: boolean
loading?: boolean
showLabelWhileLoading?: boolean
loadingLabel?: string
block?: boolean
icon?: string | Component
}
const props = withDefaults(defineProps<Props>(), {
label: "",
variant: "filled",
color: "primary",
size: "sm",
type: "button",
disabled: false,
loading: false,
showLabelWhileLoading: false,
block: false,
})
const slots = useSlots()
const hasIconSlot = computed(() => !!slots.icon)
const hasLabel = computed(() => props.label !== "" || !!slots.default)
const emit = defineEmits<{ click: [event: MouseEvent] }>()
const handleClick = (event: MouseEvent) => {
if (props.disabled || props.loading) return
emit("click", event)
}
</script>
<template>
<button
v-bind="$attrs"
:type="type"
class="mg-button"
:class="[
`mg-button-${variant}-${color}`,
`mg-button-${size}`,
{ 'mg-button-block': block, 'mg-button-loading': loading },
]"
:disabled="disabled || loading"
@click="handleClick"
>
<template v-if="loading">
<span class="mg-button-loading-icon" />
<span v-if="showLabelWhileLoading" class="mg-button-label">
<slot name="loading-label">{{ loadingLabel || label }}</slot>
</span>
</template>
<template v-else>
<span v-if="hasIconSlot || icon" class="mg-button-icon">
<slot name="icon">
<component :is="icon" v-if="typeof icon !== 'string'" />
<span v-else-if="icon">{{ icon }}</span>
</slot>
</span>
<span v-if="hasLabel" class="mg-button-label">
<slot>{{ label }}</slot>
</span>
</template>
</button>
</template>
组件特点:
- 无
<style>块,样式全部来自全局 CSS - 只有 ~110 行代码,极简清晰
- 类型安全(TypeScript),共享类型从
src/types/components.ts导入 - 支持 11 种 props + 3 种插槽,覆盖日常场景
v-bind="$attrs"透传原生属性
构建架构:Vite 多入口 + 独立导出
初版组件库只有一个主入口。但随着组件增多,需要支持按需引入——用户只想用 Button 时不应加载全部组件。
v1.5.0 采用 Vite library mode 的多入口构建:
// vite.config.ts(简化)
import { componentNames } from "./scripts/component-list.js"
// 每个组件独立入口(src/exports/<Name>.ts → dist/<kebab>.js)
const componentEntries = Object.fromEntries(
componentNames.map((name) => {
const kebab = name.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase()
return [`${kebab}`, resolve(__dirname, `src/exports/${name}.ts`)]
}),
)
export default defineConfig({
build: {
lib: {
entry: {
index: resolve(__dirname, "src/index.ts"),
...componentEntries, // 27 个组件 + 主入口
},
formats: ["es"], // 纯 ES Module,无 CJS
},
rollupOptions: {
external: ["vue"], // Vue 作为 peerDependency
output: {
assetFileNames: "style.css", // CSS 统一输出
},
},
cssCodeSplit: false,
},
})
对应的 package.json 导出映射:
{
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"default": "./dist/index.js"
},
"./style.css": "./dist/style.css",
"./reset.css": "./dist/reset.css",
"./button": {
"types": "./dist/exports/Button.d.ts",
"import": "./dist/button.js"
},
"./badge": { "...": "..." }
}
}
用户既可以使用全量引入 import { Button } from 'moongate-vue',也可以按需 import Button from 'moongate-vue/button'。
体积控制:25KB 预算 + 自动化验证
体积是组件库的生命线。为了不让体积悄悄失控,我在 pnpm build 后自动执行 scripts/tree-shake-check.js:
- 使用 Vite JS API 将
src/index.ts打包为单个 ESM bundle(minify) - 统计 JS + CSS 的 gzip 体积
- 如果超过 25KB 预算,build 会在 CI 中断言失败
# 构建后自动输出(简化示例)
📦 完整库 Min+Gzip:
✅ 完整库: 32.50 KB (gzipped 24.80 KB)
├─ JS: 22.00 KB (gzipped 9.20 KB)
└─ CSS: 10.50 KB (gzipped 5.60 KB)
✅ 完整库 Min+Gzip 在 25KB 预算内
这个”预算”与文化有关:我用「25KB gzip 完整组件库」作为设计挑战来对抗组件库普遍臃肿的现状。与主流相比,Element Plus 完整引入约 100KB+ gzip,Naive UI 约 120KB+。25KB 是一个数量级的差距。
为什么能这么小?
- 零运行时依赖:peerDependencies 只有
vue,没有 lodash、async-validator 等 - CSS 变量代替 JS 主题系统:主题切换不需要 JS 集成
- 组件薄封装:逻辑极简,组合式函数复用
- 极少的运行时 JS:组合式函数复用 + 无运行时依赖
微工具类:极简语义工具类
utilities/ 中保留了一套极简的语义工具类,直接引用设计令牌:
/* 语义颜色工具类 */
.text-primary {
color: var(--ui-primary);
}
.text-muted {
color: var(--ui-text-muted);
}
.bg-primary {
background-color: var(--ui-primary);
}
.bg-muted {
background-color: var(--ui-bg-muted);
}
/* 核心契约 */
:root {
--ui-radius: 0px;
--ui-glow-alpha: var(--ui-physics-glow-alpha-dawn);
}
特点:
- 只有最常用的 ~20 个类,按需添加
- 数值绑定设计令牌(
var(--ui-*)),保持主题一致 - 通过
--ui-radius/--ui-glow-alpha契约变量为全局提供样式锚点 - 附带
.mg-lunar-halo(月晕阴影效果)等设计系统特有的工具类 - 布局需求在组件内部通过 scoped 样式解决,工具层不承担布局职责
非侵入式样式
组件库在 v1.5.0 明确了样式非侵入原则:
style.css只包含组件样式,不会重置你的全局样式- 可选引入
moongate-vue/reset.css统一box-sizing: border-box
// 只引入组件样式
import "moongate-vue/style.css"
// 或额外引入全局重置(可选)
import "moongate-vue/reset.css"
体积与维护性分析
体积数据(v1.5.0 实测口径)
| 类型 | 原始大小 | Gzip 压缩后 |
|---|---|---|
| CSS(令牌 + 组件样式) | ~10.5 KB | ~5.6 KB |
| JS(完整组件库) | ~22 KB | ~9.2 KB |
| 总计 | ~32.5 KB | ~24.8 KB |
(实际构建产物以 pnpm build 后的 size-report.json 为准)
维护性对比
| 维度 | 原子化方案(UnoCSS 映射) | 本方案(CSS 变量 + 薄封装) |
|---|---|---|
| CSS 体积 | 按需生成,极小 | ~5.6 KB (gzip) |
| 维护成本 | 需同步映射配置 | 直接改 CSS |
| 心智负担 | 记忆数百个类名及其映射逻辑 | 只需 ~20 个组件类名 |
| 可读性 | 模板臃肿,难以一眼看出组件层级 | 模板极简,类名语义化清晰 |
| 首屏渲染 | 需等待 JS 注入样式 | 纯 CSS,浏览器原生渲染 |
| 运行环境 | 需要 Node + PostCSS/Vite 插件 + 配置文件 | 只需浏览器支持 CSS Variables(98%+ 环境) |
| 多框架复用 | 不可能 | 样式文件可跨框架 |
| 按需引入 | - | 27 个独立导出入口(v1.5.0) |
| 体积预算 | - | 25KB gzip 强制验证(CI 中断) |
总结
适用场景
- ✅ 已有成熟设计令牌的项目
- ✅ 追求极致体积(gzip < 25KB)的组件库
- ✅ 需要按需引入的构建场景(Vite multi-entry)
- ✅ 不希望引入复杂工具链的场景
不适用场景
- ❌ 从零开始、没有设计令牌的项目
- ❌ 需要动态主题切换的大型设计系统(需 JS 主题引擎)
- ❌ 需要大量业务组件(DatePicker、Tree 等)
核心收获
初版的 10KB 承诺在 v1.5.0 经过多轮功能迭代(全局 i18n 文案、可搜索 Select、多选、无障碍增强、450 测试),依然保持 25KB gzip 预算内——这不是偶然,而是通过架构纪律(零依赖 + 薄封装)和自动化(tree-shake-check.js 体积门禁)共同守住的。
这 25KB 不仅是体积的缩减,更是思维的减负。

