// Created At 2026-04-19// P3
// CSS · Vue · Design System · Engineering · Architecture

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 组件只需要做三件事:

  1. 组合正确的类名
  2. 处理交互逻辑(click、disabled、loading)
  3. 透传插槽
<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-busyaria-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 不仅是体积的缩减,更是思维的减负。

如果这篇文档对你有帮助,可以请我喝杯咖啡 ☕️
Ali PayWechat Pay
评论区
© 2026 MOONGATE