组件库设计为什么需要Token体系
Vue3组件库的可定制性决定了它的适用范围。硬编码颜色值和间距的组件库只能满足一种视觉风格,换个项目就要逐个改组件源码。Design Token体系将视觉决策从组件代码中抽离出来,统一管理颜色、间距、字体、圆角等设计变量,主题切换和品牌定制只需替换Token值。
Token不是一个CSS变量列表,而是一个分层体系:全局Token定义基础值,别名Token赋予语义,组件Token绑定具体场景。三层结构确保修改一层不影响其他层。
CSS变量实现Token分层架构
用CSS自定义属性实现三层Token体系:
/* ===== 第一层:全局原始Token ===== */
:root {
/* 颜色 */
--color-blue-500: #3b82f6;
--color-blue-600: #2563eb;
--color-blue-700: #1d4ed8;
--color-red-500: #ef4444;
--color-green-500: #22c55e;
--color-gray-50: #f9fafb;
--color-gray-100: #f3f4f6;
--color-gray-900: #111827;
/* 间距(4px基数) */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-6: 24px;
--space-8: 32px;
/* 圆角 */
--radius-sm: 2px;
--radius-md: 4px;
--radius-lg: 8px;
--radius-full: 9999px;
/* 字体 */
--font-size-sm: 12px;
--font-size-base: 14px;
--font-size-lg: 16px;
--font-size-xl: 20px;
--line-height: 1.5;
}
/* ===== 第二层:语义别名Token ===== */
:root {
--color-primary: var(--color-blue-500);
--color-primary-hover: var(--color-blue-600);
--color-primary-active: var(--color-blue-700);
--color-danger: var(--color-red-500);
--color-success: var(--color-green-500);
--color-bg-primary: #ffffff;
--color-bg-secondary: var(--color-gray-50);
--color-text-primary: var(--color-gray-900);
--color-text-secondary: var(--color-gray-100);
--color-border: var(--color-gray-100);
--component-padding-x: var(--space-4);
--component-padding-y: var(--space-2);
}
/* ===== 第三层:组件级Token ===== */
:root {
--button-bg: var(--color-primary);
--button-bg-hover: var(--color-primary-hover);
--button-color: #ffffff;
--button-padding-x: var(--component-padding-x);
--button-padding-y: var(--component-padding-y);
--button-radius: var(--radius-md);
--button-font-size: var(--font-size-base);
--input-border-color: var(--color-border);
--input-focus-border: var(--color-primary);
--input-padding-x: var(--space-3);
--input-padding-y: var(--space-2);
--input-radius: var(--radius-md);
}
三层分离的好处:全局Token改一个色值,语义Token和组件Token自动联动;主题覆盖只需重写语义Token层,不需要碰组件代码。
Vue3组件设计模式
组件库的核心是Provide/Inject实现跨层级通信,配合Composable抽取可复用逻辑:
// composables/useTheme.ts
import { provide, inject, reactive, computed } from 'vue'
interface ThemeTokens {
colorPrimary: string
colorDanger: string
borderRadius: number
fontSize: number
}
const THEME_KEY = Symbol('theme')
export function provideTheme(userTokens: Partial<ThemeTokens> = {}) {
const defaultTokens: ThemeTokens = {
colorPrimary: 'var(--color-primary)',
colorDanger: 'var(--color-danger)',
borderRadius: 4,
fontSize: 14,
}
const tokens = reactive({ ...defaultTokens, ...userTokens })
provide(THEME_KEY, tokens)
return tokens
}
export function useTheme() {
const tokens = inject<ThemeTokens>(THEME_KEY)
if (!tokens) {
throw new Error('useTheme must be used inside a ThemeProvider')
}
return tokens
}
按钮组件实现:
<!-- Button.vue -->
<template>
<button
:class="[
'btn',
`btn--${type}`,
`btn--${size}`,
{ 'btn--block': block, 'btn--loading': loading }
]"
:disabled="disabled || loading"
@click="handleClick"
>
<span v-if="loading" class="btn__loading-icon">
<svg viewBox="0 0 24 24" class="spin">
<circle cx="12" cy="12" r="10" stroke="currentColor" fill="none" stroke-width="3" opacity="0.3"/>
<path d="M12 2a10 10 0 0 1 10 10" stroke="currentColor" fill="none" stroke-width="3" stroke-linecap="round"/>
</svg>
</span>
<slot />
</button>
</template>
<script setup lang="ts">
import { computed } from 'vue'
type ButtonType = 'primary' | 'default' | 'dashed' | 'danger'
type ButtonSize = 'small' | 'medium' | 'large'
const props = withDefaults(defineProps<{
type?: ButtonType
size?: ButtonSize
disabled?: boolean
loading?: boolean
block?: boolean
}>(), {
type: 'default',
size: 'medium',
disabled: false,
loading: false,
block: false,
})
const emit = defineEmits<{
click: [event: MouseEvent]
}>()
const handleClick = (e: MouseEvent) => {
if (props.disabled || props.loading) return
emit('click', e)
}
</script>
<style scoped>
.btn {
display: inline-flex;
align-items: center;
gap: var(--space-2);
padding: var(--button-padding-y) var(--button-padding-x);
font-size: var(--button-font-size);
border-radius: var(--button-radius);
border: 1px solid transparent;
cursor: pointer;
transition: all 0.2s ease;
background: transparent;
color: var(--color-text-primary);
}
.btn--primary {
background: var(--button-bg);
color: var(--button-color);
}
.btn--primary:hover {
background: var(--button-bg-hover);
}
.btn--danger {
background: var(--color-danger);
color: #fff;
}
.btn--small { padding: 4px 8px; font-size: 12px; }
.btn--large { padding: 8px 16px; font-size: 16px; }
.btn--block { display: flex; width: 100%; justify-content: center; }
.btn--loading { opacity: 0.7; cursor: wait; }
.spin { animation: rotate 1s linear infinite; width: 16px; height: 16px; }
@keyframes rotate { to { transform: rotate(360deg); } }
</style>
主题定制与运行时切换
组件库的主题定制分为编译时和运行时两种。编译时替换CSS变量值,运行时通过JavaScript动态修改document.documentElement的style:
// theme/ThemeProvider.vue
<template>
<div :style="cssVars">
<slot />
</div>
</template>
<script setup lang="ts">
import { computed, watch } from 'vue'
import { provideTheme } from '../composables/useTheme'
const props = defineProps<{
theme?: 'light' | 'dark' | Record<string, string>
}>()
const darkTokens = {
'--color-bg-primary': '#1a1a2e',
'--color-bg-secondary': '#16213e',
'--color-text-primary': '#e0e0e0',
'--color-text-secondary': '#a0a0a0',
'--color-border': '#2a2a4a',
'--color-primary': '#4f8ff7',
'--color-primary-hover': '#6ba3ff',
}
const cssVars = computed(() => {
if (typeof props.theme === 'string' && props.theme === 'dark') {
return darkTokens
}
if (typeof props.theme === 'object') {
return props.theme
}
return {}
})
// 监听变化并写入DOM
watch(cssVars, (vals) => {
const root = document.documentElement
Object.entries(vals).forEach(([key, val]) => {
root.style.setProperty(key, val)
})
}, { immediate: true })
provideTheme()
</script>
组件库构建与Tree-shaking优化
组件库的打包方式直接影响使用方的包体积。分别输出ESM和CJS格式,确保Tree-shaking生效:
// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
build: {
lib: {
entry: resolve(__dirname, 'index.ts'),
name: 'MyUI',
formats: ['es', 'cjs'],
fileName: (format) => `my-ui.${format}.js`
},
rollupOptions: {
external: ['vue'],
output: {
preserveModules: true,
preserveModulesRoot: 'src'
}
}
}
})
preserveModules: true是关键配置——保留源码模块结构而非打包成单文件,让消费方的bundler能按需引入组件,未引用的组件不会进入最终产物。
每个组件独立导出:
// components/index.ts
export { default as Button } from './Button.vue'
export { default as Input } from './Input.vue'
export { default as Select } from './Select.vue'
export { ThemeProvider } from './theme/ThemeProvider.vue'
export { useTheme } from './composables/useTheme'
组件库工程化远不止这些。从Token体系、组件设计、主题定制到构建优化,每个环节都需要在设计灵活性和开发效率之间取舍。核心原则:设计决策从组件代码中分离,用分层Token管理复杂度,用TypeScript保障类型安全,用Tree-shaking控制包体积。这套体系搭建完成后,后续新增组件和主题变体的边际成本会大幅降低。
原创文章,作者:小编,如若转载,请注明出处:https://www.yunthe.com/vue3-zu-jian-ku-she-ji-cong-token-ti-xi-dao-zhu-ti-ding-zhi/