Vue3组件库设计:从Token体系到主题定制的工程化实践

组件库设计为什么需要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/

(0)
小编小编
上一篇 14小时前
下一篇 14小时前

相关推荐