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 的定制能力远不止本文所述。掌握这些技巧后,你可以:
- 建立统一的设计语言:通过 CSS 变量定制品牌色彩
- 提高开发效率:通过组件二次封装减少重复代码
- 优化用户体验:通过样式微调提升交互细节
- 控制包体积:通过按需引入减少构建产物
记住:好的组件库定制,是让用户感受到品牌一致性,而不是感受到 Element Plus。
"组件库是工具,产品体验才是目的。"


