前言
Element-ui开源以来,已经成为前中后台系统中最受欢迎的UI框架。
如果说 Vue、React、Angular 是前端三剑客的话,那么 element-ui 可以说占据了中后端领域的半壁江山,拥有 43k 个 github stars。截至目前,它已有 84 个组件(版本 2.13.0)。
从第 2 行开始,前两行为空。
原因
需求:由于公司业务需要,页面上经常会有需要进行多项选择(勾选)的表格,然后将勾选的ID组装成字符串提交给后台。
解决方法:查看element-ui官网的文档,可以找到在table组件中实现多选表格的方法,只需要在table组件中添加一个类型为selection的列即可。
table
ref="multipleTable"
:data="tableData"
tooltip-effect="dark"
style="width: 100%"
@selection-change="handleSelectionChange">
table-column
type="selection"
width="55">
table-column>
table>
通过selection-change事件,您可以获取用户选择的行数组。
效果如下:
一切看上去都很完美,但在实际使用中却出现不少问题。
为什么这么说呢?因为在公司实际业务中,表格是分页表,每次切换页码,都要重新获取数据,重新渲染表格。那么第一个问题就来了:切换页码后,页码为 1 的表格中用户选中的行就消失了。
分页表应如下所示:
于是就去element-ui官网查看文档,在table组件中找到了toggleRowSelection这个方法,这个方法可以切换表格中某一行的具体选中状态。
通过这个方法,我们在获取到表格数据之后,就立刻用这个方法去设置之前选中的数据,这样,当用户切换的时候,之前选中的行选择状态也能被渲染出来,对吧?
坑又来了!!
因为selection-change事件获取到的是一个叫selection的数组,这个数组里保存着用户选中的行的信息,我们将这个数组保存在一个变量里,这样用户在切换页面之后,仍然可以看到之前选中的行,然后使用toggleRowSelection方法设置选中的行信息。
selection.forEach(row => {
this.$refs.multipleTable.toggleRowSelection(row);
});
这个乍一看好像没什么问题,但是在表格里,却起不到任何制约作用!!?
我去百度搜了一下,说在nextTick中调用这个方法:
this.$nextTick(() => {
selection.forEach(row => {
this.$refs.multipleTable.toggleRowSelection(row);
});
});
嗯,没有错误,我打开页面一看,咦??为什么还是没选上!!!
我当时就想,妈的,这家伙刚刚路过……
确认了ref名称是否一致、selection内的数据是否存在、调用方法是否触发之后,还是得不到我想要的结果。
弹奏一串弦乐...
冷静了一会儿之后,我决定打开element-ui源码看看table组件是如何确定选择的?
源码分析
仅表格组件的部分源代码。
3.1 结构
首先看一下表格组件的结构
结构是这样的,最外层的index.js是用来导出table模块的,里面的代码也很简单,你肯定能看懂。
// index.js
import ElTable from './src/table';
/* istanbul ignore next */
ElTable.install = function(Vue) {
Vue.component(ElTable.name, ElTable);
};
export default ElTable;
然后 src 里面包含一个 store 文件夹和一些表格组件:body、column、footer、header、layout 等,一个工具文件 util.js,一个配置文件 config.js,还有一个 dropdown(看不懂),一个 layout-observer(看名字就知道是用来监听布局的),还有一个 filter-panel(用来过滤的),就这些了。
store文件夹中的代码实现了一个私有的Vuex,仅用于table组件中组件之间的数据交换。
3.2 查找
根据我的需求,我只需要查看有关选择的源代码。因此从布局开始,我可以从列开始,即 table-column.js 文件。
然而,查看了table-column.js之后,确实有一些关于列的内容,但是我找不到选择功能的任何代码。
于是我暂时放弃在布局中搜索,直接在方法 toggleRowSelection 中搜索。在这个表格文件夹中,我搜索了关键字 toggleRowSelection,在 src/store/watcher.js 中找到了以下内容:
// watcher.js 158行
toggleRowSelection(row, selected, emitChange = true) {
const changed = toggleRowStatus(this.states.selection, row, selected);
if (changed) {
const newSelection = (this.states.selection || []).slice();
// 调用 API 修改选中值,不触发 select 事件
if (emitChange) {
this.table.$emit('select', newSelection, row);
}
this.table.$emit('selection-change', newSelection);
}
}
这个方法是暴露给外部让我们调用的,第一行是主要的信息,调用toggleRowStatus方法获取变化的值,然后emit这个值,大概就是这个流程,所以我们还是从toggleRowStatus开始吧。
请注意,第一行中的 this.states.selection 将成为后续行的关键。
直接搜索关键字,可以发现这个方法是从外部导出中引用的。
import { getKeysMap, getRowIdentity, getColumnById, getColumnByKey, orderBy, toggleRowStatus } from '../util';
打开util.js文件,成功找到如下代码:
export function toggleRowStatus(statusArr, row, newVal) {
let changed = false;
const index = statusArr.indexOf(row);
const included = index !== -1;
const addRow = () => {
statusArr.push(row);
changed = true;
};
const removeRow = () => {
statusArr.splice(index, 1);
changed = true;
};
if (typeof newVal === 'boolean') {
if (newVal && !included) {
addRow();
} else if (!newVal && included) {
removeRow();
}
} else {
if (included) {
removeRow();
} else {
addRow();
}
}
return changed;
}
解释起来并不难,方法名的字面意思就是:切换行的状态,一共有两个方法addRow和removeRow,这两个方法都有字面意思。
主要作用是判断是否是新值(newVal),如果不存在(!included)则添加,否则移除。主要就是获取索引值,很简单粗暴,直接用Array.prototype.indexOf判断就行了。想想是不是这个原因?
Array.prototype.indexOf():该方法返回在数组中找到给定元素的第一个索引,如果不存在则返回 -1。
陷阱1:如果这个元素是一个对象的话,要知道对象是引用类型,也就是说用indexOf来判断只能判断对象引用的地址是否相同,而不能判断里面的值是否相同。
不过我仔细想了想好像这对表格没什么影响,我们来详细想一下:我们初始化表格,有10条数据,此时表格组件中用来存储选中行的数组selection一开始是空的,然后我们调用toggleRowSelection主动设置选中行,并把这些行放入表格组件中的selection中(通过toggleRowStatus中的addRow方法)。
已经放进去了,为什么不渲染出相应的状态!!?
因为我们知道表格组件是通过选择来存储选中的行的,所以我们来搜索选择。
我们在 7 个文件中得到了 78 个结果。结果太多了,我们不希望这样。
然后进一步进行完全匹配搜索:
在 5 个文件中找到 38 条结果。
我稍微缩小了范围,但还是有很多,没办法,只能一个一个的找。
纯粹按照Vscode给我搜索的顺序,第一个文件是config.js文件。
这个关键字在config.js文件中出现了4次,可以看到前两次匹配的结果都是一种样式,这并不是我们想要的。
最后两次值得一看。
// config.js 29行
// 这些选项不应该被覆盖
export const cellForced = {
selection: {
renderHeader: function(h, { store }) {
return disabled={ store.states.data && store.states.data.length === 0 }
indeterminate={ store.states.selection.length > 0 && !this.isAllSelected }
nativeOn-click={ this.toggleAllSelection }
value={ this.isAllSelected } />;
},
renderCell: function(h, { row, column, store, $index }) {
return nativeOn-click={ (event) => event.stopPropagation() }
value={ store.isSelected(row) }
disabled={ column.selectable ? !column.selectable.call(null, row, $index) : false }
on-input={ () => { store.commit('rowSelectedChanged', row); } } />;
},
sortable: false,
resizable: false
}
// ...省略
}
我只贴有用的,从大耳朵里看到导出了一个名为cellForced的模块,虽然不知道是什么意思。(我四级没过,该死的)。
不过里面两个函数我看懂了,看到关键字render,就是渲染的意思,往里面一看,天呐,幸福啊!里面有个el-checkbox组件,就是多选模式下的列(除此之外,table里什么都放不了!)。
事实上,只有第四个关键字出现在第34行,这才是我们想要的选择。
不确定={store.states.selection.length>0&&!this.isAllSelected}
分析:store.states.selection:我是包含选定行的数组集合。
事实上,如果我们查看搜索结果中的第三个文件:watcher.js,我们可以清楚地找到它:
而在第五个文件:table.vue中,mapStates用来进行地图选择,同样能找到它的影子:
然后我们就不需要再关心这两个文件了,因为我们已经找到了布局的位置,返回config.js:
// config.js 29行
// 这些选项不应该被覆盖
export const cellForced = {
selection: {
renderHeader: function(h, { store }) {
return disabled={ store.states.data && store.states.data.length === 0 }
indeterminate={ store.states.selection.length > 0 && !this.isAllSelected }
nativeOn-click={ this.toggleAllSelection }
value={ this.isAllSelected } />;
},
renderCell: function(h, { row, column, store, $index }) {
return nativeOn-click={ (event) => event.stopPropagation() }
value={ store.isSelected(row) }
disabled={ column.selectable ? !column.selectable.call(null, row, $index) : false }
on-input={ () => { store.commit('rowSelectedChanged', row); } } />;
},
sortable: false,
resizable: false
}
// ...省略
}
一共使用了两个渲染函数,一个用于渲染表头,一个用于渲染网格。通过el-checkbox组件的属性值,我们可以确定在第41行:
value={ store.isSelected(row) }
这行就是渲染是否被选中的关键,逻辑很简单,就是调用了一个方法叫:isSelected,并且还告诉我们这是store中的一个方法。
好的,找到store文件夹,搜索isSelected关键字。我们在watcher.js中找到了它:
// watcher.js 120行
// 选择
isSelected(row) {
const { selection = [] } = this.states;
return selection.indexOf(row) > -1;
},
里面的逻辑就更简单了,就是取出包含选中行的选择数组,然后返回该行在选择中的位置是否大于-1,根据渲染函数中的内容判断,如果返回值为true,则渲染为选中,否则为未选中;
陷阱2:再次使用indexOf来判断对象是否在数组中。
这个地方很致命,为什么我这么说呢?
因为selection确实存储了toggleRowSelection设置的row。但是在isSelected中,参数row是从table组件中的props中的数据传递过来的。
数据是通过重新请求接口来获取的,所以data里的行和selection里存储的行不是同一行。
这句话听上去好绕啊,我们回到最基本的,Row 是一个对象,是引用类型,只要引用的地址不一样,你就不再是你自己了。
虽然这两行的数据结构和内容相同,但我们假设它们如下:
const row1 = { name: '1', id: 0, code: 110110, area: '北京市', street: '二环' }
const row2 = { name: '1', id: 0, code: 110110, area: '北京市', street: '二环' }
row1 和 row2 不相等。
但是根据我们实际的业务,row1和row2的structure和id是相同的,所以这两个是同一个东西。
我给你举个更现实的例子:你二叔从村里的瓦房里走出来,你认出他是你二叔;你看见你二叔从北京东二环某小区某单位走出来,你二叔不是你二叔。这简直可笑!!
所以,这就说明,表格组件渲染源码时的判断太简单了,并没有做更深层次的判断,只是对比了一下引用地址是否相同。
一旦找到问题的根源,解决它就很容易了。
解决方案
上面的1、2、3、4方法我都实现过了,根据具体业务需求不同而不同。
我只实现了一层深度比较,我的想法是先比较key值的数量,然后判断你二叔的key到你大叔的属性是否存在,值是否相等,这是因为我的业务数据只有一层属性值。
4.1 第一种方法是自己渲染一个新的el-checkbox(shaonialife提出):
// 通过自己渲染一个新的<el-checkbox />
<el-table-column
:align="tableColumn.align"
type="index"
label="序号"
width="70">
<template slot-scope="scope">
<el-checkbox :value="!!checkedRowIds[scope.row.id]" @change="(val) => { toggleRowSelection(val, scope.row) }"/>
template>
el-table-column>
checkedRowIds 是一个包含带有 id 的键的集合的对象,如下所示:
const checkedRowIds = {
0: true,
1: true,
2: false
}
当row.id = 1时,checkedRowIds[row.id]相当于checkedRowIds.1,该值为true,为true则渲染checked状态。
然后我们看一下change事件的回调函数toggleRowSelection
toggleRowSelection(val, row) {
const { checkedRowIds } = this.$props
const { id } = row
this.$set(checkedRowIds, id, val)
const includes = checkedRowIds.hasOwnProperty(id)
const remove = () => delete checkedRowIds[id]
if (!val && includes) remove()
const keys = Object.keys(checkedRowIds)
const arrToString = arr => arr.join(',')
const ids = arrToString(keys)
// ids: 1,2,3
},
首先解构获取checkedRowIds和row.id,然后使用this.$set方法将键为id,值是val的项设置到checkedRowIds对象集合中。
然后判断val是否为false,以及key是否在checkedRowIds中存在,如果两个条件都满足,则删除该属性。最后通过Object.keys()获取checkedRowIds中所有key的集合,再通过join()方法组成字符串ids。
分析:这个方法很棒,!!checkedRowIds[scope.row.id]很短小精悍,然后进入页面的时候默认勾选就很方便了,只要checkedRowIds里面有一个就会勾选,不用再调用toggleRowSelection设置了。
4.2 第二种方法,采用row-key和reserve-selection相结合的方式(Han__提出)
// 利用row-key与reserve-selection的组合拳
table
v-loading="loading"
:data="tableData"
row-key="id"
@selection-change="rowChange"
>
table-column
:reserve-selection="true"
type="selection"
width="55"/>
table>
将row-key设置为id之后,每一行都有一个非唯一的键值,然后通过reserve-selection字段保留每次选择的结果,这样不管页码如何切换,都能保留用户先前选择的值。
分析:这种方法很简单,主要用了官方的属性,不用改太多代码,这点很好。但是进入页面后,要显示默认勾选的行,需要和tableData中的数据进行对比,然后通过toggleRowSelection方法进行设置。
写在后面
我之所以能成功,是因为我站在巨人的肩膀上。————牛顿
非常感谢大家的集思广益,评论区的每一条留言我都认真看,每一个方案我都会去实现,谢谢大家,希望在这条路上我能走得更远。