我们需要实现的功能
- 展示数据(带边框,紧凑型/松散型,斑马条纹)
- 选中数据(单选/全选)
- 展示排序
- 固定表头/列
- 可展开
组件的基本结构
- table.vue
#
{{column.text}}
{{index}}
{{item[column.field]}}
- demo.vue
data() {
return {
columns: [
//表头每一列显示的文本和字段
{text: '姓名', field: 'name'},
{text: '分数', field: 'score'}
],
dataSource: [
{id: 1, name: '发发', score: 100},
{id: 2, name: '琳琳', score: 99}
]
}
},
实现checkbox全选和单选
思路: 通过单向数据流,外界好传入一个selectedItem,table接受这个selectedItem,默认为空数组
单选:对表单内容的checkbox添加change事件onChangeItem把每一列行的index,item还有原生event,如果是选中状态就把传入的所有数据的数组dataSource作为参数通过refs.a.indeterminate = true,其他情况都等于false
- demo.vue
- table.vue
#
{{column.text}}
{{index+1}}
{{item[column.field]}}
解决取消选中异常的bug
当我们全选的时候,比如我点击第二个取消选中可最后一个也跟着取消选中了,原因就是因为深拷贝的原因,因为深拷贝后数组里的对象和原先的不是同一个索引,所以不能通过indexOf查找,而要通过取消选中的时候,当前取消选中项的id,找到深拷贝后数组里的id不等于当前选中项的id的元素,然后传给父组件
let copy = JSON.parse(JSON.stringify(this.selectedItem))
if(e.target.checked){
copy.push(item)
}else{
//取消选中状态:点击当前的checkbox保留数组中id不等于当前id的项
copy= copy.filter(i=>i.id !== item.id)
}
this.$emit('update:selectedItem',copy)
},
watch: {
selectedItem(){
if(this.selectedItem.length === this.dataSource.length){
this.$refs.a.indeterminate = false
}else if(this.selectedItem.length === 0){
this.$refs.a.indeterminate = false
}else{
this.$refs.a.indeterminate = true
}
}
}
实现选中所有单选的checkbox后,全选的checkbox也跟着选中
错误写法:
直接判断selectedItem.length和dataSource.length的长度是否相等,这样虽然可以实现我们要的功能,但是是错误的写法
computed: {
areAllItemChecked(){
return this.selectedItem.length === this.dataSource
}
}
正确写法:
我们需要判断这两个数组里的所有项的id是否都相等来判断是否全选。
那么现在的问题是我们如何判断两个数组是一样的。比如this.dataSource=[{id:1},{id:2}]
和this.selectedItem = [{id:2},{id:1}]
我们该如何判断两个数组是一样的哪?
我们无法通过遍历判断第一个dataSource的第一个是否等于selectedItem的第一个来判断是否相等,因为上面的顺序不一样,但也是相等的。
所以我们需要
- 先对这两个数组里的id进行排序
this.dataSource.sort((a,b)=>a.id - b.id)
但是sort会改变原来的数组,所以我们需要先用map生成一个新的数组,然后在排序
areAllItemChecked(){
const a = this.dataSource.map(n=>n.id).sort()
const b = this.selectedItem.map(n=>n.id).sort()
if(a.length === b.length){
for(let i = 0;i
表格排序的实现
补充知识:在vue里,你不可能根据一个属性检查另一个属性的合法性,因为你在validate中拿不到实例(this)
export default{
props: {
name: 'lifa'
},
orderBy: {
type: Object,
default: ()=>({}),
validator(object){
console.log(this)//undefined
}
}
}
定义排序规则:
通过外界传入一个ordreBy对象来定义
orderBy: {//true:开启排序,但是不确定asc desc
name: true,
score: 'desc'
},
如果想要某个列按升序排列就在当前字段后加一个'asc',降序就加'score',默认开启排序,但是不是升序也不是降序就用true,然后在组件中接受这个orderBy
{{column.text}}
props: {
//通过什么排序
orderBy: {
type: Object,
default: ()=>({})
}
},
methods: {
changeOrderBy(key){
const copy = JSON.parse(JSON.stringify(this.orderBy))
if(copy[key] === 'asc'){
copy[key] = 'desc'
}else if(copy[key] === 'desc'){
copy[key] = true
}else{
copy[key] = 'asc'
}
this.$emit('update:orderBy',copy)
}
}
上面的代码可以实现状态的切换,当点击的时候如果是升序就会变成降序,如果是降序就会变成默认状态,否则就会变成升序,然后需要在外界.sync一下
然后我们只需要监听这个update:orderBy事件,当事件触发的时候执行一个方法,来修改我们dataSource里的数据
methods: {
changeOrder(data){
this.$nextTick(()=>{
let type
let arr = this.dataSource.map(item=>{
type = typeof item[this.key]
return item[this.key]
})
if( data[this.key]=== 'asc'){
if(type === 'number'){
arr.sort((a,b)=>a-b)
}else{
arr.sort((a, b) => b.localeCompare(a, 'zh-Hans-CN', {sensitivity: 'accent'}))
}
}else if(data[this.key] === 'desc'){
if(type === 'number'){
arr.sort((a,b)=>b-a)
}else{
arr.sort((a, b) => a.localeCompare(b, 'zh-Hans-CN', {sensitivity: 'accent'}))
}
}
arr.map((item,index)=>{
this.dataSource[index][this.key]=item
})
})
},
},
watch: {
//监听orderBy的变化,如果新值里的属性值不等于旧值的属性值说明当前属性变了,拿到这个key,比如我一开始是{name: '发发',score:100}现在是{name: '发发',score:90},那么我们就可以拿到score这个key
orderBy(val,oldVal){
for(let key in this.orderBy){
if(val[key] !== oldVal[key]){
this.key = key
}
}
}
},
实现表头固定
思路:
- 对table拷贝一份,然后把拷贝后的table中的tbody删除,只留一个thead固定在顶部(这里因为thead必须在table中,所以我们不能像div一样把它单独拿出来)
- 复制后的table删除tbody后,宽度会和之前的不一致,这时候你需要获取拷贝前的table里每一个th的宽度,然后设置给拷贝后的th
- table.vue
#
{{column.text}}
{{index+1}}
{{item[column.field]}}
props: {
height: {
type: [Number, String],
}
},
mounted(){
let oldTable = this.$refs.table
let newTable = oldTable.cloneNode(true)
this.newTable = newTable
this.updateHeadersWidth()
//窗口改变时重新获取一下各个th的宽度,重新赋值
window.addEventListener('resize', this.onWindowResize)
newTable.classList.add('lifa-table-copy')
this.$refs.warpper.appendChild(newTable)
},
beforeDestroy() {
window.removeEventListener('resize', this.onWindowResize)
this.newTable.remove()
},
methods: {
onWindowResize() {
this.updateHeadersWidth()
},
updateHeadersWidth() {
let tableHeader = Array.from(this.$refs.table.children).filter(node => node.nodeName.toLocaleLowerCase() === 'thead')[0]
let tableHeader2
Array.from(this.newTable.children).map((node) => {
if (node.nodeName.toLocaleLowerCase() === 'tbody') {
node.remove()
} else {
//也就是tableHeader=
tableHeader2 = node
}
})
Array.from(tableHeader.children[0].children).map((node, index) => {
let {width} = node.getBoundingClientRect()
console.log(width);
tableHeader2.children[0].children[index].style.width = `${width}px`
})
},
}
上面的代码虽然实现了顶部表头固定,但是因为你是复制了一个,所以表头的点击事件并不会起作用。
解决方法:
我们在拷贝一个新的table的时候不全部拷贝只拷贝table不拷贝table里面的子元素,然后我们把原有的thead添加到拷贝后的table中,这样就还是原来的thead,而且可以单独拿出来,然后我们通过让用户给每一列传一个宽度,通过这个宽度来设置原来的table里的宽度,并且拷贝后的table因为会覆盖表格内容的第一行,所以还要通过margin-top移下来thead的高度的距离
data(){
return {
columns: [
//表头每一列显示的文本和字段
{text: '姓名', field: 'name', width: 100},
{text: '分数', field: 'score',width: 100},
{text: '年龄', field: 'age'}
],
orderBy: {//true:开启排序,但是不确定asc desc
name: true,
score: 'desc'
},
dataSource: [
{id: 1, name: '发发', score: 100, age:18},
{id: 2, name: '琳琳', score: 99, age: 16},
{id: 3, name: '西西', score: 99, age: 20},
{id: 4, name: '泳儿', score: 99, age: 21},
{id: 5, name: '美美', score: 99, age: 22},
{id: 6, name: '阿宇', score: 99, age: 26},
{id: 7, name: '发发', score: 100, age:18},
{id: 8, name: '琳琳', score: 99, age: 16},
{id: 9, name: '西西', score: 99, age: 20},
{id: 10, name: '泳儿', score: 99, age: 21},
{id: 11, name: '美美', score: 99, age:18},
{id: 12, name: '阿宇', score: 99, age: 16}
],
}
}
- table.vue
#
{{column.text}}
{{index+1}}
{{item[column.field]}}
mounted() {
let oldTable = this.$refs.table
let newTable = oldTable.cloneNode()
let {height} = oldTable.children[0].getBoundingClientRect()
newTable.appendChild(oldTable.children[0])
this.$refs.tableContent.style.marginTop = height + 'px'
this.$refs.wrapper.style.height = this.height - height + 'px'
newTable.classList.add('lifa-table-copy')
this.$refs.wrapper.appendChild(newTable)
},
实现展开行功能
实现思路:通过用户在dataSource里传入description指定展开要显示的字段,然后通过一个expend-field传入我们的description字段,也就是expend-field="description"
。因为我们要在每一个tr下面再加一个tr,但是你不能再单独v-for那就不能同步了,所以要想同时遍历多个外层就要用template,然后给每一列前面加一个展开的按钮,点击这个按钮的时候把当前的id传给一个数组,然后通过判断数组里是否有这个id,没有就加到数组里,有就删除,之后再给展开的这一栏通过判断数组里是否有当前id来让它是否展示
- table.vue
{{index+1}}
{{item[column.field]}}
{{item[expendField] || '空'}}
methods: {
expendItem(id){
if(this.expendIds.indexOf(id) >= 0){
this.expendIds.splice(this.expendIds.indexOf(id),1)
}else{
this.expendIds.push(id)
}
},
expendVisible(id){
return this.expendIds.indexOf(id) >= 0
},
}
dataSource: [
{id: 1, name: '发发', score: 100, age:18, description: '你最帅'},
{id: 2, name: '琳琳', score: 99, age: 16, description: '为啥不做我媳妇'},
{id: 3, name: '西西', score: 99, age: 20, description: '好累啊'},
{id: 4, name: '泳儿', score: 99, age: 21},
{id: 5, name: '美美', score: 99, age: 22},
{id: 6, name: '阿宇', score: 99, age: 26},
{id: 7, name: '发发', score: 100, age:18},
{id: 8, name: '琳琳', score: 99, age: 16},
{id: 9, name: '西西', score: 99, age: 20},
{id: 10, name: '泳儿', score: 99, age: 21},
{id: 11, name: '美美', score: 99, age:18},
{id: 12, name: '阿宇', score: 99, age: 16}
],
上面的展开行里的列数是写死的,而我们有可能没有展开按钮或者没有选择框,那么这个列数就不对了,所以我们需要通过计算属性来计算
{{item[expendField] || '空'}}
computed: {
expendedCellColSpan(){
let result = 0
if(this.checkable){
result += 1
}
if(this.expendField){
result += 1
}
return result
}
},
props: {
//是否显示选择框
checkable: {
type: Boolean,
default: false
}
}
table里面可以有按钮
思路通过用户传入一个template,然后在table中通过slot来把你template里的按钮放到指定位置
- table.vue
methods: {
edit(id){
alert(`正在编辑第${id}个`)
},
view(id){
alert(`正在查看第${id}个`)
}
}
升级table组件,支持自定义
问题:我们table里只能传入data而不能传入a标签,我们现在想给每一列添加一个a标签,使用vue就没法实现
思路:使用jsx
- 在vue中使用JSX
(1).lang="jsx"
(2).render返回一个标签
(3).使用css直接还是用class
问题:我们必须得将之前的代码都改成jsx,而且使用的人也必须使用jsx格式,限制性太多
- 使用slot插槽自定义
相关补充:我们可以在组件里写任何组件标签,这些组件标签会默认转为插槽,获取他们的方式有两种,一:在mounted中通过this.children拿到他们
将原来的columns数组去掉,使用组件table-columns来代替
- table-columns.vue
- demo.vue
{{scope.value}}
import LfTable from './table'
import LfTableColumn from './table-column'
export default {
name: "demo",
components: {
LfTable,
LfTableColumn
},
data() {
return {
dataSource: [
{id: 1, name: '发发', score: 100, age:18, description: '你最帅,将来一定会成为一个了不起的演员'},
{id: 2, name: '琳琳', score: 99, age: 16, description: '为啥不做我媳妇'},
{id: 3, name: '西西', score: 99, age: 20, description: '好累啊'},
{id: 4, name: '泳儿', score: 99, age: 21},
{id: 5, name: '美美', score: 99, age: 22},
{id: 6, name: '阿宇', score: 99, age: 26},
{id: 7, name: '泳儿', score: 99, age: 21},
{id: 8, name: '美美', score: 99, age: 22},
{id: 9, name: '阿宇', score: 99, age: 26}
],
selectedItem: [],
}
},
- table.vue
// 在原来遍历td内容的地方直接加判断
{{column.render ? column.render({value: item[column.field]}) : item[column.field]}}
data() {
+ columns: []
},
mounted() {
// this.$slots遍历拿到每一个table-columns组件
this.columns = this.$slots.default.map(node => {
// node.componentOptions.propsData拿到组件中外界传进来的props的值
let { text, field, width } = node.componentOptions.propsData
// 如果组件里面使用了插槽那么就可以拿到插槽里对应的render函数也就是
//
// {{scope.value}}
//
let render = node.data.scopedSlots && node.data.scopedSlots.default
return {
text, field, width, render
}
})
// 这里之所以要render里面传一个对象key是value,是因为我们前面写的scope.value,
// scope就是我们的形参也就是下面的{value:'立发'}
let result = this.columns[0].render({value: '立发'})
console.log(result)
}
问题: 上面的代码如果直接在原来的td渲染的时候修改会报错
单独将this.columns[0].render({value: '立发'})打印我们发现,他是一个Vnode
问题:那么我们该如何将vnode展示在template里哪
方法:
(1).声明一个vnodes组件如下
components: {
LfIcon,
vnodes: {
function: true,
render: (h, ctx) => ctx.props.vnodes
}
},
(2).对vnodes组件传入一个vnodes,绑定的数据就是你要传入的value
{{item[column.field]}}