Skip to content

Virtualized Select 虚拟化选择器

概述

Virtualized Select 虚拟化选择器是 Element Plus 提供的高性能选择器组件,专门用于处理大量数据的选择场景。通过虚拟滚动技术,它可以高效渲染数万条数据而不会造成性能问题,为用户提供流畅的交互体验。

主要特性

  • 虚拟滚动:只渲染可见区域的选项,支持大量数据
  • 高性能:优化的渲染机制,避免 DOM 节点过多
  • 多选支持:支持单选和多选模式
  • 过滤搜索:内置搜索过滤功能
  • 自定义模板:支持自定义选项和标签模板
  • 远程搜索:支持异步数据加载
  • 分组显示:支持选项分组展示
  • 标签管理:多选时的标签折叠和管理

适用场景

  • 大数据选择:需要从数千或数万条数据中选择
  • 用户选择器:企业级应用的用户/部门选择
  • 商品选择:电商系统的商品选择器
  • 地区选择:多级地区选择器
  • 标签管理:内容管理系统的标签选择
  • 数据筛选:报表系统的筛选条件选择

学习目标

  • 掌握 Virtualized Select 的基本概念和使用场景
  • 学会基础选择器功能的实现
  • 了解虚拟滚动的性能优势
  • 掌握多选和标签管理功能
  • 学会自定义选项模板
  • 了解远程搜索和数据加载
  • 掌握 API 的完整使用方法

基础用法

基本选择器

最简单的虚拟化选择器:

vue
<template>
  <div>
    <h4>基础用法</h4>
    <el-select-v2
      v-model="value"
      :options="options"
      placeholder="请选择"
      style="width: 240px"
    />
    <p>选中的值:{{ value }}</p>
  </div>
</template>

<script setup>
import { ref } from 'vue'

const value = ref('')

// 生成大量选项数据
const options = Array.from({ length: 10000 }, (_, index) => ({
  value: `option_${index}`,
  label: `选项 ${index + 1}`
}))
</script>

多选模式

支持选择多个选项:

vue
<template>
  <div>
    <h4>多选模式</h4>
    <el-select-v2
      v-model="multipleValue"
      :options="options"
      placeholder="请选择多个选项"
      multiple
      style="width: 240px"
    />
    <p>选中的值:{{ multipleValue }}</p>
  </div>
</template>

<script setup>
import { ref } from 'vue'

const multipleValue = ref([])

const options = Array.from({ length: 5000 }, (_, index) => ({
  value: `option_${index}`,
  label: `选项 ${index + 1}`
}))
</script>

不同尺寸

使用 size 属性改变选择器大小:

vue
<template>
  <div>
    <h4>不同尺寸</h4>
    
    <div style="margin-bottom: 20px;">
      <span>Large: </span>
      <el-select-v2
        v-model="value1"
        :options="options"
        placeholder="请选择"
        size="large"
        style="width: 240px"
      />
    </div>
    
    <div style="margin-bottom: 20px;">
      <span>Default: </span>
      <el-select-v2
        v-model="value2"
        :options="options"
        placeholder="请选择"
        style="width: 240px"
      />
    </div>
    
    <div>
      <span>Small: </span>
      <el-select-v2
        v-model="value3"
        :options="options"
        placeholder="请选择"
        size="small"
        style="width: 240px"
      />
    </div>
  </div>
</template>

<script setup>
import { ref } from 'vue'

const value1 = ref('')
const value2 = ref('')
const value3 = ref('')

const options = Array.from({ length: 1000 }, (_, index) => ({
  value: `option_${index}`,
  label: `选项 ${index + 1}`
}))
</script>

高级功能

标签折叠

多选时折叠显示标签:

vue
<template>
  <div>
    <h4>标签折叠</h4>
    
    <div style="margin-bottom: 20px;">
      <span>使用 collapse-tags: </span>
      <el-select-v2
        v-model="collapseValue"
        :options="options"
        placeholder="请选择"
        multiple
        collapse-tags
        style="width: 240px"
      />
    </div>
    
    <div style="margin-bottom: 20px;">
      <span>使用 collapse-tags-tooltip: </span>
      <el-select-v2
        v-model="tooltipValue"
        :options="options"
        placeholder="请选择"
        multiple
        collapse-tags
        collapse-tags-tooltip
        style="width: 240px"
      />
    </div>
    
    <div>
      <span>使用 max-collapse-tags: </span>
      <el-select-v2
        v-model="maxValue"
        :options="options"
        placeholder="请选择"
        multiple
        :max-collapse-tags="3"
        style="width: 240px"
      />
    </div>
  </div>
</template>

<script setup>
import { ref } from 'vue'

const collapseValue = ref([])
const tooltipValue = ref([])
const maxValue = ref([])

const options = Array.from({ length: 1000 }, (_, index) => ({
  value: `option_${index}`,
  label: `选项 ${index + 1}`
}))
</script>

可过滤搜索

启用搜索过滤功能:

vue
<template>
  <div>
    <h4>可过滤搜索</h4>
    <el-select-v2
      v-model="filterValue"
      :options="options"
      placeholder="请输入关键词搜索"
      filterable
      style="width: 240px"
    />
    <p>选中的值:{{ filterValue }}</p>
  </div>
</template>

<script setup>
import { ref } from 'vue'

const filterValue = ref('')

const options = Array.from({ length: 10000 }, (_, index) => ({
  value: `option_${index}`,
  label: `选项 ${index + 1} - 这是一个很长的选项描述`
}))
</script>

禁用状态

禁用选择器或特定选项:

vue
<template>
  <div>
    <h4>禁用状态</h4>
    
    <div style="margin-bottom: 20px;">
      <span>禁用选择器: </span>
      <el-select-v2
        v-model="disabledValue"
        :options="options"
        placeholder="禁用状态"
        disabled
        style="width: 240px"
      />
    </div>
    
    <div>
      <span>禁用部分选项: </span>
      <el-select-v2
        v-model="partialDisabledValue"
        :options="optionsWithDisabled"
        placeholder="请选择"
        style="width: 240px"
      />
    </div>
  </div>
</template>

<script setup>
import { ref } from 'vue'

const disabledValue = ref('')
const partialDisabledValue = ref('')

const options = Array.from({ length: 100 }, (_, index) => ({
  value: `option_${index}`,
  label: `选项 ${index + 1}`
}))

const optionsWithDisabled = Array.from({ length: 100 }, (_, index) => ({
  value: `option_${index}`,
  label: `选项 ${index + 1}`,
  disabled: index % 10 === 0 // 每10个选项禁用一个
}))
</script>

选项分组

对选项进行分组显示:

vue
<template>
  <div>
    <h4>选项分组</h4>
    <el-select-v2
      v-model="groupValue"
      :options="groupOptions"
      placeholder="请选择"
      style="width: 240px"
    />
    <p>选中的值:{{ groupValue }}</p>
  </div>
</template>

<script setup>
import { ref } from 'vue'

const groupValue = ref('')

const groupOptions = [
  {
    label: '前端技术',
    options: [
      { value: 'vue', label: 'Vue.js' },
      { value: 'react', label: 'React' },
      { value: 'angular', label: 'Angular' },
      { value: 'svelte', label: 'Svelte' }
    ]
  },
  {
    label: '后端技术',
    options: [
      { value: 'nodejs', label: 'Node.js' },
      { value: 'python', label: 'Python' },
      { value: 'java', label: 'Java' },
      { value: 'go', label: 'Go' }
    ]
  },
  {
    label: '数据库',
    options: [
      { value: 'mysql', label: 'MySQL' },
      { value: 'mongodb', label: 'MongoDB' },
      { value: 'redis', label: 'Redis' },
      { value: 'postgresql', label: 'PostgreSQL' }
    ]
  }
]
</script>

一键清除

支持清除所有选中的选项:

vue
<template>
  <div>
    <h4>一键清除</h4>
    
    <div style="margin-bottom: 20px;">
      <span>单选清除: </span>
      <el-select-v2
        v-model="clearValue1"
        :options="options"
        placeholder="请选择"
        clearable
        style="width: 240px"
        @clear="handleClear"
      />
    </div>
    
    <div>
      <span>多选清除: </span>
      <el-select-v2
        v-model="clearValue2"
        :options="options"
        placeholder="请选择"
        multiple
        clearable
        style="width: 240px"
        @clear="handleClear"
      />
    </div>
  </div>
</template>

<script setup>
import { ref } from 'vue'
import { ElMessage } from 'element-plus'

const clearValue1 = ref('')
const clearValue2 = ref([])

const options = Array.from({ length: 1000 }, (_, index) => ({
  value: `option_${index}`,
  label: `选项 ${index + 1}`
}))

const handleClear = () => {
  ElMessage.info('已清除选择')
}
</script>

自定义模板

自定义选项模板

自定义选项的显示内容:

vue
<template>
  <div>
    <h4>自定义选项模板</h4>
    <el-select-v2
      v-model="customValue"
      :options="customOptions"
      placeholder="请选择用户"
      style="width: 300px"
    >
      <template #default="{ item }">
        <div class="custom-option">
          <img :src="item.avatar" :alt="item.label" class="avatar" />
          <div class="info">
            <div class="name">{{ item.label }}</div>
            <div class="email">{{ item.email }}</div>
          </div>
          <el-tag :type="item.status === 'online' ? 'success' : 'info'" size="small">
            {{ item.status === 'online' ? '在线' : '离线' }}
          </el-tag>
        </div>
      </template>
    </el-select-v2>
  </div>
</template>

<script setup>
import { ref } from 'vue'

const customValue = ref('')

const customOptions = Array.from({ length: 1000 }, (_, index) => ({
  value: `user_${index}`,
  label: `用户 ${index + 1}`,
  email: `user${index + 1}@example.com`,
  avatar: `https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png`,
  status: index % 3 === 0 ? 'online' : 'offline'
}))
</script>

<style scoped>
.custom-option {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 8px 0;
}

.avatar {
  width: 32px;
  height: 32px;
  border-radius: 50%;
  object-fit: cover;
}

.info {
  flex: 1;
}

.name {
  font-weight: 500;
  color: #303133;
}

.email {
  font-size: 12px;
  color: #909399;
  margin-top: 2px;
}
</style>

自定义标签模板

自定义多选时标签的显示:

vue
<template>
  <div>
    <h4>自定义标签模板</h4>
    <el-select-v2
      v-model="tagValue"
      :options="tagOptions"
      placeholder="请选择技术栈"
      multiple
      style="width: 400px"
    >
      <template #tag="{ props, onClose }">
        <el-tag
          v-bind="props"
          closable
          :type="getTagType(props.value)"
          @close="onClose"
        >
          {{ getTagLabel(props.value) }}
        </el-tag>
      </template>
    </el-select-v2>
  </div>
</template>

<script setup>
import { ref } from 'vue'

const tagValue = ref([])

const tagOptions = [
  { value: 'vue', label: 'Vue.js', category: 'frontend' },
  { value: 'react', label: 'React', category: 'frontend' },
  { value: 'angular', label: 'Angular', category: 'frontend' },
  { value: 'nodejs', label: 'Node.js', category: 'backend' },
  { value: 'python', label: 'Python', category: 'backend' },
  { value: 'java', label: 'Java', category: 'backend' },
  { value: 'mysql', label: 'MySQL', category: 'database' },
  { value: 'mongodb', label: 'MongoDB', category: 'database' },
  { value: 'redis', label: 'Redis', category: 'database' }
]

const getTagType = (value) => {
  const option = tagOptions.find(opt => opt.value === value)
  if (!option) return 'info'
  
  switch (option.category) {
    case 'frontend': return 'primary'
    case 'backend': return 'success'
    case 'database': return 'warning'
    default: return 'info'
  }
}

const getTagLabel = (value) => {
  const option = tagOptions.find(opt => opt.value === value)
  return option ? option.label : value
}
</script>

自定义下拉菜单头部和底部

自定义下拉菜单的头部和底部内容:

vue
<template>
  <div>
    <h4>自定义下拉菜单</h4>
    <el-select-v2
      v-model="menuValue"
      :options="menuOptions"
      placeholder="请选择"
      multiple
      style="width: 300px"
    >
      <template #header>
        <div class="menu-header">
          <el-input
            v-model="searchKeyword"
            placeholder="搜索选项..."
            size="small"
            @input="handleSearch"
          >
            <template #prefix>
              <el-icon><Search /></el-icon>
            </template>
          </el-input>
        </div>
      </template>
      
      <template #footer>
        <div class="menu-footer">
          <el-button size="small" @click="selectAll">全选</el-button>
          <el-button size="small" @click="clearAll">清空</el-button>
          <span class="count">共 {{ filteredOptions.length }} 项</span>
        </div>
      </template>
    </el-select-v2>
  </div>
</template>

<script setup>
import { ref, computed } from 'vue'
import { Search } from '@element-plus/icons-vue'

const menuValue = ref([])
const searchKeyword = ref('')

const allOptions = Array.from({ length: 100 }, (_, index) => ({
  value: `option_${index}`,
  label: `选项 ${index + 1}`
}))

const filteredOptions = computed(() => {
  if (!searchKeyword.value) return allOptions
  return allOptions.filter(option => 
    option.label.toLowerCase().includes(searchKeyword.value.toLowerCase())
  )
})

const menuOptions = computed(() => filteredOptions.value)

const handleSearch = () => {
  // 搜索逻辑已在 computed 中处理
}

const selectAll = () => {
  menuValue.value = filteredOptions.value.map(option => option.value)
}

const clearAll = () => {
  menuValue.value = []
}
</script>

<style scoped>
.menu-header {
  padding: 8px;
  border-bottom: 1px solid #e4e7ed;
}

.menu-footer {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 8px;
  border-top: 1px solid #e4e7ed;
  background-color: #f5f7fa;
}

.count {
  font-size: 12px;
  color: #909399;
}
</style>

远程搜索

基础远程搜索

从服务器搜索数据:

vue
<template>
  <div>
    <h4>远程搜索</h4>
    <el-select-v2
      v-model="remoteValue"
      :options="remoteOptions"
      placeholder="请输入关键词搜索"
      filterable
      remote
      :remote-method="remoteSearch"
      :loading="loading"
      style="width: 300px"
    />
    <p>选中的值:{{ remoteValue }}</p>
  </div>
</template>

<script setup>
import { ref } from 'vue'

const remoteValue = ref('')
const remoteOptions = ref([])
const loading = ref(false)

// 模拟远程数据
const mockData = Array.from({ length: 10000 }, (_, index) => ({
  value: `remote_${index}`,
  label: `远程选项 ${index + 1}`,
  description: `这是第 ${index + 1} 个远程选项的描述`
}))

const remoteSearch = async (query) => {
  if (!query) {
    remoteOptions.value = []
    return
  }
  
  loading.value = true
  
  // 模拟网络延迟
  await new Promise(resolve => setTimeout(resolve, 300))
  
  const results = mockData.filter(item => 
    item.label.toLowerCase().includes(query.toLowerCase())
  ).slice(0, 50) // 限制返回结果数量
  
  remoteOptions.value = results
  loading.value = false
}
</script>

创建临时选项

允许用户创建不在选项列表中的新选项:

vue
<template>
  <div>
    <h4>创建临时选项</h4>
    <el-select-v2
      v-model="createValue"
      :options="createOptions"
      placeholder="输入新选项或选择现有选项"
      filterable
      allow-create
      default-first-option
      :reserve-keyword="false"
      style="width: 300px"
      @change="handleCreateChange"
    />
    <p>选中的值:{{ createValue }}</p>
  </div>
</template>

<script setup>
import { ref } from 'vue'
import { ElMessage } from 'element-plus'

const createValue = ref('')
const createOptions = ref([
  { value: 'option1', label: '选项1' },
  { value: 'option2', label: '选项2' },
  { value: 'option3', label: '选项3' },
  { value: 'option4', label: '选项4' },
  { value: 'option5', label: '选项5' }
])

const handleCreateChange = (value) => {
  // 检查是否是新创建的选项
  const exists = createOptions.value.some(option => option.value === value)
  if (!exists && value) {
    ElMessage.success(`创建了新选项:${value}`)
    // 可以在这里将新选项添加到选项列表中
    createOptions.value.push({
      value: value,
      label: value
    })
  }
}
</script>

实际应用示例

员工选择器

企业级应用中的员工选择组件:

vue
<template>
  <div class="employee-selector">
    <h3>员工选择器</h3>
    
    <div class="selector-container">
      <el-select-v2
        v-model="selectedEmployees"
        :options="employeeOptions"
        placeholder="搜索并选择员工"
        multiple
        filterable
        remote
        :remote-method="searchEmployees"
        :loading="loading"
        collapse-tags
        collapse-tags-tooltip
        :max-collapse-tags="3"
        clearable
        style="width: 100%"
        @change="handleEmployeeChange"
      >
        <template #default="{ item }">
          <div class="employee-option">
            <img :src="item.avatar" :alt="item.label" class="employee-avatar" />
            <div class="employee-info">
              <div class="employee-name">{{ item.label }}</div>
              <div class="employee-details">
                <span class="department">{{ item.department }}</span>
                <span class="position">{{ item.position }}</span>
              </div>
            </div>
            <div class="employee-status">
              <el-tag :type="item.status === 'active' ? 'success' : 'info'" size="small">
                {{ item.status === 'active' ? '在职' : '离职' }}
              </el-tag>
            </div>
          </div>
        </template>
        
        <template #tag="{ props, onClose }">
          <el-tag
            v-bind="props"
            closable
            type="primary"
            @close="onClose"
          >
            {{ getEmployeeName(props.value) }}
          </el-tag>
        </template>
        
        <template #header>
          <div class="selector-header">
            <div class="search-tips">
              <el-icon><InfoFilled /></el-icon>
              <span>支持按姓名、部门、职位搜索</span>
            </div>
          </div>
        </template>
        
        <template #footer>
          <div class="selector-footer">
            <span class="result-count">找到 {{ employeeOptions.length }} 名员工</span>
            <el-button size="small" @click="clearSelection">清空选择</el-button>
          </div>
        </template>
      </el-select-v2>
    </div>
    
    <div v-if="selectedEmployees.length > 0" class="selected-employees">
      <h4>已选择的员工 ({{ selectedEmployees.length }})</h4>
      <div class="employee-list">
        <div
          v-for="employeeId in selectedEmployees"
          :key="employeeId"
          class="employee-card"
        >
          <img :src="getEmployeeById(employeeId)?.avatar" class="card-avatar" />
          <div class="card-info">
            <h5>{{ getEmployeeById(employeeId)?.label }}</h5>
            <p>{{ getEmployeeById(employeeId)?.department }} - {{ getEmployeeById(employeeId)?.position }}</p>
            <p class="email">{{ getEmployeeById(employeeId)?.email }}</p>
          </div>
          <el-button
            size="small"
            type="danger"
            @click="removeEmployee(employeeId)"
          >
            移除
          </el-button>
        </div>
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, computed } from 'vue'
import { InfoFilled } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'

const selectedEmployees = ref([])
const employeeOptions = ref([])
const loading = ref(false)
const allEmployees = ref([])

// 模拟员工数据
const generateEmployees = () => {
  const departments = ['技术部', '产品部', '设计部', '运营部', '市场部', '人事部', '财务部']
  const positions = ['工程师', '高级工程师', '技术专家', '经理', '总监', '专员', '主管']
  
  return Array.from({ length: 5000 }, (_, index) => ({
    value: `emp_${index}`,
    label: `员工${index + 1}`,
    email: `employee${index + 1}@company.com`,
    department: departments[index % departments.length],
    position: positions[index % positions.length],
    status: index % 10 === 0 ? 'inactive' : 'active',
    avatar: `https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png`
  }))
}

allEmployees.value = generateEmployees()

const searchEmployees = async (query) => {
  if (!query) {
    employeeOptions.value = allEmployees.value.slice(0, 50)
    return
  }
  
  loading.value = true
  
  // 模拟网络延迟
  await new Promise(resolve => setTimeout(resolve, 200))
  
  const searchQuery = query.toLowerCase()
  const results = allEmployees.value.filter(emp => 
    emp.label.toLowerCase().includes(searchQuery) ||
    emp.department.toLowerCase().includes(searchQuery) ||
    emp.position.toLowerCase().includes(searchQuery) ||
    emp.email.toLowerCase().includes(searchQuery)
  ).slice(0, 100)
  
  employeeOptions.value = results
  loading.value = false
}

const getEmployeeName = (employeeId) => {
  const employee = allEmployees.value.find(emp => emp.value === employeeId)
  return employee ? employee.label : employeeId
}

const getEmployeeById = (employeeId) => {
  return allEmployees.value.find(emp => emp.value === employeeId)
}

const handleEmployeeChange = (value) => {
  ElMessage.success(`已选择 ${value.length} 名员工`)
}

const removeEmployee = (employeeId) => {
  const index = selectedEmployees.value.indexOf(employeeId)
  if (index > -1) {
    selectedEmployees.value.splice(index, 1)
    ElMessage.info('已移除员工')
  }
}

const clearSelection = () => {
  selectedEmployees.value = []
  ElMessage.info('已清空选择')
}

// 初始化显示一些员工
searchEmployees('')
</script>

<style scoped>
.employee-selector {
  max-width: 800px;
  padding: 20px;
  border: 1px solid #dcdfe6;
  border-radius: 8px;
}

.selector-container {
  margin-bottom: 24px;
}

.employee-option {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 8px 0;
}

.employee-avatar {
  width: 36px;
  height: 36px;
  border-radius: 50%;
  object-fit: cover;
}

.employee-info {
  flex: 1;
}

.employee-name {
  font-weight: 500;
  color: #303133;
  margin-bottom: 4px;
}

.employee-details {
  display: flex;
  gap: 12px;
  font-size: 12px;
  color: #909399;
}

.employee-status {
  margin-left: auto;
}

.selector-header {
  padding: 8px 12px;
  background-color: #f5f7fa;
  border-bottom: 1px solid #e4e7ed;
}

.search-tips {
  display: flex;
  align-items: center;
  gap: 6px;
  font-size: 12px;
  color: #606266;
}

.selector-footer {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 8px 12px;
  background-color: #f5f7fa;
  border-top: 1px solid #e4e7ed;
}

.result-count {
  font-size: 12px;
  color: #909399;
}

.selected-employees {
  padding-top: 20px;
  border-top: 1px solid #e4e7ed;
}

.selected-employees h4 {
  margin: 0 0 16px 0;
  color: #303133;
}

.employee-list {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
  gap: 16px;
}

.employee-card {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 16px;
  background-color: #f5f7fa;
  border-radius: 8px;
  border: 1px solid #e4e7ed;
}

.card-avatar {
  width: 48px;
  height: 48px;
  border-radius: 50%;
  object-fit: cover;
}

.card-info {
  flex: 1;
}

.card-info h5 {
  margin: 0 0 4px 0;
  color: #303133;
}

.card-info p {
  margin: 2px 0;
  font-size: 12px;
  color: #606266;
}

.email {
  color: #909399 !important;
}
</style>

商品选择器

电商系统中的商品选择组件:

vue
<template>
  <div class="product-selector">
    <h3>商品选择器</h3>
    
    <div class="filters">
      <el-select
        v-model="categoryFilter"
        placeholder="选择分类"
        clearable
        style="width: 150px; margin-right: 12px;"
        @change="handleCategoryChange"
      >
        <el-option
          v-for="category in categories"
          :key="category.value"
          :label="category.label"
          :value="category.value"
        />
      </el-select>
      
      <el-select
        v-model="priceFilter"
        placeholder="价格范围"
        clearable
        style="width: 150px;"
        @change="handlePriceChange"
      >
        <el-option label="0-100" value="0-100" />
        <el-option label="100-500" value="100-500" />
        <el-option label="500-1000" value="500-1000" />
        <el-option label="1000+" value="1000+" />
      </el-select>
    </div>
    
    <div class="selector-container">
      <el-select-v2
        v-model="selectedProducts"
        :options="filteredProducts"
        placeholder="搜索商品名称、SKU或描述"
        multiple
        filterable
        remote
        :remote-method="searchProducts"
        :loading="loading"
        collapse-tags
        :max-collapse-tags="2"
        clearable
        style="width: 100%"
        @change="handleProductChange"
      >
        <template #default="{ item }">
          <div class="product-option">
            <img :src="item.image" :alt="item.label" class="product-image" />
            <div class="product-info">
              <div class="product-name">{{ item.label }}</div>
              <div class="product-details">
                <span class="sku">SKU: {{ item.sku }}</span>
                <span class="category">{{ item.category }}</span>
              </div>
              <div class="product-price">¥{{ item.price }}</div>
            </div>
            <div class="product-stock">
              <el-tag :type="item.stock > 10 ? 'success' : item.stock > 0 ? 'warning' : 'danger'" size="small">
                库存: {{ item.stock }}
              </el-tag>
            </div>
          </div>
        </template>
        
        <template #footer>
          <div class="selector-footer">
            <span class="result-count">找到 {{ filteredProducts.length }} 个商品</span>
            <span class="total-value">总价值: ¥{{ totalValue }}</span>
          </div>
        </template>
      </el-select-v2>
    </div>
    
    <div v-if="selectedProducts.length > 0" class="selected-products">
      <h4>已选择的商品 ({{ selectedProducts.length }})</h4>
      <div class="product-summary">
        <div class="summary-item">
          <span class="label">总数量:</span>
          <span class="value">{{ selectedProducts.length }} 件</span>
        </div>
        <div class="summary-item">
          <span class="label">总价值:</span>
          <span class="value">¥{{ totalValue }}</span>
        </div>
      </div>
      
      <div class="product-list">
        <div
          v-for="productId in selectedProducts"
          :key="productId"
          class="product-card"
        >
          <img :src="getProductById(productId)?.image" class="card-image" />
          <div class="card-info">
            <h5>{{ getProductById(productId)?.label }}</h5>
            <p>SKU: {{ getProductById(productId)?.sku }}</p>
            <p>分类: {{ getProductById(productId)?.category }}</p>
            <div class="price-stock">
              <span class="price">¥{{ getProductById(productId)?.price }}</span>
              <span class="stock">库存: {{ getProductById(productId)?.stock }}</span>
            </div>
          </div>
          <el-button
            size="small"
            type="danger"
            @click="removeProduct(productId)"
          >
            移除
          </el-button>
        </div>
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, computed } from 'vue'
import { ElMessage } from 'element-plus'

const selectedProducts = ref([])
const filteredProducts = ref([])
const loading = ref(false)
const categoryFilter = ref('')
const priceFilter = ref('')
const allProducts = ref([])

const categories = [
  { value: 'electronics', label: '电子产品' },
  { value: 'clothing', label: '服装' },
  { value: 'books', label: '图书' },
  { value: 'home', label: '家居' },
  { value: 'sports', label: '运动' }
]

// 生成模拟商品数据
const generateProducts = () => {
  const categoryNames = ['电子产品', '服装', '图书', '家居', '运动']
  const productNames = {
    '电子产品': ['iPhone', 'iPad', 'MacBook', '耳机', '充电器'],
    '服装': ['T恤', '牛仔裤', '连衣裙', '外套', '运动鞋'],
    '图书': ['小说', '技术书籍', '历史', '传记', '科幻'],
    '家居': ['沙发', '餐桌', '床', '衣柜', '台灯'],
    '运动': ['篮球', '足球', '跑鞋', '健身器材', '瑜伽垫']
  }
  
  return Array.from({ length: 10000 }, (_, index) => {
    const category = categoryNames[index % categoryNames.length]
    const productName = productNames[category][index % productNames[category].length]
    const price = Math.floor(Math.random() * 2000) + 10
    const stock = Math.floor(Math.random() * 100)
    
    return {
      value: `product_${index}`,
      label: `${productName} ${index + 1}`,
      sku: `SKU${String(index + 1).padStart(6, '0')}`,
      category,
      price,
      stock,
      image: 'https://cube.elemecdn.com/6/94/4d3ea53c084bad6931a56d5158a48png.png'
    }
  })
}

allProducts.value = generateProducts()

const totalValue = computed(() => {
  return selectedProducts.value.reduce((total, productId) => {
    const product = getProductById(productId)
    return total + (product ? product.price : 0)
  }, 0)
})

const searchProducts = async (query) => {
  loading.value = true
  
  // 模拟网络延迟
  await new Promise(resolve => setTimeout(resolve, 200))
  
  let results = allProducts.value
  
  // 应用分类过滤
  if (categoryFilter.value) {
    results = results.filter(product => product.category === categoryFilter.value)
  }
  
  // 应用价格过滤
  if (priceFilter.value) {
    const [min, max] = priceFilter.value.split('-').map(Number)
    if (max) {
      results = results.filter(product => product.price >= min && product.price <= max)
    } else {
      results = results.filter(product => product.price >= min)
    }
  }
  
  // 应用搜索查询
  if (query) {
    const searchQuery = query.toLowerCase()
    results = results.filter(product => 
      product.label.toLowerCase().includes(searchQuery) ||
      product.sku.toLowerCase().includes(searchQuery) ||
      product.category.toLowerCase().includes(searchQuery)
    )
  }
  
  filteredProducts.value = results.slice(0, 100)
  loading.value = false
}

const getProductById = (productId) => {
  return allProducts.value.find(product => product.value === productId)
}

const handleCategoryChange = () => {
  searchProducts('')
}

const handlePriceChange = () => {
  searchProducts('')
}

const handleProductChange = (value) => {
  ElMessage.success(`已选择 ${value.length} 个商品`)
}

const removeProduct = (productId) => {
  const index = selectedProducts.value.indexOf(productId)
  if (index > -1) {
    selectedProducts.value.splice(index, 1)
    ElMessage.info('已移除商品')
  }
}

// 初始化显示一些商品
searchProducts('')
</script>

<style scoped>
.product-selector {
  max-width: 900px;
  padding: 20px;
  border: 1px solid #dcdfe6;
  border-radius: 8px;
}

.filters {
  display: flex;
  margin-bottom: 16px;
}

.selector-container {
  margin-bottom: 24px;
}

.product-option {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 8px 0;
}

.product-image {
  width: 48px;
  height: 48px;
  border-radius: 4px;
  object-fit: cover;
}

.product-info {
  flex: 1;
}

.product-name {
  font-weight: 500;
  color: #303133;
  margin-bottom: 4px;
}

.product-details {
  display: flex;
  gap: 12px;
  font-size: 12px;
  color: #909399;
  margin-bottom: 4px;
}

.product-price {
  font-weight: 600;
  color: #f56c6c;
}

.product-stock {
  margin-left: auto;
}

.selector-footer {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 8px 12px;
  background-color: #f5f7fa;
  border-top: 1px solid #e4e7ed;
  font-size: 12px;
}

.result-count {
  color: #909399;
}

.total-value {
  color: #f56c6c;
  font-weight: 600;
}

.selected-products {
  padding-top: 20px;
  border-top: 1px solid #e4e7ed;
}

.selected-products h4 {
  margin: 0 0 16px 0;
  color: #303133;
}

.product-summary {
  display: flex;
  gap: 24px;
  margin-bottom: 16px;
  padding: 12px;
  background-color: #f5f7fa;
  border-radius: 6px;
}

.summary-item {
  display: flex;
  gap: 8px;
}

.summary-item .label {
  color: #606266;
}

.summary-item .value {
  font-weight: 600;
  color: #303133;
}

.product-list {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
  gap: 16px;
}

.product-card {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 16px;
  background-color: #f5f7fa;
  border-radius: 8px;
  border: 1px solid #e4e7ed;
}

.card-image {
  width: 60px;
  height: 60px;
  border-radius: 6px;
  object-fit: cover;
}

.card-info {
  flex: 1;
}

.card-info h5 {
  margin: 0 0 4px 0;
  color: #303133;
}

.card-info p {
  margin: 2px 0;
  font-size: 12px;
  color: #606266;
}

.price-stock {
  display: flex;
  justify-content: space-between;
  margin-top: 8px;
}

.price {
  font-weight: 600;
  color: #f56c6c;
}

.stock {
  font-size: 12px;
  color: #909399;
}
</style>

API

Attributes

属性名说明类型默认值
model-value / v-model选中项绑定值string / number / boolean / object / array
options选项的数据源array
props配置选项,具体看下表object
multiple是否多选booleanfalse
disabled是否禁用booleanfalse
value-key作为 value 唯一标识的键名,绑定值为对象类型时必填stringvalue
size组件大小enum: '' / 'large' / 'small'''
clearable是否可以清空选项booleanfalse
clear-icon自定义清除图标string / objectCircleClose
collapse-tags多选时是否将选中值按文字的形式展示booleanfalse
multiple-limit多选时可被选择的最大数目。当被设置为0时,可被选择的数目不设限number0
name选择器的原生name属性string
effecttooltip 主题,内置了 dark / light 两种enum: 'dark' / 'light'light
autocomplete自动补全stringoff
placeholder占位符string请选择
filterable是否可搜索booleanfalse
allow-create是否允许用户创建新条目,需配合 filterable 使用booleanfalse
filter-method自定义搜索方法function
remote是否为远程搜索booleanfalse
remote-method远程搜索方法function
loading是否正在从远程获取数据booleanfalse
loading-text远程加载时显示的文字string加载中
no-match-text搜索条件无匹配时显示的文字,也可以使用slot="empty"设置string无匹配数据
no-data-text选项为空时显示的文字,也可以使用slot="empty"设置string无数据
popper-classSelect 下拉框的类名string
reserve-keyword多选且可搜索时,是否在选中一个选项后保留当前的搜索关键词booleantrue
default-first-option在输入框按下回车,选择第一个匹配项。需配合 filterable 或 remote 使用booleanfalse
teleported是否将弹出层元素插入 append-to 指向的元素下booleantrue
persistent当下拉选择器未被激活并且persistent设置为false,选择器会被删除booleantrue
automatic-dropdown对于不可搜索的 Select,是否在输入框获得焦点后自动弹出选项菜单booleanfalse
fit-input-width下拉框的宽度是否与输入框相同booleanfalse
suffix-icon自定义后缀图标组件string / ComponentArrowDown
tag-type标签类型enum: 'success' / 'info' / 'warning' / 'danger'info
validate-event输入时是否触发表单的校验booleantrue
placement下拉框出现的位置enumbottom-start
collapse-tags-tooltip当鼠标悬停于折叠标签的文本时,是否显示所有选中的标签。要使用此属性,collapse-tags属性必须设定为 truebooleanfalse
max-collapse-tags需要显示的 Tag 的最大数量 只有当 collapse-tags 设置为 false 时才会生效number1
popper-optionspopper.js 参数object{}
aria-label等价于原生 input aria-label 属性string
empty-values组件的空值配置 参考config-providerarray
value-on-clear清空选项的值 参考config-providerstring / number / boolean / Function

Props 配置

属性名说明类型默认值
value指定选项的值为选项对象的某个属性值stringvalue
label指定选项标签为选项对象的某个属性值stringlabel
children指定选项的子选项为选项对象的某个属性值stringchildren
disabled指定选项的禁用为选项对象的某个属性值stringdisabled

Events

事件名说明回调参数
change选中值发生变化时触发目前的选中值
visible-change下拉框出现/隐藏时触发出现则为 true,隐藏则为 false
remove-tag多选模式下移除tag时触发移除的tag值
clear可清空的单选模式下用户点击清空按钮时触发
blur当 input 失去焦点时触发(event: Event)
focus当 input 获得焦点时触发(event: Event)

Slots

插槽名说明子标签
default自定义选项的内容,参数为
header下拉选项顶部的内容
footer下拉选项底部的内容
tag自定义标签内容,参数为
loading自定义加载中内容
empty自定义当选项为空时的内容

Methods

方法名说明参数
focus使选择器的输入框获取焦点
blur使选择器的输入框失去焦点,并隐藏下拉框

最佳实践

性能优化

  1. 合理设置选项数量

    • 单次渲染的选项数量不宜过多
    • 使用远程搜索减少初始数据量
    • 合理使用分页或懒加载
  2. 优化搜索体验

    • 设置合适的防抖时间
    • 提供搜索提示和帮助信息
    • 支持多种搜索方式(拼音、首字母等)
  3. 内存管理

    • 及时清理不需要的数据
    • 避免在组件中存储大量数据
    • 使用虚拟滚动减少DOM节点

用户体验优化

  1. 交互反馈

    • 提供加载状态提示
    • 显示搜索结果数量
    • 合理的错误提示信息
  2. 视觉设计

    • 保持选项样式一致
    • 合理使用图标和标签
    • 适配不同屏幕尺寸
  3. 键盘操作

    • 支持方向键导航
    • 支持回车选择
    • 支持ESC关闭

数据处理

  1. 数据结构设计

    • 统一的数据格式
    • 合理的字段命名
    • 支持扩展属性
  2. 搜索优化

    • 前端搜索 vs 后端搜索
    • 搜索算法选择
    • 结果排序和高亮

常见问题

性能相关

Q: 选项数据很多时,组件卡顿怎么办?

A: 使用虚拟化选择器就是为了解决这个问题:

vue
<template>
  <!-- 使用 el-select-v2 而不是 el-select -->
  <el-select-v2
    v-model="value"
    :options="largeDataset"
    placeholder="请选择"
    filterable
    remote
    :remote-method="searchMethod"
  />
</template>

Q: 如何优化远程搜索的性能?

A: 使用防抖和缓存:

javascript
import { debounce } from 'lodash-es'

const searchCache = new Map()

const searchMethod = debounce(async (query) => {
  if (searchCache.has(query)) {
    options.value = searchCache.get(query)
    return
  }
  
  const results = await fetchData(query)
  searchCache.set(query, results)
  options.value = results
}, 300)

Q: 多选时标签太多怎么办?

A: 使用标签折叠功能:

vue
<template>
  <el-select-v2
    v-model="value"
    :options="options"
    multiple
    collapse-tags
    collapse-tags-tooltip
    :max-collapse-tags="3"
  />
</template>

数据相关

Q: 如何处理分组数据?

A: 使用嵌套的数据结构:

javascript
const groupOptions = [
  {
    label: '分组1',
    options: [
      { value: 'option1', label: '选项1' },
      { value: 'option2', label: '选项2' }
    ]
  },
  {
    label: '分组2',
    options: [
      { value: 'option3', label: '选项3' },
      { value: 'option4', label: '选项4' }
    ]
  }
]

Q: 如何自定义选项的显示格式?

A: 使用默认插槽:

vue
<template>
  <el-select-v2 v-model="value" :options="options">
    <template #default="{ item }">
      <div class="custom-option">
        <span class="label">{{ item.label }}</span>
        <span class="description">{{ item.description }}</span>
      </div>
    </template>
  </el-select-v2>
</template>

样式相关

Q: 如何自定义下拉框的样式?

A: 使用 popper-class 属性:

vue
<template>
  <el-select-v2
    v-model="value"
    :options="options"
    popper-class="custom-select-dropdown"
  />
</template>

<style>
.custom-select-dropdown {
  max-height: 300px;
}

.custom-select-dropdown .el-select-dropdown__item {
  padding: 12px 20px;
}
</style>

Q: 如何调整选择器的宽度?

A: 直接设置样式或使用 fit-input-width 属性:

vue
<template>
  <!-- 方式1:直接设置宽度 -->
  <el-select-v2
    v-model="value"
    :options="options"
    style="width: 300px"
  />
  
  <!-- 方式2:下拉框宽度跟随输入框 -->
  <el-select-v2
    v-model="value"
    :options="options"
    fit-input-width
    style="width: 200px"
  />
</template>

总结

Virtualized Select 虚拟化选择器是 Element Plus 为处理大量数据选择场景而设计的高性能组件。它具有以下特点:

核心特点

  • 高性能:通过虚拟滚动技术,可以流畅处理数万条数据
  • 功能完整:支持单选、多选、搜索、远程加载等完整功能
  • 高度定制:提供丰富的插槽和配置选项,满足各种定制需求
  • 用户友好:优秀的交互体验和无障碍访问支持
  • 易于集成:简单的 API 设计,易于在项目中集成使用

适用场景

  • 企业级应用:员工选择、部门选择、权限分配等
  • 电商系统:商品选择、分类筛选、库存管理等
  • 数据管理:大量数据的筛选和选择操作
  • 内容管理:标签管理、分类选择等
  • 报表系统:数据维度选择、筛选条件设置等

最佳实践建议

  1. 性能优先:合理使用虚拟滚动和远程搜索
  2. 用户体验:提供清晰的反馈和帮助信息
  3. 数据管理:设计合理的数据结构和缓存策略
  4. 样式统一:保持与整体设计风格的一致性
  5. 无障碍访问:确保组件的可访问性

通过合理使用 Virtualized Select 组件,可以在保证性能的同时,为用户提供优秀的数据选择体验。

参考资料

Element Plus Study Guide