Component Design Pattern Analysis
Overview
This document provides an in-depth analysis of Element Plus component design patterns, exploring the architectural principles, design philosophies, and implementation strategies that make Element Plus a robust and maintainable UI library.
Core Design Principles
1. Composition over Inheritance
Element Plus follows the composition pattern extensively, allowing components to be built by combining smaller, focused pieces rather than inheriting from large base classes.
// Example: Button component composition
import { computed, inject } from 'vue'
import { useNamespace } from '@element-plus/hooks'
import { buttonGroupContextKey } from './constants'
export const useButton = (props: ButtonProps) => {
const ns = useNamespace('button')
const buttonGroupContext = inject(buttonGroupContextKey, undefined)
const buttonSize = computed(() => {
return props.size || buttonGroupContext?.size || 'default'
})
const buttonType = computed(() => {
return props.type || buttonGroupContext?.type || 'default'
})
return {
ns,
buttonSize,
buttonType
}
}
2. Single Responsibility Principle
Each component has a clear, single responsibility, making them easier to understand, test, and maintain.
// Example: Separate concerns in Form components
// FormItem - handles validation and layout
export const FormItem = defineComponent({
name: 'ElFormItem',
setup(props) {
const { validateField, clearValidate } = useFormItem(props)
return { validateField, clearValidate }
}
})
// Input - handles user input
export const Input = defineComponent({
name: 'ElInput',
setup(props) {
const { handleInput, handleFocus, handleBlur } = useInput(props)
return { handleInput, handleFocus, handleBlur }
}
})
3. Dependency Inversion
Components depend on abstractions rather than concrete implementations, enabling flexibility and testability.
// Abstract validation interface
interface ValidationRule {
required?: boolean
message?: string
validator?: (rule: any, value: any, callback: any) => void
}
// Form component depends on abstraction
export const useFormValidation = (rules: ValidationRule[]) => {
const validate = async (value: any): Promise<boolean> => {
for (const rule of rules) {
if (rule.validator) {
const isValid = await new Promise(resolve => {
rule.validator!(rule, value, (error?: Error) => {
resolve(!error)
})
})
if (!isValid) return false
}
}
return true
}
return { validate }
}
Component Architecture Patterns
1. Provider-Consumer Pattern
Used extensively for component communication and context sharing.
// Provider: Form component
export const Form = defineComponent({
name: 'ElForm',
setup(props, { slots }) {
const formContext = {
model: props.model,
rules: props.rules,
validateField: (prop: string) => { /* validation logic */ },
clearValidate: (props?: string[]) => { /* clear logic */ }
}
provide(formContextKey, formContext)
return () => slots.default?.()
}
})
// Consumer: FormItem component
export const FormItem = defineComponent({
name: 'ElFormItem',
setup(props) {
const formContext = inject(formContextKey)
const validate = () => {
return formContext?.validateField(props.prop)
}
return { validate }
}
})
2. Compound Component Pattern
Components that work together to form a cohesive unit.
// Menu compound components
export const Menu = defineComponent({
name: 'ElMenu',
setup(props, { slots }) {
const menuContext = {
activeIndex: ref(props.defaultActive),
openedMenus: ref([]),
handleMenuItemClick: (index: string) => { /* handle click */ }
}
provide(menuContextKey, menuContext)
return () => (
<ul class="el-menu">
{slots.default?.()}
</ul>
)
}
})
export const MenuItem = defineComponent({
name: 'ElMenuItem',
setup(props) {
const menuContext = inject(menuContextKey)
const handleClick = () => {
menuContext?.handleMenuItemClick(props.index)
}
return () => (
<li class="el-menu-item" onClick={handleClick}>
{slots.default?.()}
</li>
)
}
})
3. Render Props Pattern (Vue Slots)
Providing flexibility through customizable rendering.
// Table component with flexible rendering
export const Table = defineComponent({
name: 'ElTable',
setup(props, { slots }) {
const renderCell = (row: any, column: TableColumn) => {
// Check for custom slot
if (slots[column.prop]) {
return slots[column.prop]!({ row, column, $index: row.index })
}
// Check for column render function
if (column.render) {
return column.render(row, column)
}
// Default rendering
return row[column.prop]
}
return { renderCell }
}
})
State Management Patterns
1. Local State with Composables
// useToggle composable for managing boolean state
export const useToggle = (initialValue = false) => {
const state = ref(initialValue)
const toggle = () => {
state.value = !state.value
}
const setTrue = () => {
state.value = true
}
const setFalse = () => {
state.value = false
}
return {
state: readonly(state),
toggle,
setTrue,
setFalse
}
}
// Usage in Dialog component
export const Dialog = defineComponent({
setup(props) {
const { state: visible, setTrue: show, setFalse: hide } = useToggle(props.modelValue)
return { visible, show, hide }
}
})
2. Reactive State Management
// useFormState for complex form management
export const useFormState = (initialData: Record<string, any>) => {
const formData = reactive({ ...initialData })
const errors = reactive<Record<string, string>>({})
const touched = reactive<Record<string, boolean>>({})
const setFieldValue = (field: string, value: any) => {
formData[field] = value
touched[field] = true
// Clear error when field is modified
if (errors[field]) {
delete errors[field]
}
}
const setFieldError = (field: string, error: string) => {
errors[field] = error
}
const reset = () => {
Object.assign(formData, initialData)
Object.keys(errors).forEach(key => delete errors[key])
Object.keys(touched).forEach(key => delete touched[key])
}
return {
formData: readonly(formData),
errors: readonly(errors),
touched: readonly(touched),
setFieldValue,
setFieldError,
reset
}
}
Event Handling Patterns
1. Event Delegation
// Table component using event delegation
export const Table = defineComponent({
setup(props) {
const handleTableClick = (event: MouseEvent) => {
const target = event.target as HTMLElement
// Handle row click
const row = target.closest('.el-table__row')
if (row) {
const rowIndex = parseInt(row.dataset.index || '0')
emit('row-click', props.data[rowIndex], rowIndex)
return
}
// Handle cell click
const cell = target.closest('.el-table__cell')
if (cell) {
const cellIndex = parseInt(cell.dataset.index || '0')
emit('cell-click', target, cellIndex)
return
}
}
return { handleTableClick }
}
})
2. Event Bus Pattern
// Global event bus for component communication
class EventBus {
private events: Map<string, Function[]> = new Map()
on(event: string, callback: Function) {
if (!this.events.has(event)) {
this.events.set(event, [])
}
this.events.get(event)!.push(callback)
}
emit(event: string, ...args: any[]) {
const callbacks = this.events.get(event)
if (callbacks) {
callbacks.forEach(callback => callback(...args))
}
}
off(event: string, callback?: Function) {
if (!callback) {
this.events.delete(event)
return
}
const callbacks = this.events.get(event)
if (callbacks) {
const index = callbacks.indexOf(callback)
if (index > -1) {
callbacks.splice(index, 1)
}
}
}
}
export const eventBus = new EventBus()
// Usage in Message component
export const useMessage = () => {
const show = (options: MessageOptions) => {
eventBus.emit('message:show', options)
}
const hide = (id: string) => {
eventBus.emit('message:hide', id)
}
return { show, hide }
}
Performance Optimization Patterns
1. Lazy Loading Pattern
// Lazy loading for large components
export const useLazyComponent = (loader: () => Promise<any>) => {
const component = ref(null)
const loading = ref(false)
const error = ref(null)
const load = async () => {
if (component.value) return component.value
loading.value = true
error.value = null
try {
const loaded = await loader()
component.value = loaded.default || loaded
return component.value
} catch (err) {
error.value = err
throw err
} finally {
loading.value = false
}
}
return {
component: readonly(component),
loading: readonly(loading),
error: readonly(error),
load
}
}
// Usage in Select component for large option lists
export const Select = defineComponent({
setup(props) {
const { component: OptionList, load: loadOptions } = useLazyComponent(
() => import('./OptionList.vue')
)
const handleDropdownShow = async () => {
if (props.options.length > 1000) {
await loadOptions()
}
}
return { OptionList, handleDropdownShow }
}
})
2. Virtual Scrolling Pattern
// Virtual scrolling for large lists
export const useVirtualScroll = (options: {
items: Ref<any[]>
itemHeight: number
containerHeight: number
}) => {
const scrollTop = ref(0)
const startIndex = computed(() => {
return Math.floor(scrollTop.value / options.itemHeight)
})
const endIndex = computed(() => {
const visibleCount = Math.ceil(options.containerHeight / options.itemHeight)
return Math.min(startIndex.value + visibleCount + 1, options.items.value.length)
})
const visibleItems = computed(() => {
return options.items.value.slice(startIndex.value, endIndex.value)
})
const totalHeight = computed(() => {
return options.items.value.length * options.itemHeight
})
const offsetY = computed(() => {
return startIndex.value * options.itemHeight
})
const handleScroll = (event: Event) => {
const target = event.target as HTMLElement
scrollTop.value = target.scrollTop
}
return {
visibleItems,
totalHeight,
offsetY,
handleScroll
}
}
Testing Patterns
1. Component Testing with Composition API
// Testing composables in isolation
import { describe, it, expect } from 'vitest'
import { useToggle } from '../composables/useToggle'
describe('useToggle', () => {
it('should initialize with default value', () => {
const { state } = useToggle()
expect(state.value).toBe(false)
})
it('should toggle state', () => {
const { state, toggle } = useToggle()
toggle()
expect(state.value).toBe(true)
toggle()
expect(state.value).toBe(false)
})
})
// Testing components with context
import { mount } from '@vue/test-utils'
import { provide } from 'vue'
import Form from '../Form.vue'
import FormItem from '../FormItem.vue'
describe('Form integration', () => {
it('should provide context to form items', () => {
const wrapper = mount({
template: `
<Form :model="model" :rules="rules">
<FormItem prop="name">
<input v-model="model.name" />
</FormItem>
</Form>
`,
components: { Form, FormItem },
data() {
return {
model: { name: '' },
rules: { name: [{ required: true, message: 'Name is required' }] }
}
}
})
expect(wrapper.findComponent(FormItem).exists()).toBe(true)
})
})
Best Practices
1. Component API Design
- Consistent naming: Use clear, descriptive names for props, events, and slots
- Prop validation: Always provide proper TypeScript types and runtime validation
- Event naming: Use kebab-case for events and include descriptive prefixes
- Slot design: Provide meaningful slot names and scope data
2. Performance Considerations
- Computed properties: Use computed properties for derived state
- Event handlers: Avoid creating new functions in templates
- Watchers: Use watchers sparingly and clean them up properly
- Memory leaks: Always clean up event listeners and timers
3. Accessibility
- ARIA attributes: Include proper ARIA labels and roles
- Keyboard navigation: Implement keyboard support for interactive elements
- Focus management: Handle focus states appropriately
- Screen readers: Ensure compatibility with assistive technologies
Conclusion
Element Plus demonstrates excellent component design patterns that promote:
- Maintainability: Clear separation of concerns and modular architecture
- Reusability: Composable functions and flexible component APIs
- Performance: Optimized rendering and efficient state management
- Developer Experience: Intuitive APIs and comprehensive TypeScript support
- Accessibility: Built-in support for assistive technologies
These patterns serve as excellent examples for building robust, scalable Vue.js applications and component libraries.