效果图
这是 Element-UI 的 Transfer 组件,下面就配合源码看下具体实现。Template
复制代码// 底部插槽
整体上,可以划分为左中右三块,左右两个 TransferPanel 组件承载数据展示。中间两个 ElButton 是左右移动的操作按钮。结构清晰。
JS 部分
mixins: [Emitter, Locale, Migrating],复制代码
mixins 部分混入了三个对象。Locale 是国际化的东西,Migrating 是组件迁移的一些提示信息。需要关注的是 Emitter 部分,代码如下:
// 寻找所有子组件,直到找到名为componentName的组件,调用其$emit方法function broadcast(componentName, eventName, params) { this.$children.forEach(child => { var name = child.$options.componentName; if (name === componentName) { child.$emit.apply(child, [eventName].concat(params)); } else { broadcast.apply(child, [componentName, eventName].concat([params])); } });}export default { // 事件定向传播 methods: { // 寻找所有父组件,直到找到名为componentName的组件,调用其$emit方法 dispatch(componentName, eventName, params) { var parent = this.$parent || this.$root; var name = parent.$options.componentName; while (parent && (!name || name !== componentName)) { parent = parent.$parent; if (parent) { name = parent.$options.componentName; } } if (parent) { parent.$emit.apply(parent, [eventName].concat(params)); } }, broadcast(componentName, eventName, params) { broadcast.call(this, componentName, eventName, params); } }};复制代码
提供了两个方法: dispatch, broadcast 做事件的定向传播。
属性 props
- data ,Transfer 的数据源
// array[{ key, label, disabled }]data: {type: Array,default() { return [];}}复制代码
传入的数组,每一项需要有三个属性,key : 唯一标识,label : 展示内容,disabled : 是否可勾选,如果不想用这三个属性名,可以通过 props 属性设置别名。
- props , 数据源的字段别名
props: { type: Object, default() { return { label: 'label', key: 'key', disabled: 'disabled' }; } }复制代码
- titles , 允许自定义标题列表
// ['列表 1', '列表 2']titles: { type: Array, default() { return []; } }复制代码
- buttonTexts , 自定义 el-button 文案
// ['到左边', '到右边']buttonTexts: { type: Array, default() { return []; } }复制代码
- filterPlaceholder , 搜索框占位符
filterPlaceholder: { type: String, default: '' }复制代码
- filterMethod , 自定义搜索方法
filterMethod: Function复制代码
- leftDefaultChecked / rightDefaultChecked ,初始状态下左侧/右侧列表的已勾选项的 key 数组
leftDefaultChecked: { type: Array, default() { return []; } }复制代码
- renderContent , 自定义的数据渲染函数
renderContent: Function,复制代码
- value , 目标列表的 key 数组
value: { type: Array, default() { return []; } }复制代码
- format , 列表顶部勾选状态文案
// object{noChecked, hasChecked} format: { type: Object, default() { return {}; } }复制代码
- filterable , 是否可搜索,默认为 false
filterable: Boolean复制代码
- targetOrder , 右侧列表元素的排序策略:若为 original,则保持与数据源相同的顺序;若为 push,则新加入的元素排在最后;若为 unshift,则新加入的元素排在最前
targetOrder: { type: String, default: 'original' }复制代码
计算属性 computed
- dataObj , data 数组转为对象
// [{key:1,label:'数据1',disabled:false}] => {1:{key:1,label:'数据1',disabled:false}dataObj() { const key = this.props.key; return this.data.reduce((o, cur) => (o[cur[key]] = cur) && o, {});}复制代码
- sourceData , leftPanel 数据源
// 筛选在 data 中 ,但是不在 value 中的数据sourceData() { return this.data.filter(item => this.value.indexOf(item[this.props.key]) === -1); }复制代码
- targetData , rightPanel 数据源
targetData() { // 目标源排序顺序为 original,按照数据在 data 数组的先后顺序 if (this.targetOrder === 'original') { return this.data.filter(item => this.value.indexOf(item[this.props.key]) > -1); // 否则按照 value 数组中 key 的先后顺序 } else { return this.value.reduce((arr, cur) => { const val = this.dataObj[cur]; if (val) { arr.push(val); } return arr; }, []); } }复制代码
- hasButtonTexts , 是否传入可用的按钮文案
// 当传入的 button-text 有两项的时候返回 true hasButtonTexts() { return this.buttonTexts.length === 2;}复制代码
方法 methods
methods 中 leftPanel 和 rightPanel 的部分是对称的,所以只选取 rightPanel 部分展示:
// val : 当前选中项的 key 数组// movedKeys: 选中状态发生变化的 key 数组onTargetCheckedChange(val, movedKeys) { this.rightChecked = val; if (movedKeys === undefined) return; this.$emit('right-check-change', val, movedKeys);},addToLeft() { // rightPanel 中数据项的 key 数组 let currentValue = this.value.slice(); // 从 currentValue 中删除选中的项 this.rightChecked.forEach(item => { const index = currentValue.indexOf(item); if (index > -1) { currentValue.splice(index, 1); } }); // currentValue: 当前 rightPanel 中存在数据的 key 数组 // rightChecked: 选中移动的数据项的 key 数组 this.$emit('input', currentValue); this.$emit('change', currentValue, 'left', this.rightChecked); },clearQuery(which) { // 清除 leftPanel 搜索栏的搜索条件 if (which === 'left') { this.$refs.leftPanel.query = ''; // 清除 rightPanel 搜索栏的搜索条件 } else if (which === 'right') { this.$refs.rightPanel.query = ''; }} 复制代码
Transfer 组件部分就这些内容,主要是控制传入 TransferPanel 的 data ,以及向外发射 change ,check-change 事件。
ElTransferPanel
template
复制代码// 全选框
// 展示文本 { { title }} // 勾选总结文本 { { checkedSummary }} // 搜索栏// 底部插槽,当设置footer时展示// prefix 插槽, 点击清除搜索栏条件 // 多选框组// v-for 列表渲染,数据源为 filteredData // 没有匹配数据时的展示内容// option-content 组件 {
{ t('el.transfer.noMatch') }} // 有匹配项并且数据项为空时展示内容{
{ t('el.transfer.noData') }}
JS
引入的组件中,需要关注下 option-content ,它是 render 函数直接定义的
OptionContent: { props: { option: Object }, render(h) { // 获取名为 ElTransferPanel 的父组件 const getParent = vm => { if (vm.$options.componentName === 'ElTransferPanel') { return vm; } else if (vm.$parent) { return getParent(vm.$parent); } else { return vm; } }; const panel = getParent(this); // 获取 transfer 组件 const transfer = panel.$parent || panel; // 如果设置了自定义数据项渲染函数,则调用自定义的渲染函数 // 如果没有定义 render-content 方法,则检查 Transfer 组件是否设置了 slot-scope 插槽内容 // 如果设置了,则用 slot-scope 内容渲染 // 否则用默认的 span 标签渲染 // 意味着数据项的渲染可以通过 render-content 或者 slot-scoped 自定义 return panel.renderContent ? panel.renderContent(h, this.option) : transfer.$scopedSlots.default ? transfer.$scopedSlots.default({ option: this.option }) : { this.option[panel.labelProp] || this.option[panel.keyProp] }; } }复制代码
组件传入的 option 是 item ,item 来自 filteredData,
filteredData() { // data 为 数据源, leftPanel 对应 sourceData return this.data.filter(item => { // 如果自定义了搜索方法,则调用自定义的方法 if (typeof this.filterMethod === 'function') { return this.filterMethod(this.query, item); // 默认搜索规则是数据项的 label 中是否包含输入的条件 } else { const label = item[this.labelProp] || item[this.keyProp].toString(); return label.toLowerCase().indexOf(this.query.toLowerCase()) > -1; } });}复制代码
watch
// 选择项发生变化 // val 当前选中的元素的 key 数组 // oldVal 前一状态选中的元素的 key 数组 checked(val, oldVal) { // 更新全新状态 this.updateAllChecked(); // 如果改变是用户点击造成的 if (this.checkChangeByUser) { // 选中状态发生变化的元素的 key 数组 const movedKeys = val.concat(oldVal) .filter(v => val.indexOf(v) === -1 || oldVal.indexOf(v) === -1); this.$emit('checked-change', val, movedKeys); } else { this.$emit('checked-change', val); this.checkChangeByUser = true; } }, // 数据源发生变化 data() { const checked = []; const filteredDataKeys = this.filteredData.map(item => item[this.keyProp]); this.checked.forEach(item => { if (filteredDataKeys.indexOf(item) > -1) { checked.push(item); } }); // 标记此次勾选状态改变不是由用户造成的 this.checkChangeByUser = false; // 重新设置勾选的元素项 this.checked = checked; }, // 可勾选的数据改变 checkableData() { this.updateAllChecked(); }, // 默认选中的数据改变 defaultChecked: { // 设置该回调将会在侦听开始之后被立即调用 immediate: true, handler(val, oldVal) { // 存在旧数据,且旧数据和当前数据包含项一致,返回,不进行后续赋值操作 if (oldVal && val.length === oldVal.length && val.every(item => oldVal.indexOf(item) > -1)) return; const checked = []; const checkableDataKeys = this.checkableData.map(item => item[this.keyProp]); val.forEach(item => { if (checkableDataKeys.indexOf(item) > -1) { checked.push(item); } }); this.checkChangeByUser = false; this.checked = checked; } }复制代码
Transferpanel 组件的 computed 比较简单,主要的 filteredData 在上面已经提过,下面看他的 methods
methods
// 更新全选状态updateAllChecked() { // 所有可勾选数据项的 key 数组 const checkableDataKeys = this.checkableData.map(item => item[this.keyProp]); // 所有可勾选的项都在已勾选数组中,则标记为全勾选状态 this.allChecked = checkableDataKeys.length > 0 && checkableDataKeys.every(item => this.checked.indexOf(item) > -1);},// 勾选全选框的回调handleAllCheckedChange(value) { // 如果是选中,则将所有可勾选数据项的 key 放入 checked 数组 // 如果是取消选中,则清空 checked 数组 this.checked = value ? this.checkableData.map(item => item[this.keyProp]) : [];},// 清空搜索栏clearQuery() { // 如果搜索栏输入了内容 if (this.inputIcon === 'circle-close') { this.query = ''; }} 复制代码