Back to home

Vue 3 Dialog Component Deep Dive into v-model and Component Encapsulation

267 min read

Introduction

In modern Vue 3 applications, creating reusable dialog components is a common requirement. This article will explore how to build a robust dialog component using Vue 3's composition API, with a focus on v-model implementation and component encapsulation.

Core Implementation

1. Component Types

First, let's define our TypeScript interfaces:

// types/dialog.ts
export interface CategoryForm {
  categoryName: string
}

export interface CategoryDialogProps {
  visible: boolean
  formLabelWidth?: string
  loading?: boolean
  title?: string
}

export interface CategoryEmits {
  (e: 'update:visible', value: boolean): void
  (e: 'addCategory', name: string): void
  (e: 'close'): void
}

2. Complete Dialog Component

<!-- components/CategoryDialog.vue -->
<template>
  <el-dialog
    v-model="dialogVisible"
    :title="title"
    :width="width"
    destroy-on-close
    center
    @close="handleClose"
  >
    <el-form
      ref="formRef"
      :model="form"
      :rules="rules"
      :validate-on-rule-change="false"
    >
      <el-form-item
        :label="label"
        :label-width="formLabelWidth"
        prop="categoryName"
      >
        <el-input
          v-model.trim="form.categoryName"
          :placeholder="placeholder"
          maxlength="50"
          show-word-limit
          @keyup.enter="handleConfirm"
        />
      </el-form-item>
    </el-form>

    <template #footer>
      <div class="dialog-footer">
        <el-button 
          @click="handleClose"
          :disabled="loading"
        >
          {{ cancelText }}
        </el-button>
        <el-button
          type="primary"
          @click="handleConfirm"
          :loading="loading"
        >
          {{ confirmText }}
        </el-button>
      </div>
    </template>
  </el-dialog>
</template>

<script setup lang="ts">
import { ref, computed } from 'vue'
import type { FormInstance, FormRules } from 'element-plus'
import type { CategoryForm, CategoryDialogProps } from '@/types/dialog'

// Props definition with default values
const props = withDefaults(defineProps<CategoryDialogProps>(), {
  visible: false,
  formLabelWidth: '80px',
  loading: false,
  title: 'Add Category',
  width: '400px',
  label: 'Category Name',
  placeholder: 'Please enter category name',
  cancelText: 'Cancel',
  confirmText: 'Confirm'
})

// Emits definition
const emits = defineEmits<CategoryEmits>()

// Form ref for validation
const formRef = ref<FormInstance>()

// Form data
const form = ref<CategoryForm>({
  categoryName: ''
})

// Validation rules
const rules: FormRules = {
  categoryName: [
    { 
      required: true, 
      message: 'Please enter category name', 
      trigger: 'blur' 
    },
    { 
      min: 2, 
      max: 50, 
      message: 'Length should be 2 to 50 characters', 
      trigger: 'blur' 
    },
    { 
      pattern: /^[\w\-\u4e00-\u9fa5]+$/, 
      message: 'Only letters, numbers, Chinese characters, underscores and hyphens are allowed',
      trigger: 'blur'
    }
  ]
}

// Dialog visibility computed property
const dialogVisible = computed({
  get() {
    return props.visible
  },
  set(value: boolean) {
    emits('update:visible', value)
  }
})

// Close handler
const handleClose = () => {
  if (props.loading) return
  
  dialogVisible.value = false
  resetForm()
  emits('close')
}

// Reset form
const resetForm = () => {
  form.value.categoryName = ''
  formRef.value?.resetFields()
}

// Confirm handler
const handleConfirm = async () => {
  if (!formRef.value || props.loading) return
  
  try {
    const valid = await formRef.value.validate()
    if (valid) {
      emits('addCategory', form.value.categoryName)
    }
  } catch (error) {
    console.error('Validation failed:', error)
  }
}

// Expose methods
defineExpose({
  resetForm,
  validate: () => formRef.value?.validate()
})
</script>

<style scoped>
.dialog-footer {
  padding-top: 20px;
  text-align: right;
}

:deep(.el-form-item__label) {
  font-weight: 500;
}

:deep(.el-input__wrapper) {
  width: 100%;
}
</style>

3. Usage Example

<!-- views/CategoryManagement.vue -->
<template>
  <div class="category-management">
    <div class="header">
      <h2>Category Management</h2>
      <el-button 
        type="primary" 
        @click="openDialog"
      >
        Add Category
      </el-button>
    </div>

    <el-table 
      :data="categories"
      border
      style="width: 100%"
    >
      <el-table-column prop="id" label="ID" width="80" />
      <el-table-column prop="name" label="Name" />
      <el-table-column prop="createTime" label="Create Time" width="180" />
      <el-table-column label="Actions" width="150" fixed="right">
        <template #default="{ row }">
          <el-button 
            type="primary" 
            link
            @click="handleEdit(row)"
          >
            Edit
          </el-button>
          <el-button 
            type="danger" 
            link
            @click="handleDelete(row)"
          >
            Delete
          </el-button>
        </template>
      </el-table-column>
    </el-table>

    <CategoryDialog
      v-model:visible="dialogVisible"
      :loading="loading"
      @addCategory="handleAddCategory"
      @close="handleDialogClose"
    />
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import CategoryDialog from '@/components/CategoryDialog.vue'

// State
const dialogVisible = ref(false)
const loading = ref(false)
const categories = ref<Category[]>([])

// Open dialog
const openDialog = () => {
  dialogVisible.value = true
}

// Add category
const handleAddCategory = async (name: string) => {
  try {
    loading.value = true
    // API call would go here
    await new Promise(resolve => setTimeout(resolve, 1000))
    
    categories.value.push({
      id: Date.now(),
      name,
      createTime: new Date().toLocaleString()
    })
    
    ElMessage.success('Category added successfully')
    dialogVisible.value = false
  } catch (error) {
    ElMessage.error('Failed to add category')
  } finally {
    loading.value = false
  }
}

// Dialog close
const handleDialogClose = () => {
  // Additional cleanup if needed
}
</script>

<style scoped>
.category-management {
  padding: 20px;
}

.header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 20px;
}

.header h2 {
  margin: 0;
  font-size: 20px;
  font-weight: 500;
}
</style>

Key Features

1. v-model Implementation

The core of our dialog component's two-way binding is implemented through a computed property:

const dialogVisible = computed({
  get() {
    return props.visible
  },
  set(value: boolean) {
    emits('update:visible', value)
  }
})

2. Form Validation

We implement comprehensive form validation with async support:

const handleConfirm = async () => {
  if (!formRef.value || props.loading) return
  
  try {
    const valid = await formRef.value.validate()
    if (valid) {
      emits('addCategory', form.value.categoryName)
    }
  } catch (error) {
    console.error('Validation failed:', error)
  }
}

3. Loading State

The component handles loading states gracefully:

const handleClose = () => {
  if (props.loading) return
  
  dialogVisible.value = false
  resetForm()
  emits('close')
}

Best Practices

  1. TypeScript Integration

    • Use proper type definitions for props, emits, and refs
    • Leverage TypeScript's type inference where possible
  2. Component Design

    • Follow Single Responsibility Principle
    • Implement proper prop validation
    • Handle edge cases and error states
  3. Performance Optimization

    • Use v-show for frequently toggled elements
    • Implement proper cleanup on component destruction
    • Avoid unnecessary re-renders
  4. User Experience

    • Add loading states
    • Implement keyboard navigation
    • Provide clear feedback for user actions

Common Pitfalls and Solutions

1. Form Reset Issues

Always reset the form state when closing the dialog:

const resetForm = () => {
  form.value.categoryName = ''
  formRef.value?.resetFields()
}

2. Validation Timing

Ensure validation happens at the right time:

<el-form
  :validate-on-rule-change="false"
  @submit.prevent
>

3. Loading State Management

Properly handle loading states to prevent multiple submissions:

if (props.loading) return

Conclusion

Building a robust dialog component in Vue 3 requires careful attention to various aspects:

  • Proper v-model implementation
  • Form validation
  • Loading states
  • TypeScript integration
  • Error handling

By following these patterns and best practices, you can create reusable, maintainable dialog components that provide a great user experience.

Resources

Remember that this implementation can be further customized based on your specific needs. The key is maintaining a balance between functionality, reusability, and maintainability.