最近在使用 el-table
的树形表格的时候, 发现两个问题:
- 没有展开到
level
级的功能
- 表头的
checkbox
只能控制第一层级的 checkbox
, 无法控制子级
element-ui: 2.13.0
国际惯例, 先奉上源码
展开到 level 级
查阅官方文档
首先查看官方文档发现两个参数:
参数 |
说明 |
类型 |
可选值 |
默认值 |
default-expand-all |
是否默认展开所有行, 当 Table 包含展开行存在或者为树形表格时有效 |
Boolean |
- |
false |
expand-row-keys |
可以通过该属性设置 Table 目前的展开行, 需要设置 row-key 属性才能使用, 该属性为展开行的 keys 数组. |
Array |
- |
|
这里我想到一个最笨到办法: 我们可以对源数据进行处理, 根据层级将数据分类, 将相同层级的 id
放到一个数组中, 将这些数组和层级做一个 map
映射. level
等于 0
时, default-expand-all
为 true
, 大于 0
时, 从映射中取出对应的数组, 给 expand-row-keys
赋值, 这样不就可以了吗. 我真是一个天才啊. 但结果总是在那么不经意间给你当头一棒(这个暂且不表, 后续会说明的)
所以 html
为:
1 2 3 4 5 6 7 8 9
| <el-table class="tree-table" :ref="ref" v-bind="$attrs" :default-expanded-all="defaultExpandAll" :expand-row-keys="expandRowKeys" > <slot></slot> </el-table>
|
参考 el-tree 方法
由于本人曾经封装过 el-tree
的相同功能, 所以想着 el-table
是不是可以参考一下呢? 结果发现还是太年轻啊
封装 el-tree
的思路:
先找出 tree
的所有 node
, 再根据传入的 level
来判断 node
是展开还是折叠状态
level
的 props
和 watch
这里就不在赘述了, 和 el-tree
是一样的. 就说一下怎么处理展开折叠吧
tree
的 store
有 _getAllNodes
方法可以获取所有数据, 但 table
没有. 没有怎么办? 找找看有没有别的方法或数据吧.
终于经过大半天的苦苦探索后, 发现了这个: this.$refs[this.ref].store.states.treeData
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| { 3: { children: [31, 32], level: 0, expanded: false, display: true }, 31: { children: [311, 312], level: 1, expanded: false }, 311: { children: [3111, 3112], level: 2, expanded: false } }
|
看来我和 ElementUI
不谋而合呀. 这种方式虽然没有 level: [ids]
取值方便, 但处理一下也行呀
处理数据
首先我们来分析一下数据: 我们一般说 展开到 level
级是指到 level
级前是展开状态, 那 treeData
的 level
和咱们说的 level
相差为 2
, 所以我们比较的时候需要将传入的 level
减去 2
. 而 level = 0
时是指全部展开, 所以此时 level
应该为 treeData
的 maxLevel
我们先处理一下数据:
1 2 3 4 5 6 7 8 9 10 11
| handleData() { this.treeData = this.$refs[this.ref].store.states.treeData this.maxLevel = Math.max.apply( null, Object.values(this.treeData).map(({ level }) => level) ) + 2 this.$emit('max-level', this.maxLevel) }
|
这样我们在 watch level
中就可以处理展开折叠了:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
| if (!this.$refs[this.ref]) return if (!this.maxLevel) { this.handleData() } let level = 0 if (this.level <= 0) { level = this.maxLevel -2
} else { level = this.level - 2 } let expandRowKeys = [] for (const key in this.treeData) { if (this.treeData.hasOwnProperty(key)) { if (this.treeData[key].level <= level) { expandRowKeys.push(key) } } } this.expandRowKeys = expandRowKeys
|
本来以为到这里应该就完美解决了, 没想到啊, expandRowKeys
只负责展开, 不负责折叠.
比如现在展开到 3
级了, 想要展开到 2
级, table
一看目前 2
级节点是展开状态, 不用我管了, 我继续吃瓜去了. 什么? 你说 3
级为什么不折叠起来? 我收到的展开节点的 id
数组为第 1
级和第 2
级, 现在不是展开状态吗? 我有错吗? emmmm
, 你好像确实没错, 是我错怪你了, 你继续吃瓜去吧. 当头一棒该来的还是来了呀. 但我怎么折叠呀! 有了, 不是还有 key
吗?
1 2 3 4 5 6 7 8 9 10
| let expandRowKeys = [] for (const key in this.treeData) { if (this.treeData.hasOwnProperty(key)) { if (this.treeData[key].level <= level) { expandRowKeys.push(key) } } } this.key = Date.now() this.expandRowKeys = expandRowKeys
|
优化
虽然实现功能了, 但上面自己义正言辞的说 key
不能用, 这里倒用的挺勤快的. 这不是既当又立吗? 而且 level
增加的时候其实是不需要的, 只有减少的时候才需要, 那这里再判断一下? 还有如果使用这个组件的时候传入 default-expand-all
, 展开折叠完全无效好伐
现在问题好像有点麻烦了, 咱们是不是一开始方向就错了? 当初自己也说了这是一个笨办法, 是不是有什么细节被忽略了呢?
咱们再看一下 treeData
1 2 3 4 5 6 7 8
| { 3: { children: [31, 32], level: 0, expanded: false, display: true } }
|
这个 expanded
不就是控制展开折叠的吗? 修改这个值不就可以了吗? 我的天呐
1 2 3 4 5
| for (const key in this.treeData) { if (this.treeData.hasOwnProperty(key)) { this.treeData[key].expanded = this.treeData[key].level <= level } }
|
搞定! 不需要 key
, 传入的 default-expand-all
也不影响了, 完美. 哎, 写代码就是这么朴实无华且枯燥
全选
这个正好从网上找到一个大佬分享的方法: el-table 树形结构的复选 - 个人文章 - SegmentFault 思否, 修改一下应该就可以了
分析大佬代码
首先大佬是直接写死的 row-key
和 children
, 而我们现在是封装成组件, 那就需要定义成变量; 还需要把那几个选择方法 emit
出去; 而且大佬这个方法只能够选到第二级, 所以需要用到递归
处理数据
1 2 3 4 5 6 7 8 9 10 11 12
| <el-table class="tree-table" :ref="ref" :data="data" v-bind="$attrs" @select="select" @select-all="selectAll" @selection-change="selectionChange" v-on="$listeners" > <slot></slot> </el-table>
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76
| props: { data: { type: Array, default() { return [] } } }, data() { return { rowKey: '', children: '', timeout: null } }, methods: { handleData() { this.treeData = this.$refs[this.ref].store.states.treeData this.maxLevel = Math.max.apply( null, Object.values(this.treeData).map(({ level }) => level) ) + 2 this.$emit('max-level', this.maxLevel)
this.rowKey = this.$refs[this.ref].rowKey this.children = this.$refs[this.ref].treeProps.children },
select(selection, row) { const selected = selection.some(item => item[this.rowKey] === row[this.rowKey]) this.selectChildren(row, selected) }, selectAll(selection) { const tableDataIds = this.data.map(({id}) => id) const isSelect = selection.some(item => tableDataIds.includes(item.id)) const selectIds = selection.map(({id}) => id) const isCancel = !this.data.every(item => selectIds.includes(item.id)) if (isSelect) { selection.forEach(item => { this.selectChildren(item, isSelect) }) } if (isCancel) { this.data.forEach(item => { this.selectChildren(item, !isCancel) }) } }, selectChildren(row, selected) { if (row[this.children] && Array.isArray(row[this.children])) { row[this.children].forEach(item => { this.toggleSelection(item, selected) this.selectChildren(item, selected) }) } }, selectionChange(selection) { this.$emit('selection-change', selection) }, toggleSelection(row, select) { row && this.$nextTick(() => { this.$refs[this.ref] && this.$refs[this.ref].toggleRowSelection(row, select) }) }, cancelAll() { this.$refs[this.ref] && this.$refs[this.ref].clearSelection() } }
|
优化
selection
、row
和 this.data
中的对象引用地址是相同的, 所以可以直接比较, 不用比较 id
selectAll
只有两种状态, 所以只判断一种情况即可
- 切换全选和父级的状态时,
selectionChange
会触发多次, 需要防抖
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69
| methods: { handleData() { this.treeData = this.$refs[this.ref].store.states.treeData this.maxLevel = Math.max.apply( null, Object.values(this.treeData).map(({ level }) => level) ) + 2 this.$emit('max-level', this.maxLevel)
this.children = this.$refs[this.ref].treeProps.children },
select(selection, row) { const selected = selection.some(item => item === row) this.selectChildren(row, selected) }, selectAll(selection) {
const isSelect = this.data.some(item => selection.includes(item)) if (isSelect) { selection.forEach(item => { this.selectChildren(item, isSelect) }) } else { this.data.forEach(item => { this.selectChildren(item, isSelect) }) } }, selectChildren(row, selected) { if (row[this.children] && Array.isArray(row[this.children])) { row[this.children].forEach(item => { this.toggleSelection(item, selected) this.selectChildren(item, selected) }) } }, selectionChange(selection) { this.debounce(this.emitSelectionChange, 100, selection) }, emitSelectionChange(selection) { this.$emit('selection-change', selection) }, toggleSelection(row, select) { row && this.$nextTick(() => { this.$refs[this.ref] && this.$refs[this.ref].toggleRowSelection(row, select) }) }, cancelAll() { this.$refs[this.ref] && this.$refs[this.ref].clearSelection() }, debounce(fun, wait, params) { clearTimeout(this.timeout) this.timeout = setTimeout(fun, wait, params) } }
|
存在的问题
- 目前我们只是把
selection-change
emit
出去了, select
和 select-all
并没有 emit
出去. 鉴于工作中很少用到这两个方法, 暂时就先不做处理了, 以后如果需要再说
- 选中子级的时候, 父级没有联动, 无法出现半选和选中状态, 只有表头的全选有联动.
添加配置项
现在我们实现了展开到 level
级和全选功能. 万一有人需要展开到 level
级, 而全选功能保持原来到样子怎么办?
所以我们需要添加一些配置项来让使用者控制功能
目前需要两个配置项:
- check-strictly: Boolean, 同 el-tree, 在显示复选框的情况下,是否严格的遵循父子不互相关联的做法,默认为 false
- check-all: Boolean, 点击表头的多选框时, 是否影响全部数据, 默认为 true
代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| props: { checkStrictly: { type: Boolean, default: false }, checkAll: { type: Boolean, default: true } }, methods: { select(selection, row) { if (this.checkStrictly) { this.$emit('select', selection, row) return } }, selectAll(selection) { if (!this.checkAll) { this.$emit('select-all', selection) return } } }
|
修正
当初封装组件的时候没有测试方法, 今天一测试, 发现 selection-change
、 select
和 select-all
都触发了, 而且 selection-change
触发了两次, 一次是 el-table
的, 一次是咱 emit
出去的, 如果选中的是父级并且 check-strictly
为 false
时, 会触发多次, 咱做的防抖确实是只 emit
最后一次, 但 el-table
自己会触发多次
一直以为 $listeners
和 $attrs
是一样的: 写在标签里的属性会覆盖 $attrs
; 但方法不会, 我想应该是方法是对象, 引用地址不同, 所以不会覆盖
关于这两个属性的介绍可以看看这篇文章: Vue $attrs 和 $listeners | Henry
那这样的话就需要咱们自己覆盖了
1 2 3 4 5 6 7 8 9
| <el-table class="tree-table" :ref="ref" :data="data" v-bind="$attrs" v-on="{ ...$listeners, select, 'select-all': selectAll, 'selection-change': selectionChange }" > <slot></slot> </el-table>
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| select(selection, row) { if (!this.checkStrictly) { const selected = selection.some(item => item === row) this.selectChildren(row, selected) } this.$emit('select', selection, row) }, selectAll(selection) { if (this.checkAll) { const isSelect = this.data.some(item => selection.includes(item)) if (isSelect) { selection.forEach(item => { this.selectChildren(item, isSelect) }) } else { this.data.forEach(item => { this.selectChildren(item, isSelect) }) } } this.$nextTick(() => { this.$emit('select-all', selection) }) }
|
而且我还发现一个现象: 使用 console.log
输出 selectAll
的 selection
一开始为所有根级, 但点击以后发现是全部数据, 那么就可以使用 $nextTick
来 emit
正确数据了. 但 select
的 selection
不会变, 所以这个在选中父级后, 仍然只能 emit
父级, 想要将子级也全部 emit
出去, 那就还需要递归将所有子级全部加到 selection
中去. 鉴于目前没有使用到这个数据, 就暂时不做处理了, 等以后需要或者想到更好办法了再解决吧.
emit select 的 selection
以前没有使用过 select
这个方法, 最近在实际工作中用到了 select
, 发现自己理解错这个方法了.
select
是用户手动勾选数据行后, emit
出去表格全部选中的数据和当前行的数据. 所以每勾选一条数据, 就要 emit
一次
那如果我们切换父级选中状态后, 应该 emit
一次, 然后我们通过代码控制子级的选中状态, 这时候也应该 emit
关于递归遍历子级本人想到两种方法
方法一: 从根级开始遍历, 先遍历根级的所有子级, 再遍历子级的子级, 以此类推
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39
| select(selection, row) { if (!this.checkStrictly) { const selected = selection.includes(row) this.$emit('select', selection, row) this.selectChildren(row, selected, selection) return } this.$emit('select', selection, row) },
selectChildren(row, selected, selection) { if (row[this.children] && Array.isArray(row[this.children])) { let children = [] row[this.children].forEach(item => { this.toggleSelection(item, selected) if (selection) { if (selected && !selection.includes(item)) { selection = selection.concat(item) this.$emit('select', selection, item) } if (!selected && selection.includes(item)) { selection = selection.filter(ele => ele !== item) this.$emit('select', selection, item) } } children.push(item) }) children.forEach(item => { this.selectChildren(item, selected, selection) }) } }
|
方法二: 从根级开始遍历, 先从根级的一叉树遍历到叶子节点, 再遍历与叶子节点的父级同级的节点, 依次回到根级
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| selectChildren(row, selected, selection) { if (row[this.children] && Array.isArray(row[this.children])) { row[this.children].forEach(item => { this.toggleSelection(item, selected) if (selection) { if (selected && !selection.includes(item)) { selection = selection.concat(item) this.$emit('select', selection, item) } if (!selected && selection.includes(item)) { selection = selection.filter(ele => ele !== item) this.$emit('select', selection, item) } const result = this.selectChildren(item, selected, selection) if (result) selection = result } else { this.selectChildren(item, selected, selection) } }) if (selection) return selection } }
|
实际工作中发现的问题
data 变化后无法展开层级
由于本人测试的时候, data
是写死的, 而实际工作中, 一般都是初始值为 []
, 随后通过接口获取重新赋值. 这个时候发现展开层级无效了, 所以还需要 watch
一下 data
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45
| watch: { level: { handler: 'expandToLevel', immediate: true }, data: { handler: 'handleData', deep: true } }, methods: { async expandToLevel() { if (!this.$refs[this.ref]) return if (!this.maxLevel) { await this.handleData() } let level = 0 if (this.level <= 0) { level = this.maxLevel - 2 } else { level = this.level - 2 } for (const key in this.treeData) { if (this.treeData.hasOwnProperty(key)) { this.treeData[key].expanded = this.treeData[key].level <= level } } }, handleData() { this.$nextTick(() => { this.treeData = this.$refs[this.ref].store.states.treeData const levels = Object.values(this.treeData).map(({ level }) => level) if (levels.length) { this.maxLevel = Math.max.apply(null, levels) + 2 } else { this.maxLevel = 0 } this.$emit('max-level', this.maxLevel)
this.children = this.$refs[this.ref].treeProps.children return Promise.resolve() }) } }
|
data 数据源变化, level 不变无法展开到 level 级
data
变化后(重新请求数据, 而不是只修改某一条数据), 我们只是处理了数据, emit max-level
, 并没有处理展开层级, 并且 level
没有变化, 所以无法展开
但我们不能在 watch data
中展开层级. 因为有可能用户已经手动展开 / 折叠了一部分数据, 修改某一条数据后, data
变化了, 如果我们处理展开层级, 那用户手动操作的展开 / 折叠就没了, 用户体验就不好了
那有人说了, data
变化前后让 level
变化一下不就可以了吗? 也是一种办法, 不过如果界面中有显示 level
的话, 会闪烁一下, 也不是太好
那就加一个 prop
吧, 变化后就处理展开层级逻辑
1 2 3 4 5 6 7 8 9 10 11
| props: { refreshLevel: { type: [String, Number], default: '' } }, watch: { refreshLevel: { handler: 'expandToLevel' } }
|
这样 data
变化而 level
没有变时, 只要让 refreshLevel
变化即可
设置高度后, 初始化 table 高度超过设置的高度时, 没有竖向滚动条
最近在做项目时要默认全部展开, 那就用 default-expand-all
这个属性了, 但出问题了: 切换 data
源的时候没有问题, 默认全部展开; 但如果修改某一条数据, 也默认全部展开了(应该是 el-table
检测到 data
变化后, 执行了 default-expand-all
), 用户之前手动展开 / 折叠的都没有保留
那这个问题上面已经解决了: 不用 default-expand-all
, 加了一个 refreshLevel
参数, 切换 data
源的时候改变 refreshLevel
值, 修改某一条的时候不做处理
但由于没有了 default-expand-all
, el-table
计算高度的时候默认是按照全部折叠计算的(全部折叠确实没有超出规定高度, 没有滚动条), 而我们手动操作全部展开了, 这个操作应该是在计算高度之后, 所以没有滚动条了
为什么这么说呢? 因为我发现有滚动条的时候, el-table
多了一个 class
: el-table--scrollable-y
. 而查看源码后发现这个 class
就是动态计算出来的:
node_modules/element-ui/packages/table/src/table.vue:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| <div class="el-table" :class="[{ 'el-table--fit': fit, 'el-table--striped': stripe, 'el-table--border': border || isGroup, 'el-table--hidden': isHidden, 'el-table--group': isGroup, 'el-table--fluid-height': maxHeight, 'el-table--scrollable-x': layout.scrollX, 'el-table--scrollable-y': layout.scrollY, 'el-table--enable-row-hover': !store.states.isComplex, 'el-table--enable-row-transition': (store.states.data || []).length !== 0 && (store.states.data || []).length < 100 }, tableSize ? `el-table--${ tableSize }` : '']" @mouseleave="handleMouseLeave($event)">
|
node_modules/element-ui/packages/table/src/table-layout.js
1 2 3 4 5 6 7 8 9 10 11 12 13
| updateScrollY() { const height = this.height; if (height === null) return false; const bodyWrapper = this.table.bodyWrapper; if (this.table.$el && bodyWrapper) { const body = bodyWrapper.querySelector('.el-table__body'); const prevScrollY = this.scrollY; const scrollY = body.offsetHeight > this.bodyHeight; this.scrollY = scrollY; return prevScrollY !== scrollY; } return false; }
|
由于我们是拿到数据后手动来改变展开折叠的, 错过了 el-table
计算高度判断是否需要出滚动条的时机, 所以需要再让计算一次
那这就好办了呀, 自己使用 ref
调用这个函数不就可以了吗?
可以是可以了, 但由于官方文档并没有写这个方法, 还是不建议这么做, 而且人家不是已经提供了一个方法吗: doLayout
, 咱直接调这个方法不香吗
注: 需要在 $nextTick
中使用该方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| async expandToLevel() { if (!this.$refs[this.ref]) return if (!this.maxLevel) { await this.handleData() } let level = 0 if (this.level <= 0) { level = this.maxLevel - 2 } else { level = this.level - 2 } for (const key in this.treeData) { if (this.treeData.hasOwnProperty(key)) { this.treeData[key].expanded = this.treeData[key].level <= level } } this.$nextTick(() => { this.$refs[this.ref].doLayout() }) }
|
columns 中 label、prop 需支持动态设置
有一个项目是动态表头, 需要后端生成返给前端. 当然可以和后端约定好字段, 但咱们作为一个成熟的框架, 也需要支持这个需求
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| props: { keyProps: { type: Object, default() { return null } } }, computed: { cols() { return this.keyProps ? this.columns.map(column => ({ ...column, prop: column[this.keyProps.prop || 'prop'], label: column[this.keyProps.label || 'label'] })) : this.columns } }
|
目前只支持这两个字段, 以后有可能还需要扩展支持更多自定义字段, 需要的时候再加