CSS 优先 + 组件薄封装:一个 10KB 组件库的极简实践
从 UnoCSS 回归原生 CSS,一套设计令牌驱动的组件库方案
回顾:第一篇文章的结论
在上一篇文章《design-tokens-vs-atomic-css》中,我分享了尝试用 UnoCSS 映射已有设计令牌的失败经历。核心结论是:
- 设计令牌是地基,原子化只是涂料
- 强行映射只会增加维护成本,得不偿失
- 对于已有成熟设计令牌的项目,原子化 CSS 不是必需品
那么,不用原子化 CSS,组件库应该怎么写?
这篇文章给出答案。
最终架构:四层 CSS 文件
整个样式系统分为四个文件,职责清晰、层层依赖:
┌─────────────────────────────────────────────────────────────┐
│ 设计令牌层(自动生成) │
│ ┌─────────────────────┐ ┌─────────────────────────────┐ │
│ │ colors.css │ │ layout.css │ │
│ │ 颜色令牌(40+变量) │ │ 布局令牌(间距/字体/动效) │ │
│ │ 【组件库的API层】 │ │ │ │
│ └──────────┬──────────┘ └─────────────┬───────────────┘ │
│ └──────────────┬──────────────┘ │
│ ↓ │
│ 组件样式层(手写) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ components.css │ │
│ │ 组件类(.mg-button, .mg-card, .mg-input) │ │
│ │ 引用 var(--ui-*) 令牌 │ │
│ └──────────────────────────┬──────────────────────────┘ │
│ ↓ │
│ 入口层(手写) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ main.css │ │
│ │ 导入三个文件 + 全局重置 + 极简工具类 │ │
│ └─────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
各文件职责与行数
| 文件 | 职责 | 生成方式 | 行数 |
|---|---|---|---|
colors.css | 浅色/深色模式颜色令牌 | 主题插件自动生成 | ~120 |
layout.css | 间距、字体、动效等布局令牌 | 主题插件自动生成 | ~50 |
components.css | 所有组件的样式类 | 手写 | ~250 |
main.css | 入口文件 + 全局重置 + 工具类 | 手写 | ~150 |
设计令牌即 API
在这种模式下,colors.css 不仅仅是样式,它更像是组件库的 Configuration API。用户通过修改这些 CSS 变量(如 --ui-primary、--ui-spacing-md),就能在不触碰任何 JS 逻辑的情况下,完成整套 UI 的换肤。这是设计令牌最核心的价值——样式配置与代码逻辑彻底分离。
工程红利:多框架复用
这种解耦意味着,如果明天我想把项目从 Vue 迁移到 React 或 Svelte,我只需要重写一遍 ~50 行的逻辑组件,而那套核心样式(~500 行 CSS)可以原地复用,无需任何改动。这是“样式绑定逻辑”的原子化方案永远无法做到的。
极简组件:Button.vue 为例
有了全局 CSS 类,Vue 组件只需要做三件事:
- 组合正确的类名
- 处理交互逻辑(click、disabled、loading)
- 透传插槽
<script setup lang="ts">
import { useSlots, computed } from "vue";
const slots = useSlots();
const hasIconSlot = computed(() => !!slots.icon);
type Variant = "filled" | "outline";
type Color = "primary" | "success" | "warning" | "error";
type Size = "xs" | "sm" | "md" | "lg";
interface Props {
label?: string;
variant?: Variant;
color?: Color;
size?: Size;
disabled?: boolean;
loading?: boolean;
block?: boolean;
icon?: string;
}
const props = withDefaults(defineProps<Props>(), {
label: "",
variant: "filled",
color: "primary",
size: "md",
disabled: false,
loading: false,
block: false,
icon: "",
});
const emit = defineEmits<{ click: [event: MouseEvent] }>();
const handleClick = (event: MouseEvent) => {
if (props.disabled || props.loading) return;
emit("click", event);
};
</script>
<template>
<button
class="mg-button"
:class="[
`mg-button-${variant}-${color}`,
`mg-button-${size}`,
{ 'mg-button-block': block, 'mg-button-loading': loading },
]"
:disabled="disabled || loading"
:aria-busy="loading"
:aria-disabled="disabled || loading"
@click="handleClick"
>
<span v-if="loading" class="mg-button-loading-icon" />
<span v-if="!loading && (hasIconSlot || icon)" class="mg-button-icon">
<slot name="icon">
<span v-if="icon">{{ icon }}</span>
</slot>
</span>
<span class="mg-button-label">
<slot>{{ label }}</slot>
</span>
</button>
</template>
组件特点:
- 无
<style>块,样式全部来自全局 CSS - 只有 ~50 行代码,极简清晰
- 类型安全(TypeScript)
- 支持无障碍属性(
aria-busy、aria-disabled),极简并不代表简陋 - 支持 8 种 props + 2 种插槽,覆盖日常使用
动态样式:CSS 变量局部覆盖
对于 Button 这种状态极多的组件,除了预设的 filled-primary 等组合类,还可以利用 CSS 变量局部覆盖的技巧:
<!-- 用户自定义颜色,无需修改组件源码 -->
<Button :style="{ '--btn-bg': '#ff6b6b' }" class="custom-button">
自定义颜色
</Button>
/* components.css 中增加一行支持 */
.mg-button {
background-color: var(--btn-bg, var(--ui-primary));
}
这样可以让组件更灵活,甚至处理用户传入的任意颜色,而不局限于预设的 4 种。
使用示例
<template>
<!-- 基础用法 -->
<Button label="默认按钮" />
<!-- 带图标 -->
<Button icon="🔍" label="搜索" />
<!-- 加载状态(自动禁用点击) -->
<Button loading label="提交中" />
<!-- 块级按钮 -->
<Button block label="全宽按钮" />
<!-- 完整组合 -->
<Button variant="outline" color="error" size="lg" block :loading="isDeleting">
删除项目
</Button>
</template>
体积与维护性分析
体积数据
| 类型 | 原始大小 | Gzip 压缩后 |
|---|---|---|
| CSS(4 个文件) | ~15 KB | ~4 KB |
| JS(Button 组件) | ~2 KB | ~0.8 KB |
| 其他组件(按需) | ~10 KB | ~3 KB |
| 总计 | ~27 KB | ~8 KB |
维护性对比
| 维度 | 原子化方案(UnoCSS 映射) | 本方案 |
|---|---|---|
| CSS 体积 | 按需生成,极小 | ~4 KB (gzip) |
| 维护成本 | 需同步映射配置 | 直接改 CSS |
| 心智负担 | 记忆数百个类名及其映射逻辑 | 只需 ~20 个组件类名 |
| 可读性 | 模板臃肿,难以一眼看出组件层级 | 模板极简,类名语义化清晰 |
| 首屏渲染 | 需等待 JS 注入样式 | 纯 CSS,浏览器原生渲染 |
| 运行环境 | 需要 Node + PostCSS/Vite 插件 + 配置文件 | 只需浏览器支持 CSS Variables(全球 98%+ 环境) |
| 多框架复用 | 不可能 | 样式文件可跨框架 |
微工具类:极简原子化
虽然放弃了完整的原子化框架,但常用的布局工具类仍然很有价值。我在 main.css 中手写了一套极简工具类:
/* 布局 */
.flex {
display: flex;
}
.inline-flex {
display: inline-flex;
}
.items-center {
align-items: center;
}
.justify-between {
justify-content: space-between;
}
/* 间距(基于设计令牌) */
.gap-2 {
gap: var(--ui-spacing-sm);
}
.p-4 {
padding: var(--ui-spacing-lg);
}
.mx-auto {
width: fit-content;
margin-left: auto;
margin-right: auto;
}
/* 尺寸 */
.w-full {
width: 100%;
}
/* 响应式补丁(5 行解决 80% 移动端适配) */
@media (max-width: 768px) {
.sm-hidden {
display: none !important;
}
.sm-flex-col {
flex-direction: column !important;
}
}
特点:
- 只有最常用的 ~30 个类,按需添加
- 数值绑定设计令牌(
var(--ui-spacing-*)),保持主题一致 - 响应式补丁仅 5 行,零依赖
命名自由度
由于这些工具类是手写的,你可以根据自己的项目偏好自由命名。如果你讨厌 Tailwind 风格,甚至可以叫它 .mobile-stack 而不是 .sm-flex-col。这种命名自由度,也是原生 CSS 方案相较于原子化框架的魅力之一。
总结
适用场景
- ✅ 已有成熟设计令牌的项目
- ✅ 个人博客、小型网站
- ✅ 追求长期可维护性的组件库
- ✅ 不希望引入复杂工具链的场景
不适用场景
- ❌ 从零开始、没有设计令牌的项目
- ❌ 需要动态主题切换的大型设计系统
- ❌ 需要极致定制化(如每个组件都要求不同样式)
核心收获
很多时候,我们引入复杂的工具链是为了缓解“写 CSS”的焦虑。但当你真正拥有一套由设计令牌驱动的底层系统时,你会发现 CSS 不再是杂乱无章的补丁,而是一场精确、优雅的逻辑拆解。
这套方案的总代码量不到 500 行(CSS + 组件),打包后不到 10KB,却完整支撑了一个组件库的核心功能。
这 10KB 不仅是体积的缩减,更是思维的减负。
如果这篇文档对你有帮助,可以请我喝杯咖啡 ☕️

