Vue 3 Composition API Application in Element Plus
Overview
This document explores how Element Plus leverages Vue 3's Composition API to create maintainable, reusable, and performant components. We'll examine the architectural decisions, implementation patterns, and best practices used throughout the library.
Composition API Fundamentals in Element Plus
1. Reactive State Management
Element Plus uses Vue 3's reactivity system extensively for component state management.
// Button component state management
import { ref, computed, inject } from 'vue'
import type { ButtonProps } from './button'
export const useButton = (props: ButtonProps, emit: any) => {
const buttonRef = ref<HTMLButtonElement>()
const loading = ref(false)
// Computed properties for dynamic classes
const buttonClass = computed(() => {
return [
'el-button',
`el-button--${props.type}`,
`el-button--${props.size}`,
{
'is-disabled': props.disabled,
'is-loading': loading.value,
'is-plain': props.plain,
'is-round': props.round,
'is-circle': props.circle
}
]
})
// Reactive style computation
const buttonStyle = computed(() => {
const style: Record<string, string> = {}
if (props.color) {
style['--el-button-bg-color'] = props.color
style['--el-button-border-color'] = props.color
}
return style
})
return {
buttonRef,
loading,
buttonClass,
buttonStyle
}
}
2. Lifecycle Management
Composition API provides better lifecycle management compared to Options API.
// Dialog component lifecycle management
import { ref, watch, onMounted, onUnmounted, nextTick } from 'vue'
import { useZIndex } from '@element-plus/hooks'
export const useDialog = (props: DialogProps, emit: any) => {
const visible = ref(false)
const dialogRef = ref<HTMLElement>()
const { nextZIndex } = useZIndex()
// Watch for visibility changes
watch(
() => props.modelValue,
(val) => {
if (val) {
open()
} else {
close()
}
},
{ immediate: true }
)
const open = async () => {
visible.value = true
emit('open')
await nextTick()
// Focus management
if (dialogRef.value) {
const focusableElement = dialogRef.value.querySelector(
'[autofocus], input, button, select, textarea'
) as HTMLElement
if (focusableElement) {
focusableElement.focus()
}
}
}
const close = () => {
visible.value = false
emit('close')
emit('update:modelValue', false)
}
// Cleanup on unmount
onUnmounted(() => {
if (visible.value) {
close()
}
})
return {
visible,
dialogRef,
open,
close
}
}
Custom Composables Architecture
1. Namespace Management
Element Plus uses a sophisticated namespace system for CSS class management.
// useNamespace composable
import { computed, unref } from 'vue'
import type { MaybeRef } from '@vueuse/core'
const defaultNamespace = 'el'
const statePrefix = 'is-'
export const useNamespace = (block: string) => {
const namespace = computed(() => defaultNamespace)
const b = (blockSuffix = '') =>
`${namespace.value}-${block}${blockSuffix ? `-${blockSuffix}` : ''}`
const e = (element?: string) =>
element ? `${b()}__${element}` : ''
const m = (modifier?: string) =>
modifier ? `${b()}--${modifier}` : ''
const be = (blockSuffix?: string, element?: string) =>
`${b(blockSuffix)}${e(element)}`
const em = (element?: string, modifier?: string) =>
`${e(element)}${m(modifier)}`
const bm = (blockSuffix?: string, modifier?: string) =>
`${b(blockSuffix)}${m(modifier)}`
const bem = (blockSuffix?: string, element?: string, modifier?: string) =>
`${b(blockSuffix)}${e(element)}${m(modifier)}`
const is = (name: string, state?: boolean | undefined) =>
name && state ? `${statePrefix}${name}` : ''
const cssVar = (object: Record<string, string>) => {
const styles: Record<string, string> = {}
for (const key in object) {
if (object[key]) {
styles[`--${namespace.value}-${key}`] = object[key]
}
}
return styles
}
const cssVarName = (name: string) => `--${namespace.value}-${name}`
const cssVarBlock = (object: Record<string, string>) => {
const styles: Record<string, string> = {}
for (const key in object) {
if (object[key]) {
styles[`--${namespace.value}-${block}-${key}`] = object[key]
}
}
return styles
}
const cssVarBlockName = (name: string) => `--${namespace.value}-${block}-${name}`
return {
namespace,
b,
e,
m,
be,
em,
bm,
bem,
is,
cssVar,
cssVarName,
cssVarBlock,
cssVarBlockName
}
}
// Usage in components
export const Button = defineComponent({
name: 'ElButton',
setup(props) {
const ns = useNamespace('button')
const buttonClass = computed(() => [
ns.b(),
ns.m(props.type),
ns.m(props.size),
ns.is('disabled', props.disabled),
ns.is('loading', props.loading)
])
return { buttonClass }
}
})
2. Form Validation System
Complex form validation using Composition API patterns.
// useFormItem composable
import { computed, inject, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import AsyncValidator from 'async-validator'
import { formContextKey, formItemContextKey } from './constants'
import type { FormItemRule, FormValidateCallback } from './types'
export const useFormItem = (props: FormItemProps) => {
const formContext = inject(formContextKey, undefined)
const validateState = ref('')
const validateMessage = ref('')
const validateDisabled = ref(false)
// Get field value from form model
const fieldValue = computed(() => {
const model = formContext?.model
if (!model || !props.prop) return
return getPropByPath(model, props.prop).v
})
// Get validation rules
const normalizedRules = computed(() => {
const { required } = props
const rules: FormItemRule[] = []
if (props.rules) {
rules.push(...(Array.isArray(props.rules) ? props.rules : [props.rules]))
}
const formRules = formContext?.rules
if (formRules && props.prop) {
const _rules = getPropByPath(formRules, props.prop).v
if (_rules) {
rules.push(...(Array.isArray(_rules) ? _rules : [_rules]))
}
}
if (required !== undefined) {
const requiredRules = rules.filter(rule => rule.required)
if (requiredRules.length === 0) {
rules.push({ required: !!required })
}
}
return rules
})
// Validation function
const validate = async (
trigger: string,
callback?: FormValidateCallback
): Promise<boolean> => {
const rules = getFilteredRule(trigger)
if ((!rules || rules.length === 0) && props.required === undefined) {
callback?.()
return true
}
validateState.value = 'validating'
const descriptor: Record<string, FormItemRule[]> = {}
if (rules && rules.length > 0) {
rules.forEach(rule => {
delete rule.trigger
})
}
descriptor[props.prop!] = rules
const validator = new AsyncValidator(descriptor)
const model: Record<string, unknown> = {}
model[props.prop!] = fieldValue.value
try {
await validator.validate(model)
validateState.value = 'success'
callback?.()
return true
} catch (error: any) {
const { errors } = error
validateState.value = 'error'
validateMessage.value = errors ? errors[0].message : ''
callback?.(validateMessage.value)
return false
}
}
const clearValidate = () => {
validateState.value = ''
validateMessage.value = ''
validateDisabled.value = false
}
const resetField = async () => {
const model = formContext?.model
if (!model || !props.prop) return
const computedValue = getPropByPath(model, props.prop)
validateDisabled.value = true
computedValue.o[computedValue.k] = clone(initialValue)
await nextTick()
clearValidate()
validateDisabled.value = false
}
const getFilteredRule = (trigger: string) => {
const rules = normalizedRules.value
return rules
.filter(rule => {
if (!rule.trigger || trigger === '') return true
if (Array.isArray(rule.trigger)) {
return rule.trigger.includes(trigger)
} else {
return rule.trigger === trigger
}
})
.map(rule => ({ ...rule }))
}
// Provide context for child components
const context = {
prop: props.prop,
validate,
clearValidate,
resetField
}
provide(formItemContextKey, context)
// Register with form
onMounted(() => {
if (props.prop) {
formContext?.addField(context)
}
})
onUnmounted(() => {
formContext?.removeField(context)
})
return {
validateState,
validateMessage,
validate,
clearValidate,
resetField
}
}
3. Event Handling Composables
Reusable event handling logic using Composition API.
// useClickOutside composable
import { onMounted, onUnmounted, unref } from 'vue'
import type { MaybeRef } from '@vueuse/core'
export const useClickOutside = (
target: MaybeRef<HTMLElement | undefined>,
handler: (event: MouseEvent) => void
) => {
const listener = (event: MouseEvent) => {
const el = unref(target)
if (!el || el.contains(event.target as Node)) {
return
}
handler(event)
}
onMounted(() => {
document.addEventListener('click', listener, true)
})
onUnmounted(() => {
document.removeEventListener('click', listener, true)
})
}
// useKeyboard composable
export const useKeyboard = () => {
const onKeydown = (event: KeyboardEvent, handlers: Record<string, () => void>) => {
const handler = handlers[event.key] || handlers[event.code]
if (handler) {
event.preventDefault()
handler()
}
}
const createKeydownHandler = (handlers: Record<string, () => void>) => {
return (event: KeyboardEvent) => onKeydown(event, handlers)
}
return {
onKeydown,
createKeydownHandler
}
}
// Usage in Select component
export const Select = defineComponent({
setup(props, { emit }) {
const selectRef = ref<HTMLElement>()
const visible = ref(false)
const { createKeydownHandler } = useKeyboard()
const close = () => {
visible.value = false
emit('visible-change', false)
}
useClickOutside(selectRef, close)
const keydownHandler = createKeydownHandler({
Escape: close,
ArrowDown: () => {
if (!visible.value) {
visible.value = true
}
}
})
return {
selectRef,
visible,
keydownHandler
}
}
})
Advanced Composition Patterns
1. Provide/Inject Pattern
Element Plus uses provide/inject extensively for component communication.
// Table component context
import { provide, inject, computed } from 'vue'
import type { InjectionKey } from 'vue'
interface TableContext {
store: any
layout: any
refs: {
tableWrapper: HTMLElement
headerWrapper: HTMLElement
bodyWrapper: HTMLElement
}
}
export const tableContextKey: InjectionKey<TableContext> = Symbol('tableContext')
// Table component
export const Table = defineComponent({
setup(props) {
const store = createStore()
const layout = createTableLayout()
const tableContext: TableContext = {
store,
layout,
refs: {
tableWrapper: null!,
headerWrapper: null!,
bodyWrapper: null!
}
}
provide(tableContextKey, tableContext)
return { store, layout }
}
})
// TableColumn component
export const TableColumn = defineComponent({
setup(props) {
const tableContext = inject(tableContextKey)
if (!tableContext) {
throw new Error('TableColumn must be used within Table')
}
const { store } = tableContext
onMounted(() => {
store.commit('insertColumn', props)
})
onUnmounted(() => {
store.commit('removeColumn', props)
})
return {}
}
})
2. Composable Composition
Combining multiple composables for complex functionality.
// usePopper composable combining multiple concerns
import { useFloating } from '@floating-ui/vue'
import { useClickOutside } from './useClickOutside'
import { useEscapeKeydown } from './useEscapeKeydown'
export const usePopper = (options: PopperOptions) => {
const triggerRef = ref<HTMLElement>()
const popperRef = ref<HTMLElement>()
const visible = ref(false)
// Floating UI integration
const { floatingStyles, placement, middlewareData } = useFloating(
triggerRef,
popperRef,
{
placement: options.placement,
middleware: [
offset(options.offset),
flip(),
shift({ padding: 8 })
]
}
)
// Click outside to close
useClickOutside(popperRef, () => {
if (visible.value && options.hideOnClickOutside) {
hide()
}
})
// Escape key to close
useEscapeKeydown(() => {
if (visible.value && options.hideOnEscape) {
hide()
}
})
const show = () => {
visible.value = true
options.onShow?.()
}
const hide = () => {
visible.value = false
options.onHide?.()
}
const toggle = () => {
visible.value ? hide() : show()
}
return {
triggerRef,
popperRef,
visible,
floatingStyles,
placement,
show,
hide,
toggle
}
}
// Usage in Tooltip component
export const Tooltip = defineComponent({
setup(props) {
const {
triggerRef,
popperRef,
visible,
floatingStyles,
show,
hide
} = usePopper({
placement: props.placement,
offset: props.offset,
hideOnClickOutside: true,
hideOnEscape: true
})
return {
triggerRef,
popperRef,
visible,
floatingStyles,
show,
hide
}
}
})
3. Reactive Transform Integration
Element Plus leverages Vue 3's reactivity transform for cleaner syntax.
// Using $ref for cleaner reactive declarations
export const useCounter = (initialValue = 0) => {
let count = $ref(initialValue)
let doubled = $computed(() => count * 2)
const increment = () => {
count++
}
const decrement = () => {
count--
}
const reset = () => {
count = initialValue
}
return {
count: $$(count),
doubled: $$(doubled),
increment,
decrement,
reset
}
}
// Component using reactive transform
export const Counter = defineComponent({
setup() {
const { count, doubled, increment, decrement } = useCounter()
return {
count,
doubled,
increment,
decrement
}
}
})
Performance Optimizations
1. Computed Properties and Memoization
// Memoized computed properties for expensive calculations
import { computed, shallowRef } from 'vue'
export const useTableData = (props: TableProps) => {
const data = shallowRef(props.data)
// Memoized sorting
const sortedData = computed(() => {
if (!props.sortBy) return data.value
return [...data.value].sort((a, b) => {
const aVal = a[props.sortBy!]
const bVal = b[props.sortBy!]
if (props.sortOrder === 'desc') {
return bVal > aVal ? 1 : -1
}
return aVal > bVal ? 1 : -1
})
})
// Memoized filtering
const filteredData = computed(() => {
if (!props.filter) return sortedData.value
return sortedData.value.filter(item => {
return Object.keys(props.filter!).every(key => {
const filterValue = props.filter![key]
const itemValue = item[key]
if (typeof filterValue === 'string') {
return itemValue.toString().toLowerCase().includes(filterValue.toLowerCase())
}
return itemValue === filterValue
})
})
})
return {
sortedData,
filteredData
}
}
2. Lazy Evaluation
// Lazy loading composable
export const useLazyLoad = <T>(loader: () => Promise<T>) => {
const data = shallowRef<T | null>(null)
const loading = ref(false)
const error = ref<Error | null>(null)
const loaded = ref(false)
const load = async () => {
if (loaded.value) return data.value
loading.value = true
error.value = null
try {
data.value = await loader()
loaded.value = true
return data.value
} catch (err) {
error.value = err as Error
throw err
} finally {
loading.value = false
}
}
return {
data: readonly(data),
loading: readonly(loading),
error: readonly(error),
loaded: readonly(loaded),
load
}
}
Testing Composition API Components
1. Testing Composables
// Testing composables in isolation
import { describe, it, expect } from 'vitest'
import { useCounter } from '../composables/useCounter'
describe('useCounter', () => {
it('should initialize with default value', () => {
const { count } = useCounter()
expect(count.value).toBe(0)
})
it('should increment count', () => {
const { count, increment } = useCounter()
increment()
expect(count.value).toBe(1)
})
it('should compute doubled value', () => {
const { count, doubled, increment } = useCounter()
increment()
increment()
expect(doubled.value).toBe(4)
})
})
2. Testing Components with Composition API
// Testing components that use composables
import { mount } from '@vue/test-utils'
import { nextTick } from 'vue'
import Button from '../Button.vue'
describe('Button', () => {
it('should handle loading state', async () => {
const wrapper = mount(Button, {
props: {
loading: true
}
})
expect(wrapper.classes()).toContain('is-loading')
expect(wrapper.find('.el-icon-loading').exists()).toBe(true)
await wrapper.setProps({ loading: false })
await nextTick()
expect(wrapper.classes()).not.toContain('is-loading')
})
})
Best Practices
1. Composable Design Principles
- Single Responsibility: Each composable should have one clear purpose
- Reusability: Design composables to be reusable across components
- Testability: Make composables easy to test in isolation
- Type Safety: Provide comprehensive TypeScript types
2. Performance Guidelines
- Use
shallowRef
for large objects that don't need deep reactivity - Prefer
computed
overwatch
for derived state - Use
readonly
to prevent accidental mutations - Implement proper cleanup in
onUnmounted
3. Code Organization
- Group related composables in dedicated files
- Use consistent naming conventions
- Provide comprehensive JSDoc comments
- Export types alongside composables
Conclusion
Element Plus demonstrates excellent use of Vue 3's Composition API, showcasing:
- Modular Architecture: Well-organized composables for specific concerns
- Type Safety: Comprehensive TypeScript integration
- Performance: Optimized reactivity and computed properties
- Reusability: Composables that can be used across multiple components
- Maintainability: Clear separation of concerns and testable code
These patterns provide excellent guidance for building modern Vue 3 applications with the Composition API.