Component Extension and Customization
Overview
This document explores advanced techniques for extending and customizing Element Plus components. We'll cover component composition, inheritance patterns, plugin development, and creating custom components that integrate seamlessly with the Element Plus ecosystem.
Component Extension Strategies
1. Composition-Based Extension
The most flexible approach to extending components is through composition, leveraging Vue 3's Composition API.
typescript
// Enhanced Button with analytics tracking
import { defineComponent, computed } from 'vue'
import { ElButton } from 'element-plus'
import { useAnalytics } from '@/composables/useAnalytics'
import { usePermissions } from '@/composables/usePermissions'
export const AnalyticsButton = defineComponent({
name: 'AnalyticsButton',
inheritAttrs: false,
props: {
...ElButton.props,
trackingId: {
type: String,
required: true
},
trackingData: {
type: Object,
default: () => ({})
},
permission: {
type: String,
default: ''
}
},
emits: ['click', 'track'],
setup(props, { emit, attrs, slots }) {
const { track } = useAnalytics()
const { hasPermission } = usePermissions()
const isVisible = computed(() => {
return !props.permission || hasPermission(props.permission)
})
const handleClick = (event: MouseEvent) => {
// Track the click event
track(props.trackingId, {
...props.trackingData,
timestamp: Date.now(),
elementType: 'button',
buttonType: props.type || 'default'
})
emit('track', {
id: props.trackingId,
data: props.trackingData
})
emit('click', event)
}
return {
isVisible,
handleClick
}
},
render() {
if (!this.isVisible) {
return null
}
return (
<ElButton
{...this.$attrs}
{...this.$props}
onClick={this.handleClick}
v-slots={this.$slots}
/>
)
}
})
2. Wrapper Component Pattern
Create wrapper components that add functionality while maintaining the original component's API.
typescript
// Enhanced Input with validation and formatting
import { defineComponent, ref, computed, watch } from 'vue'
import { ElInput, ElFormItem } from 'element-plus'
import { useValidation } from '@/composables/useValidation'
import { useFormatter } from '@/composables/useFormatter'
export const SmartInput = defineComponent({
name: 'SmartInput',
props: {
...ElInput.props,
validationRules: {
type: Array,
default: () => []
},
formatter: {
type: String,
default: ''
},
autoComplete: {
type: Boolean,
default: false
},
suggestions: {
type: Array,
default: () => []
},
debounceTime: {
type: Number,
default: 300
}
},
emits: ['update:modelValue', 'validation-change', 'suggestion-select'],
setup(props, { emit }) {
const inputValue = ref(props.modelValue)
const { validate, errors, isValid } = useValidation()
const { format, parse } = useFormatter(props.formatter)
const suggestions = ref([])
const showSuggestions = ref(false)
// Debounced validation
const debouncedValidate = debounce(async (value: string) => {
if (props.validationRules.length > 0) {
const result = await validate(value, props.validationRules)
emit('validation-change', result)
}
}, props.debounceTime)
// Format display value
const displayValue = computed(() => {
return format(inputValue.value)
})
// Watch for value changes
watch(
() => props.modelValue,
(newValue) => {
inputValue.value = newValue
},
{ immediate: true }
)
watch(inputValue, (newValue) => {
debouncedValidate(newValue)
// Auto-complete functionality
if (props.autoComplete && newValue) {
suggestions.value = props.suggestions.filter(item =>
item.toLowerCase().includes(newValue.toLowerCase())
)
showSuggestions.value = suggestions.value.length > 0
} else {
showSuggestions.value = false
}
})
const handleInput = (value: string) => {
const parsedValue = parse(value)
inputValue.value = parsedValue
emit('update:modelValue', parsedValue)
}
const handleSuggestionSelect = (suggestion: string) => {
inputValue.value = suggestion
emit('update:modelValue', suggestion)
emit('suggestion-select', suggestion)
showSuggestions.value = false
}
return {
inputValue,
displayValue,
errors,
isValid,
suggestions,
showSuggestions,
handleInput,
handleSuggestionSelect
}
},
render() {
return (
<div class="smart-input">
<ElInput
{...this.$attrs}
modelValue={this.displayValue}
onInput={this.handleInput}
class={{
'is-error': !this.isValid,
'has-suggestions': this.showSuggestions
}}
/>
{this.showSuggestions && (
<div class="smart-input__suggestions">
{this.suggestions.map((suggestion, index) => (
<div
key={index}
class="smart-input__suggestion"
onClick={() => this.handleSuggestionSelect(suggestion)}
>
{suggestion}
</div>
))}
</div>
)}
{this.errors.length > 0 && (
<div class="smart-input__errors">
{this.errors.map((error, index) => (
<div key={index} class="smart-input__error">
{error}
</div>
))}
</div>
)}
</div>
)
}
})
// Utility function for debouncing
function debounce<T extends (...args: any[]) => any>(
func: T,
wait: number
): (...args: Parameters<T>) => void {
let timeout: NodeJS.Timeout
return (...args: Parameters<T>) => {
clearTimeout(timeout)
timeout = setTimeout(() => func(...args), wait)
}
}
3. Mixin-Based Extension
Create reusable mixins for common functionality across multiple components.
typescript
// Loading state mixin
import { ref, computed } from 'vue'
export const useLoadingState = () => {
const loading = ref(false)
const loadingText = ref('Loading...')
const error = ref<string | null>(null)
const isLoading = computed(() => loading.value)
const hasError = computed(() => error.value !== null)
const startLoading = (text = 'Loading...') => {
loading.value = true
loadingText.value = text
error.value = null
}
const stopLoading = () => {
loading.value = false
}
const setError = (errorMessage: string) => {
loading.value = false
error.value = errorMessage
}
const clearError = () => {
error.value = null
}
return {
loading: readonly(loading),
loadingText: readonly(loadingText),
error: readonly(error),
isLoading,
hasError,
startLoading,
stopLoading,
setError,
clearError
}
}
// Pagination mixin
export const usePagination = (initialPageSize = 10) => {
const currentPage = ref(1)
const pageSize = ref(initialPageSize)
const total = ref(0)
const totalPages = computed(() => Math.ceil(total.value / pageSize.value))
const offset = computed(() => (currentPage.value - 1) * pageSize.value)
const hasNextPage = computed(() => currentPage.value < totalPages.value)
const hasPrevPage = computed(() => currentPage.value > 1)
const goToPage = (page: number) => {
if (page >= 1 && page <= totalPages.value) {
currentPage.value = page
}
}
const nextPage = () => {
if (hasNextPage.value) {
currentPage.value++
}
}
const prevPage = () => {
if (hasPrevPage.value) {
currentPage.value--
}
}
const setTotal = (newTotal: number) => {
total.value = newTotal
// Adjust current page if necessary
if (currentPage.value > totalPages.value && totalPages.value > 0) {
currentPage.value = totalPages.value
}
}
const setPageSize = (newPageSize: number) => {
pageSize.value = newPageSize
currentPage.value = 1 // Reset to first page
}
const reset = () => {
currentPage.value = 1
total.value = 0
}
return {
currentPage: readonly(currentPage),
pageSize: readonly(pageSize),
total: readonly(total),
totalPages,
offset,
hasNextPage,
hasPrevPage,
goToPage,
nextPage,
prevPage,
setTotal,
setPageSize,
reset
}
}
// Enhanced Table with loading and pagination
export const EnhancedTable = defineComponent({
name: 'EnhancedTable',
props: {
data: {
type: Array,
default: () => []
},
columns: {
type: Array,
required: true
},
loading: {
type: Boolean,
default: false
},
pagination: {
type: Object,
default: () => ({})
},
serverSide: {
type: Boolean,
default: false
}
},
emits: ['page-change', 'size-change', 'sort-change'],
setup(props, { emit }) {
const {
currentPage,
pageSize,
total,
totalPages,
goToPage,
setPageSize,
setTotal
} = usePagination(props.pagination.pageSize)
const {
loading,
startLoading,
stopLoading,
setError,
hasError,
error
} = useLoadingState()
// Computed data for client-side pagination
const paginatedData = computed(() => {
if (props.serverSide) {
return props.data
}
const start = (currentPage.value - 1) * pageSize.value
const end = start + pageSize.value
return props.data.slice(start, end)
})
// Watch for data changes to update total
watch(
() => props.data,
(newData) => {
if (!props.serverSide) {
setTotal(newData.length)
}
},
{ immediate: true }
)
// Watch for pagination prop changes
watch(
() => props.pagination,
(newPagination) => {
if (newPagination.total !== undefined) {
setTotal(newPagination.total)
}
if (newPagination.currentPage !== undefined) {
goToPage(newPagination.currentPage)
}
if (newPagination.pageSize !== undefined) {
setPageSize(newPagination.pageSize)
}
},
{ deep: true, immediate: true }
)
const handlePageChange = (page: number) => {
goToPage(page)
emit('page-change', page)
}
const handleSizeChange = (size: number) => {
setPageSize(size)
emit('size-change', size)
}
const handleSortChange = (sortInfo: any) => {
emit('sort-change', sortInfo)
}
return {
paginatedData,
currentPage,
pageSize,
total,
totalPages,
loading,
hasError,
error,
handlePageChange,
handleSizeChange,
handleSortChange
}
}
})
Custom Component Development
1. Building a Custom Rating Component
typescript
// Custom Rating Component with advanced features
import { defineComponent, ref, computed, watch } from 'vue'
import { useNamespace } from '@element-plus/hooks'
export const CustomRating = defineComponent({
name: 'CustomRating',
props: {
modelValue: {
type: Number,
default: 0
},
max: {
type: Number,
default: 5
},
allowHalf: {
type: Boolean,
default: false
},
allowClear: {
type: Boolean,
default: false
},
disabled: {
type: Boolean,
default: false
},
readonly: {
type: Boolean,
default: false
},
size: {
type: String,
values: ['large', 'default', 'small'],
default: 'default'
},
iconClass: {
type: String,
default: 'el-icon-star-on'
},
voidIconClass: {
type: String,
default: 'el-icon-star-off'
},
colors: {
type: Array,
default: () => ['#f7ba2a', '#f7ba2a', '#f7ba2a']
},
voidColor: {
type: String,
default: '#c6d1de'
},
disabledVoidColor: {
type: String,
default: '#eff2f7'
},
showText: {
type: Boolean,
default: false
},
showScore: {
type: Boolean,
default: false
},
textColor: {
type: String,
default: '#1f2d3d'
},
texts: {
type: Array,
default: () => ['Extremely bad', 'Disappointed', 'Fair', 'Satisfied', 'Surprise']
},
scoreTemplate: {
type: String,
default: '{value}'
},
lowThreshold: {
type: Number,
default: 2
},
highThreshold: {
type: Number,
default: 4
},
customIcon: {
type: Function,
default: null
},
tooltips: {
type: Array,
default: () => []
}
},
emits: ['update:modelValue', 'change', 'hover'],
setup(props, { emit }) {
const ns = useNamespace('rating')
const currentValue = ref(props.modelValue)
const hoverIndex = ref(-1)
const isHovering = ref(false)
const ratingClasses = computed(() => [
ns.b(),
ns.m(props.size),
{
[ns.is('disabled')]: props.disabled,
[ns.is('readonly')]: props.readonly
}
])
const displayValue = computed(() => {
return isHovering.value ? hoverIndex.value : currentValue.value
})
const activeColor = computed(() => {
const value = displayValue.value
if (value <= props.lowThreshold) {
return props.colors[0]
} else if (value <= props.highThreshold) {
return props.colors[1]
} else {
return props.colors[2]
}
})
const currentText = computed(() => {
const value = Math.ceil(displayValue.value)
return props.texts[value - 1] || ''
})
const scoreText = computed(() => {
return props.scoreTemplate.replace('{value}', displayValue.value.toString())
})
const getStarClasses = (index: number) => {
const value = displayValue.value
const isFull = index <= value
const isHalf = props.allowHalf && index === Math.ceil(value) && value % 1 !== 0
return [
ns.e('item'),
{
[ns.is('active')]: isFull,
[ns.is('half')]: isHalf,
[ns.is('hover')]: isHovering.value && index <= hoverIndex.value
}
]
}
const getStarStyle = (index: number) => {
const value = displayValue.value
const isFull = index <= value
return {
color: isFull ? activeColor.value : (props.disabled ? props.disabledVoidColor : props.voidColor)
}
}
const handleStarClick = (index: number, event: MouseEvent) => {
if (props.disabled || props.readonly) return
let newValue = index
if (props.allowHalf) {
const rect = (event.target as HTMLElement).getBoundingClientRect()
const isLeftHalf = event.clientX - rect.left < rect.width / 2
newValue = isLeftHalf ? index - 0.5 : index
}
if (props.allowClear && currentValue.value === newValue) {
newValue = 0
}
currentValue.value = newValue
emit('update:modelValue', newValue)
emit('change', newValue)
}
const handleStarHover = (index: number, event: MouseEvent) => {
if (props.disabled || props.readonly) return
let hoverValue = index
if (props.allowHalf) {
const rect = (event.target as HTMLElement).getBoundingClientRect()
const isLeftHalf = event.clientX - rect.left < rect.width / 2
hoverValue = isLeftHalf ? index - 0.5 : index
}
hoverIndex.value = hoverValue
isHovering.value = true
emit('hover', hoverValue)
}
const handleMouseLeave = () => {
isHovering.value = false
hoverIndex.value = -1
}
watch(
() => props.modelValue,
(newValue) => {
currentValue.value = newValue
}
)
return {
ns,
currentValue,
displayValue,
ratingClasses,
currentText,
scoreText,
getStarClasses,
getStarStyle,
handleStarClick,
handleStarHover,
handleMouseLeave
}
},
render() {
const stars = []
for (let i = 1; i <= this.max; i++) {
const starElement = this.customIcon ? (
this.customIcon({
index: i,
value: this.displayValue,
isFull: i <= this.displayValue,
isHalf: this.allowHalf && i === Math.ceil(this.displayValue) && this.displayValue % 1 !== 0
})
) : (
<i
class={[
this.displayValue >= i ? this.iconClass : this.voidIconClass,
this.getStarClasses(i)
]}
style={this.getStarStyle(i)}
/>
)
stars.push(
<span
key={i}
class={this.ns.e('star')}
onClick={(event: MouseEvent) => this.handleStarClick(i, event)}
onMousemove={(event: MouseEvent) => this.handleStarHover(i, event)}
title={this.tooltips[i - 1] || ''}
>
{starElement}
</span>
)
}
return (
<div
class={this.ratingClasses}
onMouseleave={this.handleMouseLeave}
>
<div class={this.ns.e('stars')}>
{stars}
</div>
{this.showText && (
<span
class={this.ns.e('text')}
style={{ color: this.textColor }}
>
{this.currentText}
</span>
)}
{this.showScore && (
<span
class={this.ns.e('score')}
style={{ color: this.textColor }}
>
{this.scoreText}
</span>
)}
</div>
)
}
})
2. Advanced Form Builder Component
typescript
// Dynamic Form Builder Component
import { defineComponent, ref, computed, watch } from 'vue'
import { ElForm, ElFormItem, ElInput, ElSelect, ElOption, ElDatePicker, ElSwitch, ElRadioGroup, ElRadio, ElCheckboxGroup, ElCheckbox } from 'element-plus'
interface FormField {
name: string
label: string
type: 'input' | 'select' | 'date' | 'switch' | 'radio' | 'checkbox' | 'textarea'
required?: boolean
placeholder?: string
options?: Array<{ label: string; value: any }>
rules?: any[]
props?: Record<string, any>
show?: (formData: Record<string, any>) => boolean
disabled?: (formData: Record<string, any>) => boolean
}
interface FormSchema {
fields: FormField[]
layout?: 'horizontal' | 'vertical' | 'inline'
labelWidth?: string
size?: 'large' | 'default' | 'small'
}
export const DynamicFormBuilder = defineComponent({
name: 'DynamicFormBuilder',
props: {
schema: {
type: Object as PropType<FormSchema>,
required: true
},
modelValue: {
type: Object,
default: () => ({})
},
loading: {
type: Boolean,
default: false
},
readonly: {
type: Boolean,
default: false
}
},
emits: ['update:modelValue', 'field-change', 'validate'],
setup(props, { emit, expose }) {
const formRef = ref()
const formData = ref({ ...props.modelValue })
const visibleFields = computed(() => {
return props.schema.fields.filter(field => {
return !field.show || field.show(formData.value)
})
})
const getFieldRules = (field: FormField) => {
const rules = [...(field.rules || [])]
if (field.required) {
rules.unshift({
required: true,
message: `${field.label} is required`,
trigger: ['blur', 'change']
})
}
return rules
}
const isFieldDisabled = (field: FormField) => {
if (props.readonly) return true
return field.disabled ? field.disabled(formData.value) : false
}
const handleFieldChange = (fieldName: string, value: any) => {
formData.value[fieldName] = value
emit('update:modelValue', { ...formData.value })
emit('field-change', { field: fieldName, value, formData: formData.value })
}
const renderField = (field: FormField) => {
const commonProps = {
modelValue: formData.value[field.name],
'onUpdate:modelValue': (value: any) => handleFieldChange(field.name, value),
placeholder: field.placeholder,
disabled: isFieldDisabled(field),
...field.props
}
switch (field.type) {
case 'input':
return <ElInput {...commonProps} />
case 'textarea':
return <ElInput {...commonProps} type="textarea" />
case 'select':
return (
<ElSelect {...commonProps}>
{field.options?.map(option => (
<ElOption
key={option.value}
label={option.label}
value={option.value}
/>
))}
</ElSelect>
)
case 'date':
return <ElDatePicker {...commonProps} />
case 'switch':
return <ElSwitch {...commonProps} />
case 'radio':
return (
<ElRadioGroup {...commonProps}>
{field.options?.map(option => (
<ElRadio key={option.value} label={option.value}>
{option.label}
</ElRadio>
))}
</ElRadioGroup>
)
case 'checkbox':
return (
<ElCheckboxGroup {...commonProps}>
{field.options?.map(option => (
<ElCheckbox key={option.value} label={option.value}>
{option.label}
</ElCheckbox>
))}
</ElCheckboxGroup>
)
default:
return <ElInput {...commonProps} />
}
}
const validate = async () => {
if (!formRef.value) return false
try {
await formRef.value.validate()
emit('validate', { valid: true, errors: [] })
return true
} catch (errors) {
emit('validate', { valid: false, errors })
return false
}
}
const validateField = (fieldName: string) => {
if (!formRef.value) return
formRef.value.validateField(fieldName)
}
const resetFields = () => {
if (!formRef.value) return
formRef.value.resetFields()
}
const clearValidate = (fieldNames?: string[]) => {
if (!formRef.value) return
formRef.value.clearValidate(fieldNames)
}
watch(
() => props.modelValue,
(newValue) => {
formData.value = { ...newValue }
},
{ deep: true }
)
expose({
validate,
validateField,
resetFields,
clearValidate,
formData
})
return {
formRef,
formData,
visibleFields,
getFieldRules,
renderField,
validate,
validateField,
resetFields,
clearValidate
}
},
render() {
return (
<ElForm
ref="formRef"
model={this.formData}
labelPosition={this.schema.layout === 'horizontal' ? 'right' : 'top'}
labelWidth={this.schema.labelWidth || '100px'}
size={this.schema.size || 'default'}
inline={this.schema.layout === 'inline'}
v-loading={this.loading}
>
{this.visibleFields.map(field => (
<ElFormItem
key={field.name}
label={field.label}
prop={field.name}
rules={this.getFieldRules(field)}
>
{this.renderField(field)}
</ElFormItem>
))}
</ElForm>
)
}
})
Plugin Development
1. Custom Directive Plugin
typescript
// Custom directives for Element Plus integration
import { App, Directive } from 'vue'
import { ElLoading } from 'element-plus'
// Auto-focus directive
const autoFocus: Directive = {
mounted(el: HTMLElement, binding) {
if (binding.value !== false) {
// Find the input element
const input = el.querySelector('input, textarea, select') as HTMLElement
if (input) {
setTimeout(() => {
input.focus()
}, 100)
}
}
}
}
// Click outside directive
const clickOutside: Directive = {
mounted(el: any, binding) {
el.clickOutsideEvent = (event: Event) => {
if (!(el === event.target || el.contains(event.target))) {
binding.value(event)
}
}
document.addEventListener('click', el.clickOutsideEvent)
},
unmounted(el: any) {
document.removeEventListener('click', el.clickOutsideEvent)
}
}
// Lazy load directive
const lazyLoad: Directive = {
mounted(el: HTMLElement, binding) {
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
binding.value()
observer.unobserve(el)
}
})
})
observer.observe(el)
el._observer = observer
},
unmounted(el: any) {
if (el._observer) {
el._observer.disconnect()
}
}
}
// Tooltip directive
const tooltip: Directive = {
mounted(el: HTMLElement, binding) {
el.title = binding.value
// Enhanced tooltip with Element Plus styling
el.addEventListener('mouseenter', () => {
// Create custom tooltip implementation
})
},
updated(el: HTMLElement, binding) {
el.title = binding.value
}
}
export const ElementPlusDirectives = {
install(app: App) {
app.directive('auto-focus', autoFocus)
app.directive('click-outside', clickOutside)
app.directive('lazy-load', lazyLoad)
app.directive('tooltip', tooltip)
}
}
2. Global Component Registration Plugin
typescript
// Global component registration with auto-import
import { App } from 'vue'
import * as ElementPlusComponents from 'element-plus'
interface ComponentRegistrationOptions {
prefix?: string
components?: string[]
exclude?: string[]
autoImport?: boolean
}
export const ElementPlusGlobalComponents = {
install(app: App, options: ComponentRegistrationOptions = {}) {
const {
prefix = 'El',
components = [],
exclude = [],
autoImport = true
} = options
if (autoImport) {
// Auto-register all Element Plus components
Object.entries(ElementPlusComponents).forEach(([name, component]) => {
if (name.startsWith('El') && typeof component === 'object' && component.name) {
if (exclude.includes(name)) return
const componentName = prefix + name.slice(2)
app.component(componentName, component)
}
})
} else {
// Register only specified components
components.forEach(componentName => {
const component = ElementPlusComponents[componentName as keyof typeof ElementPlusComponents]
if (component && typeof component === 'object') {
const registrationName = prefix + componentName.slice(2)
app.component(registrationName, component)
}
})
}
}
}
Best Practices
1. Component Extension Guidelines
- Maintain API Compatibility: Ensure extended components maintain the original API
- Performance Considerations: Avoid unnecessary re-renders and computations
- Type Safety: Provide comprehensive TypeScript definitions
- Documentation: Document all extensions and customizations
- Testing: Thoroughly test extended components
2. Customization Strategies
- Composition over Inheritance: Prefer composition for flexibility
- Prop Forwarding: Properly forward props to underlying components
- Event Handling: Maintain event compatibility and add new events as needed
- Styling: Use CSS custom properties for theme compatibility
- Accessibility: Ensure customizations don't break accessibility
3. Plugin Development
- Modular Design: Create focused, single-purpose plugins
- Configuration: Provide sensible defaults with customization options
- Error Handling: Handle edge cases gracefully
- Performance: Minimize plugin overhead
- Documentation: Provide clear usage examples and API documentation
Conclusion
Element Plus's extensible architecture enables:
- Flexible Component Extension: Multiple patterns for extending functionality
- Custom Component Development: Tools and patterns for building new components
- Plugin System: Comprehensive plugin development capabilities
- Type Safety: Full TypeScript support throughout
- Performance: Optimized patterns for efficient extensions
- Maintainability: Clean, testable code structures
These patterns and techniques provide a solid foundation for building sophisticated, customized UI components and systems based on Element Plus.