手把手实现支持百万级数据量、高可用和可扩展性的穿梭框组件

329 阅读6分钟

Vue 2 企业级穿梭框组件开发实践

前言

在企业级应用开发中,穿梭框是一个常见的交互组件,用于在两个列表之间进行数据的选择和移动。本文将详细介绍如何开发一个高性能、易用的 Vue 2 穿梭框组件。

需求分析

在开始开发之前,我们需要明确穿梭框组件的核心需求:

功能需求

  1. 基础功能:双列表展示,支持选择和移动
  2. 搜索功能:实时过滤列表项
  3. 全选功能:快速选择所有项
  4. 自定义渲染:支持复杂内容展示
  5. 大数据支持:处理大量数据时保持性能

非功能需求

  1. 性能:支持1000+数据项流畅操作
  2. 易用性:简洁的API设计
  3. 可扩展性:支持自定义样式和行为
  4. 无障碍:符合可访问性标准

技术选型

框架选择

  • Vue 2.7:充分利用 Composition API 特性
  • Vite:快速的开发构建工具

开发策略

  • 组件化:单一职责,易于维护
  • 响应式:数据驱动的交互逻辑
  • 性能优化:虚拟滚动、防抖等技术

核心实现

1. 组件结构设计

<template>
  <div class="transfer-container">
    <!-- 左侧面板 -->
    <div class="transfer-panel">
      <div class="transfer-header">...</div>
      <div class="transfer-body">
        <div class="transfer-search">...</div>
        <div class="transfer-list">...</div>
      </div>
      <div class="transfer-footer">...</div>
    </div>
    
    <!-- 操作按钮 -->
    <div class="transfer-buttons">...</div>
    
    <!-- 右侧面板 -->
    <div class="transfer-panel">...</div>
  </div>
</template>

2. 数据流设计

// 计算属性实现数据分离
computed: {
  leftData() {
    return this.data.filter(item => 
      !this.value.includes(item[this.keyProp])
    )
  },
  rightData() {
    return this.data.filter(item => 
      this.value.includes(item[this.keyProp])
    )
  }
}

3. 搜索功能实现

computed: {
  filteredLeftData() {
    if (!this.filterable || !this.leftFilterText) {
      return this.leftData
    }
    return this.leftData.filter(item => 
      this.renderLabel(item)
        .toLowerCase()
        .includes(this.leftFilterText.toLowerCase())
    )
  }
}

4. 全选功能实现

computed: {
  leftCheckAll: {
    get() {
      return this.leftCheckedCount === this.filteredLeftData.length 
        && this.filteredLeftData.length > 0
    },
    set(val) {
      if (val) {
        this.leftChecked = this.filteredLeftData
          .filter(item => !item.disabled)
          .map(item => item[this.keyProp])
      } else {
        this.leftChecked = []
      }
    }
  }
}

性能优化

1. 计算属性缓存

利用 Vue 的计算属性缓存机制,避免不必要的重复计算:

computed: {
  // 只有当依赖的 data 或 value 变化时才重新计算
  leftData() {
    return this.data.filter(item => 
      !this.value.includes(item[this.keyProp])
    )
  }
}

2. 事件防抖

对搜索功能进行防抖处理:

import { debounce } from 'lodash'

export default {
  data() {
    return {
      searchDebounce: debounce(this.handleSearch, 300)
    }
  }
}

3. 虚拟滚动 (可选)

对于超大数据量,可以实现虚拟滚动:

// 只渲染可见区域的数据
const visibleItems = items.slice(startIndex, endIndex)

用户体验优化

1. 加载状态

<template>
  <div class="transfer-list" v-loading="loading">
    <!-- 列表内容 -->
  </div>
</template>

2. 空状态处理

<template>
  <div class="transfer-empty" v-if="filteredLeftData.length === 0">
    <p>暂无数据</p>
  </div>
</template>

3. 过渡动画

.transfer-item {
  transition: all 0.2s ease;
}

.transfer-item:hover {
  background-color: #f5f7fa;
}

可访问性支持

1. 键盘导航

methods: {
  handleKeydown(event) {
    switch (event.key) {
      case 'ArrowUp':
        this.moveFocusUp()
        break
      case 'ArrowDown':
        this.moveFocusDown()
        break
      case ' ':
      case 'Enter':
        this.toggleSelection()
        break
    }
  }
}

2. ARIA 标签

<template>
  <div
    class="transfer-item"
    role="option"
    :aria-selected="isSelected"
    :aria-disabled="item.disabled"
  >
    <!-- 内容 -->
  </div>
</template>

测试策略

1. 单元测试

describe('Transfer Component', () => {
  test('should render correctly', () => {
    const wrapper = mount(Transfer, {
      propsData: {
        data: mockData,
        value: []
      }
    })
    expect(wrapper.exists()).toBe(true)
  })
  
  test('should handle selection', async () => {
    const wrapper = mount(Transfer, {
      propsData: {
        data: mockData,
        value: []
      }
    })
    
    const checkbox = wrapper.find('.transfer-checkbox')
    await checkbox.trigger('click')
    
    expect(wrapper.emitted().change).toBeTruthy()
  })
})

2. 性能测试

// 大数据量测试
const bigData = Array.from({ length: 10000 }, (_, i) => ({
  key: i,
  label: `Item ${i}`
}))

// 测试渲染性能
const startTime = performance.now()
const wrapper = mount(Transfer, {
  propsData: { data: bigData, value: [] }
})
const endTime = performance.now()
console.log(`渲染时间: ${endTime - startTime}ms`)

最佳实践

1. 数据结构设计

// 推荐的数据结构
const goodData = [
  {
    key: 'unique-id',        // 唯一标识
    label: '显示文本',       // 显示内容
    disabled: false,        // 是否禁用
    category: 'type1'       // 扩展字段
  }
]

// 避免的数据结构
const badData = [
  {
    id: 1,                  // 不一致的键名
    name: '文本',           // 不一致的标签名
    isDisabled: true        // 不一致的禁用字段
  }
]

2. 事件处理

// 推荐:解构参数,明确语义
handleChange(value, direction, movedKeys) {
  console.log('新值:', value)
  console.log('方向:', direction)
  console.log('移动项:', movedKeys)
  
  // 业务逻辑
  this.updateServer(value)
}

// 避免:直接使用事件对象
handleChange(event) {
  // 不清楚 event 的结构
  console.log(event)
}

3. 样式组织

/* 使用 BEM 命名规范 */
.transfer-container {}
.transfer-panel {}
.transfer-panel__header {}
.transfer-panel__body {}
.transfer-panel--disabled {}

/* 使用 CSS 变量实现主题 */
:root {
  --transfer-border-color: #dcdfe6;
  --transfer-bg-color: #fff;
  --transfer-text-color: #303133;
}

使用示例

基础示例

image.png

最简单的使用方式:

<template>
  <div>
    <Transfer
      :data="data"
      v-model="value"
      @change="handleChange"
    />
  </div>
</template>

<script>
import Transfer from './components/Transfer.vue'

export default {
  components: {
    Transfer
  },
  data() {
    return {
      data: [
        { key: 1, label: '选项1' },
        { key: 2, label: '选项2' },
        { key: 3, label: '选项3' }
      ],
      value: []
    }
  },
  methods: {
    handleChange(value, direction, movedKeys) {
      console.log('变化:', { value, direction, movedKeys })
    }
  }
}
</script>

可搜索穿梭框

image.png 启用搜索功能:

<template>
  <Transfer
    :data="data"
    v-model="value"
    :filterable="true"
    filter-placeholder="搜索..."
    left-title="源数据"
    right-title="目标数据"
  />
</template>

<script>
export default {
  data() {
    return {
      data: [
        { key: 'js', label: 'JavaScript' },
        { key: 'vue', label: 'Vue.js' },
        { key: 'react', label: 'React' },
        { key: 'angular', label: 'Angular' },
        { key: 'node', label: 'Node.js' }
      ],
      value: ['vue']
    }
  }
}
</script>

自定义渲染

image.png 使用 render-content 属性自定义显示内容:

<template>
  <Transfer
    :data="userData"
    v-model="selectedUsers"
    :render-content="renderUser"
    :filterable="true"
    left-title="用户列表"
    right-title="已选用户"
  />
</template>

<script>
export default {
  data() {
    return {
      userData: [
        { 
          key: 1, 
          name: '张三', 
          age: 25, 
          department: '技术部',
          position: '前端工程师',
          email: 'zhangsan@example.com'
        },
        { 
          key: 2, 
          name: '李四', 
          age: 30, 
          department: '产品部',
          position: '产品经理',
          email: 'lisi@example.com'
        }
      ],
      selectedUsers: []
    }
  },
  methods: {
    renderUser(user) {
      return `${user.name} (${user.position} - ${user.department})`
    }
  }
}
</script>

禁用状态

image.png 某些项目可以设置为禁用状态:

<template>
  <Transfer
    :data="permissions"
    v-model="userPermissions"
    left-title="所有权限"
    right-title="用户权限"
  />
</template>

<script>
export default {
  data() {
    return {
      permissions: [
        { key: 'read', label: '读取权限' },
        { key: 'write', label: '写入权限' },
        { key: 'delete', label: '删除权限', disabled: true },
        { key: 'admin', label: '管理员权限', disabled: true }
      ],
      userPermissions: ['read']
    }
  }
}
</script>

大数据量处理

image.png 组件支持大数据量的处理:

<template>
  <div>
    <div class="controls">
      <button @click="generateData">生成大量数据</button>
      <button @click="clearData">清空数据</button>
    </div>
    
    <Transfer
      :data="bigData"
      v-model="selected"
      :filterable="true"
      list-height="400px"
      left-title="数据源"
      right-title="已选数据"
      @change="handleChange"
    />
    
    <div class="stats">
      <p>总数据量: {{ bigData.length }}</p>
      <p>已选择: {{ selected.length }}</p>
    </div>
  </div>
</template>

<script>
export default {
  data() {
    return {
      bigData: [],
      selected: []
    }
  },
  methods: {
    generateData() {
      const data = []
      for (let i = 1; i <= 10000; i++) {
        data.push({
          key: i,
          label: `数据项 ${i}`,
          disabled: i % 1000 === 0 // 每1000项禁用一个
        })
      }
      this.bigData = data
    },
    
    clearData() {
      this.bigData = []
      this.selected = []
    },
    
    handleChange(value, direction, movedKeys) {
      console.log(`${direction === 'right' ? '选择' : '移除'} ${movedKeys.length} 项`)
    }
  }
}
</script>

异步数据加载

结合异步数据加载:

<template>
  <div>
    <Transfer
      :data="data"
      v-model="selected"
      :filterable="true"
      left-title="远程数据"
      right-title="已选择"
      @change="handleChange"
    />
  </div>
</template>

<script>
export default {
  data() {
    return {
      data: [],
      selected: [],
      loading: false
    }
  },
  
  async created() {
    await this.loadData()
  },
  
  methods: {
    async loadData() {
      this.loading = true
      try {
        // 模拟异步数据加载
        const response = await fetch('/api/data')
        const data = await response.json()
        this.data = data.map(item => ({
          key: item.id,
          label: item.name,
          disabled: item.disabled
        }))
      } catch (error) {
        console.error('加载数据失败:', error)
      } finally {
        this.loading = false
      }
    },
    
    handleChange(value, direction, movedKeys) {
      // 可以在这里同步到服务器
      this.syncToServer(value)
    },
    
    async syncToServer(value) {
      try {
        await fetch('/api/selection', {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json'
          },
          body: JSON.stringify({ selected: value })
        })
      } catch (error) {
        console.error('同步失败:', error)
      }
    }
  }
}
</script>

表单集成

在表单中使用穿梭框:

<template>
  <form @submit.prevent="handleSubmit">
    <div class="form-group">
      <label>选择技能:</label>
      <Transfer
        :data="skills"
        v-model="form.selectedSkills"
        :filterable="true"
        left-title="技能列表"
        right-title="已掌握技能"
        @change="validateSkills"
      />
      <div v-if="errors.skills" class="error">
        {{ errors.skills }}
      </div>
    </div>
    
    <div class="form-group">
      <label>选择兴趣:</label>
      <Transfer
        :data="interests"
        v-model="form.selectedInterests"
        :filterable="true"
        left-title="兴趣列表"
        right-title="选择的兴趣"
      />
    </div>
    
    <button type="submit">提交</button>
  </form>
</template>

<script>
export default {
  data() {
    return {
      form: {
        selectedSkills: [],
        selectedInterests: []
      },
      skills: [
        { key: 'js', label: 'JavaScript' },
        { key: 'vue', label: 'Vue.js' },
        { key: 'react', label: 'React' },
        { key: 'python', label: 'Python' },
        { key: 'java', label: 'Java' }
      ],
      interests: [
        { key: 'reading', label: '阅读' },
        { key: 'music', label: '音乐' },
        { key: 'sports', label: '运动' },
        { key: 'travel', label: '旅行' }
      ],
      errors: {}
    }
  },
  
  methods: {
    validateSkills(value) {
      if (value.length < 2) {
        this.errors.skills = '至少选择2项技能'
      } else {
        delete this.errors.skills
      }
    },
    
    handleSubmit() {
      if (this.form.selectedSkills.length < 2) {
        this.errors.skills = '至少选择2项技能'
        return
      }
      
      console.log('表单提交:', this.form)
      
      // 提交到服务器
      this.submitForm()
    },
    
    async submitForm() {
      try {
        await fetch('/api/user/profile', {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json'
          },
          body: JSON.stringify(this.form)
        })
        
        alert('提交成功!')
      } catch (error) {
        console.error('提交失败:', error)
        alert('提交失败,请重试')
      }
    }
  }
}
</script>

<style scoped>
.form-group {
  margin-bottom: 20px;
}

.form-group label {
  display: block;
  margin-bottom: 8px;
  font-weight: 500;
}

.error {
  color: #f56c6c;
  font-size: 12px;
  margin-top: 4px;
}
</style>

高级配置

更多配置选项的使用:

<template>
  <Transfer
    :data="advancedData"
    v-model="selected"
    :filterable="true"
    :show-all-btn="true"
    left-title="高级配置源"
    right-title="高级配置目标"
    filter-placeholder="输入关键词搜索..."
    key-prop="id"
    label-prop="name"
    list-height="350px"
    :render-content="renderAdvanced"
    @change="handleAdvancedChange"
  />
</template>

<script>
export default {
  data() {
    return {
      advancedData: [
        {
          id: 'config1',
          name: '系统配置',
          type: 'system',
          level: 'high',
          description: '系统级别的配置项'
        },
        {
          id: 'config2',
          name: '用户配置',
          type: 'user',
          level: 'medium',
          description: '用户级别的配置项'
        },
        {
          id: 'config3',
          name: '临时配置',
          type: 'temp',
          level: 'low',
          description: '临时性的配置项',
          disabled: true
        }
      ],
      selected: []
    }
  },
  
  methods: {
    renderAdvanced(item) {
      const levelMap = {
        high: '高',
        medium: '中',
        low: '低'
      }
      
      return `${item.name} [${levelMap[item.level]}] - ${item.description}`
    },
    
    handleAdvancedChange(value, direction, movedKeys) {
      console.log('高级配置变化:', {
        value,
        direction,
        movedKeys,
        movedItems: movedKeys.map(key => 
          this.advancedData.find(item => item.id === key)
        )
      })
    }
  }
}
</script>

性能优化示例

针对大数据量的性能优化:

<template>
  <div>
    <div class="performance-controls">
      <button @click="generateLargeData">生成大量数据</button>
      <button @click="measurePerformance">性能测试</button>
      <div v-if="performanceData">
        <p>渲染时间: {{ performanceData.renderTime }}ms</p>
        <p>搜索时间: {{ performanceData.searchTime }}ms</p>
      </div>
    </div>
    
    <Transfer
      ref="transfer"
      :data="largeData"
      v-model="selected"
      :filterable="true"
      list-height="400px"
      left-title="大数据源"
      right-title="已选择"
      @change="handleLargeChange"
    />
  </div>
</template>

<script>
export default {
  data() {
    return {
      largeData: [],
      selected: [],
      performanceData: null
    }
  },
  
  methods: {
    generateLargeData() {
      const startTime = performance.now()
      
      this.largeData = []
      for (let i = 1; i <= 50000; i++) {
        this.largeData.push({
          key: i,
          label: `数据项 ${i.toString().padStart(6, '0')}`,
          category: `分类${Math.floor(i / 1000) + 1}`,
          disabled: i % 1000 === 0
        })
      }
      
      const endTime = performance.now()
      console.log(`生成${this.largeData.length}条数据用时: ${endTime - startTime}ms`)
    },
    
    measurePerformance() {
      // 测量渲染性能
      const renderStart = performance.now()
      this.$forceUpdate()
      this.$nextTick(() => {
        const renderEnd = performance.now()
        
        // 测量搜索性能
        const searchStart = performance.now()
        // 模拟搜索操作
        const filtered = this.largeData.filter(item => 
          item.label.includes('100')
        )
        const searchEnd = performance.now()
        
        this.performanceData = {
          renderTime: (renderEnd - renderStart).toFixed(2),
          searchTime: (searchEnd - searchStart).toFixed(2)
        }
      })
    },
    
    handleLargeChange(value, direction, movedKeys) {
      console.log(`大数据操作: ${direction}, 移动${movedKeys.length}项`)
    }
  }
}
</script>

这些示例展示了 Vue 2 穿梭框组件的各种使用场景和配置方式,可以根据实际需求进行调整和扩展。

API 文档

Props

参数说明类型默认值
data数据源Array[]
value / v-model已选中的数据Array[]
leftTitle左侧标题String'待选项'
rightTitle右侧标题String'已选项'
filterable是否可搜索Booleanfalse
filterPlaceholder搜索框占位符String'请输入搜索内容'
keyProp数据项的键名String'key'
labelProp数据项的标签名String'label'
listHeight列表高度String'200px'
showAllBtn是否显示全选按钮Booleantrue
renderContent自定义渲染函数Functionnull

Events

事件名说明参数
change选中项发生变化时触发(value, direction, movedKeys)
inputv-model 事件(value)

数据格式

const data = [
  {
    key: 1,           // 必需,唯一标识
    label: '选项1',    // 必需,显示文本
    disabled: false   // 可选,是否禁用
  }
]

自定义渲染

<template>
  <Transfer
    :data="userData"
    v-model="selectedUsers"
    :render-content="renderUser"
  />
</template>

<script>
export default {
  data() {
    return {
      userData: [
        { key: 1, name: '张三', age: 25, department: '技术部' },
        { key: 2, name: '李四', age: 30, department: '产品部' }
      ],
      selectedUsers: []
    }
  },
  methods: {
    renderUser(item) {
      return `${item.name} (${item.age}岁, ${item.department})`
    }
  }
}
</script>

高级用法

大数据量处理

组件经过优化,可以处理大量数据:

// 生成大数据量测试
const bigData = []
for (let i = 1; i <= 10000; i++) {
  bigData.push({
    key: i,
    label: `选项 ${i}`,
    disabled: i % 100 === 0
  })
}

搜索过滤

启用搜索功能,支持实时过滤:

<Transfer
  :data="data"
  v-model="value"
  :filterable="true"
  filter-placeholder="搜索选项..."
/>

事件处理

methods: {
  handleChange(value, direction, movedKeys) {
    console.log('新的值:', value)
    console.log('移动方向:', direction) // 'left' 或 'right'
    console.log('移动的项:', movedKeys)
    
    // 可以在这里进行额外的业务逻辑
    if (direction === 'right') {
      this.onItemsSelected(movedKeys)
    } else {
      this.onItemsDeselected(movedKeys)
    }
  }
}

样式定制

组件提供了丰富的 CSS 类名,可以进行样式定制:

.transfer-container {
  /* 容器样式 */
}

.transfer-panel {
  /* 面板样式 */
}

.transfer-header {
  /* 头部样式 */
}

.transfer-item {
  /* 列表项样式 */
}

.transfer-item:hover {
  /* 悬停样式 */
}

.transfer-item.is-disabled {
  /* 禁用状态样式 */
}

性能优化

虚拟滚动

对于超大数据量,建议结合虚拟滚动:

// 可以考虑分页或虚拟滚动
const pageSize = 50
const currentPage = 1
const displayData = data.slice(
  (currentPage - 1) * pageSize,
  currentPage * pageSize
)

防抖搜索

搜索功能内置了防抖,但也可以自定义:

import { debounce } from 'lodash'

export default {
  data() {
    return {
      searchDebounce: debounce(this.handleSearch, 300)
    }
  },
  methods: {
    handleSearch(keyword) {
      // 搜索逻辑
    }
  }
}

无障碍支持

组件支持键盘导航和屏幕阅读器:

  • Tab 键切换焦点
  • Space 键选择/取消选择
  • Enter 键确认操作
  • 支持 ARIA 标签

兼容性

  • Vue 2.7+
  • 现代浏览器 (Chrome, Firefox, Safari, Edge)
  • IE 11+ (需要 polyfill)

常见问题

Q: 如何处理异步数据?

A: 直接绑定异步数据即可,组件会自动响应数据变化:

async created() {
  this.data = await fetchData()
}

Q: 如何实现服务端搜索?

A: 监听搜索事件,调用服务端 API:

watch: {
  leftFilterText: {
    handler: debounce(async function(keyword) {
      if (keyword) {
        this.data = await searchFromServer(keyword)
      }
    }, 300)
  }
}

Q: 如何验证选择结果?

A: 在 change 事件中进行验证:

handleChange(value, direction, movedKeys) {
  if (value.length > 10) {
    this.$message.warning('最多只能选择10项')
    return false
  }
}

总结

  1. 高性能:支持大数据量渲染
  2. 易用性:简洁的 API 设计
  3. 可扩展性:支持自定义渲染和样式
  4. 健壮性:完善的错误处理和边界情况
  5. 可访问性:符合无障碍标准

后续优化

  1. TypeScript 支持:提供完整的类型定义
  2. 国际化:支持多语言
  3. 主题定制:提供更多样式变量
  4. 插件系统:支持功能扩展
  5. 移动端适配:响应式设计优化

gitee地址 gitee.com/xie-yaozu/t…