Element Plus 深度定制指南:打造专属品牌的组件库

HTMLPAGE 团队
14 分钟阅读

全面讲解 Element Plus 的主题定制、组件二次封装、样式覆盖等高级技巧,帮助你构建符合品牌风格的 Vue 3 应用。

#Element Plus #Vue3 #主题定制 #组件库 #CSS 变量

Element Plus 深度定制指南:打造专属品牌的组件库

Element Plus 是 Vue 3 生态中最流行的组件库之一,但默认样式难以满足品牌差异化需求。本文将深入讲解如何从主题、组件、样式三个层面定制 Element Plus,打造独一无二的产品界面。

主题定制基础

理解 Element Plus 的样式架构

// Element Plus 的样式层次
@use 'element-plus/theme-chalk/src/index.scss';

// 结构:
// ├── common/var.scss     (CSS 变量定义)
// ├── mixins/             (SCSS mixins)
// ├── base.scss          (基础样式)
// └── components/        (组件样式)
//     ├── button.scss
//     ├── input.scss
//     └── ...

CSS 变量定制(推荐方式)

Element Plus 2.x 使用 CSS 变量,这是最简单的定制方式:

// styles/element-variables.scss
:root {
  // 品牌主色
  --el-color-primary: #6366f1;
  --el-color-primary-light-3: #818cf8;
  --el-color-primary-light-5: #a5b4fc;
  --el-color-primary-light-7: #c7d2fe;
  --el-color-primary-light-8: #e0e7ff;
  --el-color-primary-light-9: #eef2ff;
  --el-color-primary-dark-2: #4f46e5;
  
  // 功能色
  --el-color-success: #10b981;
  --el-color-warning: #f59e0b;
  --el-color-danger: #ef4444;
  --el-color-info: #6b7280;
  
  // 文字颜色
  --el-text-color-primary: #111827;
  --el-text-color-regular: #374151;
  --el-text-color-secondary: #6b7280;
  --el-text-color-placeholder: #9ca3af;
  --el-text-color-disabled: #d1d5db;
  
  // 边框
  --el-border-color: #e5e7eb;
  --el-border-color-light: #f3f4f6;
  --el-border-color-lighter: #f9fafb;
  --el-border-color-dark: #d1d5db;
  
  // 背景
  --el-bg-color: #ffffff;
  --el-bg-color-page: #f9fafb;
  --el-bg-color-overlay: #ffffff;
  
  // 圆角
  --el-border-radius-base: 8px;
  --el-border-radius-small: 4px;
  --el-border-radius-round: 20px;
  --el-border-radius-circle: 100%;
  
  // 字体
  --el-font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
  --el-font-size-extra-large: 20px;
  --el-font-size-large: 18px;
  --el-font-size-medium: 16px;
  --el-font-size-base: 14px;
  --el-font-size-small: 13px;
  --el-font-size-extra-small: 12px;
  
  // 组件尺寸
  --el-component-size-large: 40px;
  --el-component-size: 32px;
  --el-component-size-small: 24px;
}

SCSS 变量定制(编译时)

如果需要更深度的定制,使用 SCSS 变量覆盖:

// styles/element-theme.scss
@forward 'element-plus/theme-chalk/src/common/var.scss' with (
  $colors: (
    'primary': (
      'base': #6366f1,
    ),
    'success': (
      'base': #10b981,
    ),
    'warning': (
      'base': #f59e0b,
    ),
    'danger': (
      'base': #ef4444,
    ),
    'info': (
      'base': #6b7280,
    ),
  ),
  
  $text-color: (
    'primary': #111827,
    'regular': #374151,
    'secondary': #6b7280,
    'placeholder': #9ca3af,
    'disabled': #d1d5db,
  ),
  
  $border-color: (
    '': #e5e7eb,
    'light': #f3f4f6,
    'lighter': #f9fafb,
    'dark': #d1d5db,
  ),
  
  $fill-color: (
    '': #f3f4f6,
    'light': #f9fafb,
    'lighter': #fafafa,
    'dark': #e5e7eb,
  ),
  
  $border-radius: (
    'base': 8px,
    'small': 4px,
    'round': 20px,
    'circle': 100%,
  ),
  
  $font-size: (
    'extra-large': 20px,
    'large': 18px,
    'medium': 16px,
    'base': 14px,
    'small': 13px,
    'extra-small': 12px,
  ),
);

@use 'element-plus/theme-chalk/src/index.scss' as *;

Vite 配置

// vite.config.ts
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import ElementPlus from 'unplugin-element-plus/vite';

export default defineConfig({
  plugins: [
    vue(),
    ElementPlus({
      useSource: true, // 使用源码,支持 SCSS 变量
    }),
  ],
  css: {
    preprocessorOptions: {
      scss: {
        additionalData: `@use "@/styles/element-theme.scss" as *;`,
      },
    },
  },
});

暗色模式支持

方案一:CSS 变量切换

// styles/element-dark.scss
html.dark {
  // 覆盖暗色模式变量
  --el-color-primary: #818cf8;
  --el-color-primary-light-3: #6366f1;
  --el-color-primary-light-5: #4f46e5;
  
  --el-text-color-primary: #f9fafb;
  --el-text-color-regular: #e5e7eb;
  --el-text-color-secondary: #9ca3af;
  --el-text-color-placeholder: #6b7280;
  
  --el-bg-color: #111827;
  --el-bg-color-page: #0f172a;
  --el-bg-color-overlay: #1f2937;
  
  --el-border-color: #374151;
  --el-border-color-light: #1f2937;
  --el-border-color-lighter: #111827;
  
  --el-fill-color: #1f2937;
  --el-fill-color-light: #374151;
  --el-fill-color-lighter: #4b5563;
  
  // 遮罩层
  --el-overlay-color: rgba(0, 0, 0, 0.7);
  --el-overlay-color-light: rgba(0, 0, 0, 0.5);
  --el-overlay-color-lighter: rgba(0, 0, 0, 0.3);
  
  // 阴影
  --el-box-shadow: 0 12px 32px rgba(0, 0, 0, 0.36);
  --el-box-shadow-light: 0 0 12px rgba(0, 0, 0, 0.24);
  --el-box-shadow-lighter: 0 0 6px rgba(0, 0, 0, 0.12);
}

方案二:使用 Element Plus 内置暗色模式

// main.ts
import 'element-plus/theme-chalk/dark/css-vars.css';

// 切换暗色模式
function toggleDark() {
  document.documentElement.classList.toggle('dark');
}

配合 VueUse

<script setup>
import { useDark, useToggle } from '@vueuse/core';

const isDark = useDark();
const toggleDark = useToggle(isDark);
</script>

<template>
  <el-switch
    v-model="isDark"
    @change="toggleDark"
    active-text="暗色"
    inactive-text="亮色"
  />
</template>

组件二次封装

统一封装 Button

<!-- components/base/AppButton.vue -->
<template>
  <el-button
    v-bind="$attrs"
    :type="variant"
    :size="size"
    :loading="loading"
    :disabled="disabled"
    :class="customClass"
  >
    <template v-if="$slots.icon" #icon>
      <slot name="icon" />
    </template>
    <slot />
  </el-button>
</template>

<script setup lang="ts">
import { computed } from 'vue';

interface Props {
  variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger';
  size?: 'small' | 'default' | 'large';
  loading?: boolean;
  disabled?: boolean;
}

const props = withDefaults(defineProps<Props>(), {
  variant: 'primary',
  size: 'default',
  loading: false,
  disabled: false,
});

// 映射到 Element Plus 的 type
const variantMap = {
  primary: 'primary',
  secondary: 'info',
  outline: 'primary',
  ghost: 'info',
  danger: 'danger',
};

const variant = computed(() => variantMap[props.variant] || 'primary');

const customClass = computed(() => ({
  'app-button': true,
  'app-button--outline': props.variant === 'outline',
  'app-button--ghost': props.variant === 'ghost',
}));
</script>

<style scoped lang="scss">
.app-button {
  &--outline {
    background: transparent;
    border-color: var(--el-color-primary);
    color: var(--el-color-primary);
    
    &:hover {
      background: var(--el-color-primary-light-9);
    }
  }
  
  &--ghost {
    background: transparent;
    border-color: transparent;
    
    &:hover {
      background: var(--el-fill-color-light);
    }
  }
}
</style>

增强 Table 组件

<!-- components/base/AppTable.vue -->
<template>
  <div class="app-table">
    <!-- 工具栏 -->
    <div v-if="$slots.toolbar || showRefresh" class="app-table__toolbar">
      <slot name="toolbar" />
      <div class="app-table__actions">
        <el-button
          v-if="showRefresh"
          :icon="Refresh"
          circle
          @click="handleRefresh"
        />
        <el-dropdown v-if="showColumnSettings" @command="handleColumnCommand">
          <el-button :icon="Setting" circle />
          <template #dropdown>
            <el-dropdown-menu>
              <el-dropdown-item
                v-for="col in columns"
                :key="col.prop"
                :command="col.prop"
              >
                <el-checkbox
                  :model-value="!hiddenColumns.includes(col.prop)"
                  @change="toggleColumn(col.prop)"
                >
                  {{ col.label }}
                </el-checkbox>
              </el-dropdown-item>
            </el-dropdown-menu>
          </template>
        </el-dropdown>
      </div>
    </div>
    
    <!-- 表格 -->
    <el-table
      ref="tableRef"
      v-loading="loading"
      :data="data"
      :stripe="stripe"
      :border="border"
      :height="height"
      @selection-change="handleSelectionChange"
      @sort-change="handleSortChange"
    >
      <el-table-column
        v-if="showSelection"
        type="selection"
        width="50"
        fixed
      />
      
      <el-table-column
        v-if="showIndex"
        type="index"
        width="60"
        label="#"
      />
      
      <template v-for="col in visibleColumns" :key="col.prop">
        <el-table-column
          v-bind="col"
          :sortable="col.sortable ? 'custom' : false"
        >
          <template v-if="$slots[col.prop]" #default="scope">
            <slot :name="col.prop" v-bind="scope" />
          </template>
        </el-table-column>
      </template>
      
      <el-table-column
        v-if="$slots.actions"
        label="操作"
        fixed="right"
        :width="actionsWidth"
      >
        <template #default="scope">
          <slot name="actions" v-bind="scope" />
        </template>
      </el-table-column>
    </el-table>
    
    <!-- 分页 -->
    <div v-if="showPagination" class="app-table__pagination">
      <el-pagination
        v-model:current-page="currentPage"
        v-model:page-size="pageSize"
        :page-sizes="pageSizes"
        :total="total"
        :layout="paginationLayout"
        @current-change="handlePageChange"
        @size-change="handleSizeChange"
      />
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, computed } from 'vue';
import { Refresh, Setting } from '@element-plus/icons-vue';

interface Column {
  prop: string;
  label: string;
  width?: number | string;
  minWidth?: number | string;
  sortable?: boolean;
  fixed?: boolean | 'left' | 'right';
  align?: 'left' | 'center' | 'right';
}

interface Props {
  data: any[];
  columns: Column[];
  loading?: boolean;
  stripe?: boolean;
  border?: boolean;
  height?: string | number;
  showSelection?: boolean;
  showIndex?: boolean;
  showPagination?: boolean;
  showRefresh?: boolean;
  showColumnSettings?: boolean;
  total?: number;
  pageSizes?: number[];
  actionsWidth?: number | string;
}

const props = withDefaults(defineProps<Props>(), {
  loading: false,
  stripe: true,
  border: false,
  showSelection: false,
  showIndex: false,
  showPagination: true,
  showRefresh: true,
  showColumnSettings: true,
  total: 0,
  pageSizes: () => [10, 20, 50, 100],
  actionsWidth: 150,
});

const emit = defineEmits<{
  refresh: [];
  selectionChange: [selection: any[]];
  sortChange: [sort: { prop: string; order: string }];
  pageChange: [page: number];
  sizeChange: [size: number];
}>();

const tableRef = ref();
const currentPage = ref(1);
const pageSize = ref(props.pageSizes[0]);
const hiddenColumns = ref<string[]>([]);

const visibleColumns = computed(() =>
  props.columns.filter(col => !hiddenColumns.value.includes(col.prop))
);

const paginationLayout = computed(() =>
  'total, sizes, prev, pager, next, jumper'
);

function toggleColumn(prop: string) {
  const index = hiddenColumns.value.indexOf(prop);
  if (index > -1) {
    hiddenColumns.value.splice(index, 1);
  } else {
    hiddenColumns.value.push(prop);
  }
}

function handleRefresh() {
  emit('refresh');
}

function handleSelectionChange(selection: any[]) {
  emit('selectionChange', selection);
}

function handleSortChange({ prop, order }: { prop: string; order: string }) {
  emit('sortChange', { prop, order });
}

function handlePageChange(page: number) {
  emit('pageChange', page);
}

function handleSizeChange(size: number) {
  emit('sizeChange', size);
}

// 暴露方法
defineExpose({
  clearSelection: () => tableRef.value?.clearSelection(),
  toggleAllSelection: () => tableRef.value?.toggleAllSelection(),
});
</script>

<style scoped lang="scss">
.app-table {
  &__toolbar {
    display: flex;
    justify-content: space-between;
    align-items: center;
    margin-bottom: 16px;
  }
  
  &__actions {
    display: flex;
    gap: 8px;
  }
  
  &__pagination {
    display: flex;
    justify-content: flex-end;
    margin-top: 16px;
    padding-top: 16px;
    border-top: 1px solid var(--el-border-color-lighter);
  }
}
</style>

表单封装

<!-- components/base/AppForm.vue -->
<template>
  <el-form
    ref="formRef"
    :model="model"
    :rules="rules"
    :label-width="labelWidth"
    :label-position="labelPosition"
    :size="size"
    @submit.prevent="handleSubmit"
  >
    <el-row :gutter="gutter">
      <el-col
        v-for="field in fields"
        :key="field.prop"
        :span="field.span || 24"
      >
        <el-form-item
          :label="field.label"
          :prop="field.prop"
          :required="field.required"
        >
          <!-- 动态渲染不同类型的表单项 -->
          <component
            :is="getComponent(field.type)"
            v-model="model[field.prop]"
            v-bind="field.props"
            :placeholder="field.placeholder || `请输入${field.label}`"
            :disabled="field.disabled || disabled"
            style="width: 100%"
          >
            <!-- Select 选项 -->
            <template v-if="field.type === 'select'">
              <el-option
                v-for="opt in field.options"
                :key="opt.value"
                :label="opt.label"
                :value="opt.value"
              />
            </template>
            
            <!-- Radio 选项 -->
            <template v-if="field.type === 'radio'">
              <el-radio
                v-for="opt in field.options"
                :key="opt.value"
                :label="opt.value"
              >
                {{ opt.label }}
              </el-radio>
            </template>
            
            <!-- Checkbox 选项 -->
            <template v-if="field.type === 'checkbox'">
              <el-checkbox
                v-for="opt in field.options"
                :key="opt.value"
                :label="opt.value"
              >
                {{ opt.label }}
              </el-checkbox>
            </template>
          </component>
        </el-form-item>
      </el-col>
    </el-row>
    
    <!-- 提交按钮 -->
    <el-form-item v-if="showActions" class="app-form__actions">
      <slot name="actions">
        <el-button @click="handleReset">重置</el-button>
        <el-button type="primary" native-type="submit" :loading="loading">
          {{ submitText }}
        </el-button>
      </slot>
    </el-form-item>
  </el-form>
</template>

<script setup lang="ts">
import { ref, computed } from 'vue';

interface FormField {
  prop: string;
  label: string;
  type: 'input' | 'textarea' | 'select' | 'radio' | 'checkbox' | 'date' | 'datetime' | 'number' | 'switch';
  span?: number;
  placeholder?: string;
  required?: boolean;
  disabled?: boolean;
  options?: { label: string; value: any }[];
  props?: Record<string, any>;
}

interface Props {
  model: Record<string, any>;
  fields: FormField[];
  rules?: Record<string, any>;
  labelWidth?: string;
  labelPosition?: 'left' | 'right' | 'top';
  size?: 'small' | 'default' | 'large';
  gutter?: number;
  disabled?: boolean;
  loading?: boolean;
  showActions?: boolean;
  submitText?: string;
}

const props = withDefaults(defineProps<Props>(), {
  labelWidth: '100px',
  labelPosition: 'right',
  size: 'default',
  gutter: 20,
  disabled: false,
  loading: false,
  showActions: true,
  submitText: '提交',
});

const emit = defineEmits<{
  submit: [model: Record<string, any>];
  reset: [];
}>();

const formRef = ref();

const componentMap: Record<string, string> = {
  input: 'el-input',
  textarea: 'el-input',
  select: 'el-select',
  radio: 'el-radio-group',
  checkbox: 'el-checkbox-group',
  date: 'el-date-picker',
  datetime: 'el-date-picker',
  number: 'el-input-number',
  switch: 'el-switch',
};

function getComponent(type: string) {
  return componentMap[type] || 'el-input';
}

async function handleSubmit() {
  const valid = await formRef.value?.validate().catch(() => false);
  if (valid) {
    emit('submit', props.model);
  }
}

function handleReset() {
  formRef.value?.resetFields();
  emit('reset');
}

defineExpose({
  validate: () => formRef.value?.validate(),
  resetFields: () => formRef.value?.resetFields(),
  clearValidate: () => formRef.value?.clearValidate(),
});
</script>

<style scoped lang="scss">
.app-form {
  &__actions {
    margin-top: 24px;
    padding-top: 24px;
    border-top: 1px solid var(--el-border-color-lighter);
    
    :deep(.el-form-item__content) {
      justify-content: flex-end;
    }
  }
}
</style>

全局样式覆盖

组件样式微调

// styles/element-overrides.scss

// 按钮增强
.el-button {
  font-weight: 500;
  transition: all 0.2s ease;
  
  &--primary {
    box-shadow: 0 2px 4px rgba(99, 102, 241, 0.3);
    
    &:hover {
      box-shadow: 0 4px 8px rgba(99, 102, 241, 0.4);
      transform: translateY(-1px);
    }
    
    &:active {
      transform: translateY(0);
    }
  }
}

// 输入框聚焦效果
.el-input {
  &.is-focus {
    .el-input__wrapper {
      box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
    }
  }
}

.el-textarea {
  &.is-focus {
    .el-textarea__inner {
      box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
    }
  }
}

// 表格优化
.el-table {
  --el-table-header-bg-color: var(--el-fill-color-lighter);
  
  th.el-table__cell {
    font-weight: 600;
    color: var(--el-text-color-primary);
  }
  
  // 斑马纹颜色
  &--striped {
    .el-table__body tr.el-table__row--striped td.el-table__cell {
      background: var(--el-fill-color-lighter);
    }
  }
  
  // 悬停效果
  .el-table__body tr:hover > td.el-table__cell {
    background-color: var(--el-color-primary-light-9);
  }
}

// 弹窗优化
.el-dialog {
  --el-dialog-border-radius: 12px;
  
  .el-dialog__header {
    border-bottom: 1px solid var(--el-border-color-lighter);
    padding-bottom: 16px;
  }
  
  .el-dialog__footer {
    border-top: 1px solid var(--el-border-color-lighter);
    padding-top: 16px;
  }
}

// 卡片优化
.el-card {
  --el-card-border-radius: 12px;
  transition: box-shadow 0.3s ease;
  
  &:hover {
    box-shadow: var(--el-box-shadow-light);
  }
}

// 分页器优化
.el-pagination {
  .el-pager li {
    border-radius: var(--el-border-radius-base);
    
    &.is-active {
      font-weight: 600;
    }
  }
}

// 标签页优化
.el-tabs {
  --el-tabs-header-height: 48px;
  
  .el-tabs__item {
    font-weight: 500;
    
    &.is-active {
      font-weight: 600;
    }
  }
}

// Message 优化
.el-message {
  border-radius: 8px;
  box-shadow: var(--el-box-shadow-light);
  
  &--success {
    --el-message-bg-color: #ecfdf5;
    --el-message-border-color: #a7f3d0;
  }
  
  &--error {
    --el-message-bg-color: #fef2f2;
    --el-message-border-color: #fecaca;
  }
}

按需引入优化

自动导入配置

// vite.config.ts
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import AutoImport from 'unplugin-auto-import/vite';
import Components from 'unplugin-vue-components/vite';
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers';

export default defineConfig({
  plugins: [
    vue(),
    AutoImport({
      resolvers: [
        ElementPlusResolver({
          importStyle: 'sass',
        }),
      ],
      dts: 'src/auto-imports.d.ts',
    }),
    Components({
      resolvers: [
        ElementPlusResolver({
          importStyle: 'sass',
        }),
      ],
      dts: 'src/components.d.ts',
    }),
  ],
});

手动按需引入

// plugins/element-plus.ts
import type { App } from 'vue';
import {
  ElButton,
  ElInput,
  ElForm,
  ElFormItem,
  ElTable,
  ElTableColumn,
  ElDialog,
  ElMessage,
  ElMessageBox,
  ElNotification,
} from 'element-plus';

// 组件列表
const components = [
  ElButton,
  ElInput,
  ElForm,
  ElFormItem,
  ElTable,
  ElTableColumn,
  ElDialog,
];

// 插件
const plugins = [ElMessage, ElMessageBox, ElNotification];

export function setupElementPlus(app: App) {
  components.forEach(component => {
    app.component(component.name, component);
  });
  
  plugins.forEach(plugin => {
    app.use(plugin);
  });
}

结语

Element Plus 的定制能力远不止本文所述。掌握这些技巧后,你可以:

  1. 建立统一的设计语言:通过 CSS 变量定制品牌色彩
  2. 提高开发效率:通过组件二次封装减少重复代码
  3. 优化用户体验:通过样式微调提升交互细节
  4. 控制包体积:通过按需引入减少构建产物

记住:好的组件库定制,是让用户感受到品牌一致性,而不是感受到 Element Plus。

"组件库是工具,产品体验才是目的。"