vue3+tsx实现原生table表头单元格合并

723 阅读9分钟

前言:在前端开发中,table组件用的应该是最多的了,尤其是在后台管理系统中表格更是常见,大部分ui组件库都封装了table组件,封装的功能也很完善,我们只要会用就行了,这也导致了大部分人只停留在了会使用,本篇文章就带大家来学习一下table组件的封装,table组件常用功能的封装在上一篇文章已经讲过了,本篇文章主要分享的是表头单元格合并功能。

实现效果

20241018-092730.jpg

仓库地址

Gitee: gitee.com/sz-liunian/…

了解table

table一般由thead和tbody组成,包含了一个或多个tr、th或td

  • tr 标签表示表格里面的一行
  • th 标签表示表头里面的一行
  • td 标签表示一个单元格

合并单元格使用的是rowspan 和colspan 属性,rowspan实现跨行合并,colspan实现跨列合并

跨行合并

<th rowspan="3"></th>
垂直方向三个单元格合并成一个单元格

img_v3_02fl_4f7d86a2-cddf-4230-b453-a8561ddf369g.jpg

跨列合并

<th colspan="3"></th>
水平方向三个单元格合并成一个单元格

img_v3_02fl_ed63bec8-a8cd-41b2-8c67-31aec27c0efg.jpg

分析elementui和ant-design-vue table 组件

elementui table 组件 使用的是 <el-table-column><el-table-column>渲染列

img_v3_02fl_e9586f99-f6b6-48c4-aa82-d1d12467fa9g.jpg

ant-design 使用columns数组定义列

img_v3_02fl_020a49ff-c918-4902-af65-452160dd37ag.jpg 比较两种方式,显而易见使用columns数组定义列更简单、更易维护。

表头单元格合并分析

这里我们也仿照ant-design定义多级表头columns(添加children子列),其他参数定义请看上篇文章

从封装一个表格组件开始,带你深入学习vue3

const columns =  [
    {
      title: '日期',
      width: 100,
      key: 'date',
      ellipsis: true,
      fixed: 'left',
      slotHeader: 'slotDate'
    },
    {
      title: '基本信息',
      key: 'baseInfo',
      children: [
        {
          title: '姓名',
          width: 95,
          key: 'name',
          fieldName: 1,
          align: 'right',
          commaSplice: true,
          // formatter
        },
        {
          title: 'other',
          key: 'other',
          children: [
            {
              title: '年龄',
              width: 95,
              key: 'age',
              fieldName: 1,
              align: 'right'
            },
            {
              title: '公司',
              width: 95,
              key: 'company',
              fieldName: 1,
              align: 'right'
            },
          ]
        }
      ]
    },
    {
      title: '岗位',
      width: 95,
      key: 'work',
      fieldName: 1,
      align: 'right',
      commaSplice: true,
    },
    {
      title: '城市',
      width: 130,
      key: 'city',
      align: 'right',
      commaSplice: true,
      fieldName: 3,
      ellipsis: true
    }
  ]

我们期望渲染的效果

img_v3_02fl_7fd247df-18b0-4f7a-9a16-d38ba6cd7ccg.jpg

为方便给每一个表头设置colSpan、rowSpan属性,我们可以把数组扁平化处理,并且给每列数组添加hasChildren、deep、keysStr属性。

  • hasChildren:有子选项设置为true,反之为false
  • deep:代表当前的表头所在的深度(可以理解为行)
  • keysStr:值是当前表头key和其所有父表头拼接成的字符串

例如

  • 日期没有子选项hasChildren=false, 所在的深度deep =1,keysStr = 'date'

  • 基本信息有子选项hasChildren=true,所在的深度deep =1,keysStr = ‘baseInfo’

  • 姓名没有子选项hasChildren=false,所在的深度deep =2,keysStr = ‘baseInfo-name’

  • other有子选项hasChildren=true,所在的深度deep =2,keysStr = ‘baseInfo-other’

  • 年龄没有子选项hasChildren=false,所在的深度deep =3,keysStr = ‘baseInfo-other-age’

接下来添加方法,处理数据

/**
 * 数据处理
 * @params columns: 遍历的数组
 * @params obj: 处理后的结果
 * @params deep: 当前深度
 * @params isFirst: 是否是第一次调用函数
 * @params currentKey: 从父级到当前子级的key
 * @params fixed: 固定列子集添加fixed值
 */
function getColumnList(columns, obj, deep, isFirst, currentKey, fixed) {
  columns.forEach(item => {
    // 重置深度
    if (isFirst) {
      deep = 1
    }
    const ob = {
      ...item
    }
    ob.keysStr = isFirst ? ob.key : currentKey + '-' + ob.key
    ob.deep = deep
    if (fixed) {
      ob.fixed = fixed
    }
    if (!ob.children) {
      ob.hasChildren = false
      obj.list.push(ob)
    } else {
      ob.hasChildren = true
      deep++
      if (deep > obj.maxDeep) {
        obj.maxDeep = deep
      }
      obj.list.push({
        ...ob,
        children: []
      })
      getColumnList(item.children, obj, deep, false, ob.keysStr, ob.fixed || undefined)
    }
  })
}
const columnObj = computed(() => {
  const obj = {
    list: [], // 扁平化数据列表
    maxDeep: 1 // 初始值,代表深度为1,表头只占一行
  }
  getColumnList(props.columns, obj, 1, true, '', undefined)
  console.log('list===>', obj.list, obj.maxDeep)
  return obj
})

执行计算属性,打印出来的list值

img_v3_02fl_6ea8925d-fbdc-4914-95d5-d6b90c546c0g.jpg

img_v3_02fl_ba20e097-f6c6-45da-b291-8a141a13281g.jpg

从打印的数据可以看出,表头最大深度为3(表头占3行) 以上数据处理都是为了给每一个表头设置colSpan、rowSpan属性做铺垫,上面我们讲过colSpan属性是跨列合并,rowSpan属性是跨行合并,接下来给每一项设置colSpan、rowSpan属性。

设置colSpan属性

img_v3_02fl_ee62349f-521e-4541-8503-113c868e32eg.jpg 由此图可以看出

  • 日期所占的列数 = 当前项keyStr('date')包含当前key('date')属性,并且hasChildren 为false的项的长度 = 1
  • 基本信息所占的列数 = 当前项keyStr('baseInfo')包含当前key('baseInfo')属性,并且hasChildren 为false( 姓名、年龄、公司属性值hasChildren为false) 的项的长度 = 3
  • other所占的列数 = 当前项keyStr('baseInfo-other')包含当前key('other')属性,并且hasChildren 为false( 年龄、公司属性值hasChildren为false) 的项的长度= 2
  • ...
  • 可以得出 表头单元格所占列数 = 当前项keyStr包含当前key属性,并且hasChildren为false的所有项的长度

接下来修改方法

const columnObj = computed(() => {
  const obj = {
    list: [], // 扁平化数据列表
    maxDeep: 1 // 最大深度
  }
  getColumnList(props.columns, obj, 1, true, '', undefined)
  obj.list.forEach((item, index) => {
  	// 当前单元格占得列数 = 当前项keyStr包含当前key属性,并且hasChildren为false的所有项的长度
    item.colSpan = obj.list.filter(ite => ite.keysStr.includes(item.key) && !ite.hasChildren).length
  })
  return obj
})

设置rowSpan属性

img_v3_02fl_ef6ac2e6-320e-40f1-8b51-efb344155feg.jpg 由此图可看出 属性 hasChildren为false时

  • 日期 没有子选项,属性 hasChildren为false,rowspan = 1,即所占行数为1
  • 姓名 没有子选项,属性 hasChildren为false,rowspan = 1,即所占行数为1
  • 年龄 没有子选项,属性 hasChildren为false,rowspan = 1,即所占行数为1
  • ...
  • 可以得出 表头项属性 hasChildren为false时, rowspan = 1,即所占行数为1

属性 hasChildren为true时

  • 基本信息 有子选项,属性 hasChildren为true,deep为1,rowspan = 最大深度(maxDeep, 即表头占的总行数) - 当前深度 + 1 = 3 - 1 + 1 = 3
  • other 有子选项,属性 hasChildren为true,deep为2,rowspan = 最大深度(maxDeep, 即表头占的总行数) - 当前深度 + 1 = 3 - 2 + 1 = 2
  • ...
  • 可以得出 表头属性 hasChildren为true时,rowspan = 最大深度(maxDeep, 即表头占的总行数) - 当前深度 + 1

接下来修改计算属性

const columnObj = computed(() => {
  const obj = {
    list: [], // 扁平化数据列表
    maxDeep: 1 // 最大深度
  }
  getColumnList(props.columns, obj, 1, true, '', undefined)
  obj.list.forEach((item, index) => {
    // 当前单元格占得列数 = 当前项keyStr包含当前key属性,并且hasChildren为false的所有项的长度
    item.colSpan = obj.list.filter(ite => ite.keysStr.includes(item.key) && !ite.hasChildren).length
    if (!item.hasChildren) {
      // 表头属性 hasChildren为true时,rowspan =  最大深度(maxDeep, 即表头占的总行数) - 当前深度 + 1
      item.rowSpan = obj.maxDeep - item.deep + 1 // 根据表格得出规律
    } else {
      // 表头项属性 hasChildren为false时, rowspan = 1,即所占行数为1
      item.rowSpan = 1
    }
  })
  console.log('list===>', obj)
  return obj
})

打印一下list数据

img_v3_02fl_61830c44-cfae-4f9d-8664-bb8eab9b37bg.jpg

修改计算属性columnArr

const columnArr = computed(() => {
  const { list, maxDeep } = unref(columnObj)
  let obj = fixedArr.reduce((pre, key) => {
    pre[key] = []
    return pre
  }, {})
  list.forEach((item, index) => {
    // fixed 值为 String
    if (item.fixed === 'left' || item.fixed === 'right') {
      obj[item.fixed].push(item)
    } else {
      obj.center.push(item)
    }
  })
  const wt = fixedArr.reduce((pre, key) => {
    pre[key] = getColWidth(obj[key], fixedWidthDefault.value)
    return pre
  }, {})
  const arr = []
  Object.keys(obj).forEach(key => {
    if (obj[key].length > 0) {
      arr.push({
        titleList: getColumnArr(obj[key], maxDeep),
        col: obj[key].filter(item => !item.hasChildren),
        fixed: key,
        colWidth: wt[key], // 列宽度
        // 此处宽度需动态计算,屏幕宽度不一样对应的1rem等于多少px不一样
        width: ['left', 'right'].includes(key) ? wt[key] + 'px' : `calc(100% - ${wt.left + wt.right}px)`,
        headerSlots: getHeaderSlots(obj[key]),
        colSlots: obj[key].filter(item => slots[item.key]),
        shadow: key !== 'center' ? isXScroll.value : false
      })
    }
  })
  // 最后一列添加类名
  const objeFirst = arr[0]
  objeFirst.isFirstCol = true
  const objeEnd = arr[arr.length - 1]
  objeEnd.isLastCol = true
  return arr
})

修改之后index.js文件代码如下,由于代码过长,css样式未贴出,详细代码请查看代码仓库

<template>
  <div class="table-block">
    <div class="table-block-list" ref="tableRef">
      <div
        v-for="item in unref(columnArr)"
        :key="item.fixed"
        class="table-block-list-content"
        :class="[
          item.shadow && `content-${item.fixed}`, 
          { 'first-col': showBorder && item.isFirstCol && item.fixed === 'center'},
          { 'list-last': showBorder && item.isLastCol && item.fixed === 'center'}
        ]"
        :style="{
          width: item.width
        }"
      >
        <tableTitle
          v-if="showTitle"
          v-bind="$attrs"
          :titleList="item.titleList"
          :columns="item.col"
          :rowWidth="item.colWidth"
          :fixedCol="item.fixed"
          :isXScroll="isXScroll"
          :showBorder="showBorder"
          :maxDeep="unref(columnObj).maxDeep"
          :id="item.fixed === 'center' ? rowTitleId : ''"
          @scroll="e => onscrollHandler(e, rowListId)"
        >
          <template v-for="ite in item.headerSlots" #[ite.slotHeader]="{ titleItem }">
            <slot :name="ite.slotHeader" :titleItem="titleItem"> </slot>
          </template>
        </tableTitle>
        <list
          v-if="showList && data.length > 0"
          v-bind="$attrs"
          :columns="item.col"
          :data="data"
          :rowWidth="item.colWidth"
          :fixedCol="item.fixed"
          :isXScroll="isXScroll"
          :showBorder="showBorder"
          :fixedWidthDefault="unref(fixedWidthDefault)"
          :fixedLeftLength="unref(fixedLeftLength)"
          :centerColumnLength="unref(centerColumnLength)"
          :id="item.fixed === 'center' ? rowListId : ''"
          @scroll="e => onscrollHandler(e, rowTitleId)"
        >
        </list>
      </div>
    </div>
  </div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, unref, onMounted, nextTick, useSlots } from 'vue'
import tableTitle from './title.vue'
import list from './list.vue'
const props = defineProps({
  // 展示的列
  columns: {
    type: Array,
    default: () => []
  },
  // 源数据
  data: {
    type: Array,
    default: () => []
  },
  // 是否显示标题头
  showTitle: {
    type: Boolean,
    default: true
  },
  // 是否显示列表数据
  showList: {
    type: Boolean,
    default: true
  },
  // 是否显示边框
  showBorder: {
    type: Boolean,
    default: false
  }
})

const defaultConst = reactive({
  viewWidth: 360
})
// 屏幕宽度
function getViewWidth(dom: HTMLElement): Number {
  if (!dom.value) {
    return
  }
  const tableBlockStyle = window.getComputedStyle(dom.value)
  const width = +tableBlockStyle.width.replace('px', '')
  const paddingLeft = +tableBlockStyle.paddingLeft.replace('px', '')
  const paddingRight = +tableBlockStyle.paddingRight.replace('px', '')
  const widthSum = width - paddingLeft - paddingRight
  return !isNaN(widthSum) ? widthSum : defaultConst.viewWidth
}
const tableRef = ref(null)
onMounted(() => {
  nextTick(() => {
    defaultConst.viewWidth = getViewWidth(tableRef)
  })
})
const fixedWidthDefault = ref(70) // 单元格默认宽度

// 获取列宽和
function getColWidth(arr: Array, defaultWidth: Number): Number {
  return +arr
    .reduce((pre, cur) => {
      pre += cur.width ? +cur.width : defaultWidth
      return pre
    }, 0)
    .toFixed(2)
}

const rowTitleId = ref('rowTitle' + Math.ceil(Math.random() * 10 ** 10))
const rowListId = ref('rowList' + Math.ceil(Math.random() * 10 ** 10))
// title和list同步滚动
const onscrollHandler = function(e: HTMLElement, id: Number) {
  const dom = document.querySelector(`#${id}`)
  if (dom) {
    dom.scrollLeft = e?.target?.scrollLeft
  }
}

const slots = useSlots()
// 获取title 插槽
function getHeaderSlots(arr: Array) {
  console.log('slots===>', arr, slots)
  return arr.filter(item => item.slotHeader && slots[item.slotHeader])
}
 /**
 * 数据处理
 * @params columns: 遍历的数组
 * @params obj: 处理后的结果
 * @params deep: 当前深度
 * @params isFirst: 是否是第一次调用函数
 * @params currentKey: 从父级到当前子级的key
 * @params fixed: 固定列子集添加fixed值
 */
function getColumnList(columns, obj, deep, isFirst, currentKey, fixed) {
  columns.forEach(item => {
    // 重置深度
    if (isFirst) {
      deep = 1
    }
    const ob = {
      ...item
    }
    ob.keysStr = isFirst ? ob.key : currentKey + '-' + ob.key
    ob.deep = deep
    if (fixed) {
      ob.fixed = fixed
    }
    if (!ob.children) {
      ob.hasChildren = false
      obj.list.push(ob)
    } else {
      ob.hasChildren = true
      deep++
      if (deep > obj.maxDeep) {
        obj.maxDeep = deep
      }
      obj.list.push({
        ...ob,
        children: []
      })
      getColumnList(item.children, obj, deep, false, ob.keysStr, ob.fixed || undefined)
    }
  })
}

// 表头数据
function getColumnArr(list, maxDeep) {
  return list.reduce(
    (pre, cur) => {
      pre[cur.deep - 1].push(cur)
      return pre
    },
    Array.from({ length: maxDeep }).map(() => [])
  )
}

const columnObj = computed(() => {
  const obj = {
    list: [], // 扁平化数据列表
    maxDeep: 1 // 最大深度
  }
  getColumnList(props.columns, obj, 1, true, '', undefined)
  obj.list.forEach((item, index) => {
    // 当前单元格占得列数 = 当前项keyStr包含当前key属性,并且hasChildren为false的所有项的长度
    item.colSpan = obj.list.filter(ite => ite.keysStr.includes(item.key) && !ite.hasChildren).length
    if (!item.hasChildren) {
      // 表头属性 hasChildren为true时,rowspan =  最大深度(maxDeep, 即表头占的总行数) - 当前深度 + 1
      item.rowSpan = obj.maxDeep - item.deep + 1 // 根据表格得出规律
    } else {
      // 表头项属性 hasChildren为false时, rowspan = 1,即所占行数为1
      item.rowSpan = 1
    }
  })
  console.log('list===>', obj)
  return obj
})

// 要展示得列数
const columnsList = computed(() => {
  const { list } = unref(columnObj)
  return list.filter(item => !item.hasChildren)
})

// 左侧固定列
const fixedLeftLength = computed(() => {
  const arr = columnsList.value.filter(item => item.fixed === 'left')
  return arr.length
})

// 中间列长度
const centerColumnLength = computed(() => {
  return columnsList.value.filter(item => !['left', 'right'].includes(item.fixed)).length
})

// 计算行宽
const rowWidth = computed(() => {
  return columnsList.value.reduce((pre, cur) => {
    pre += cur.width ? +cur.width : fixedWidthDefault.value
    return pre
  }, 0)
})
// x轴宽度超出屏幕宽度
const isXScroll = computed(() => {
  return rowWidth.value - defaultConst.viewWidth > 0
})

// 列list
const fixedArr = ['left', 'center', 'right']
const columnArr = computed(() => {
  const { list, maxDeep } = unref(columnObj)
  
  let obj = fixedArr.reduce((pre, key) => {
    pre[key] = []
    return pre
  }, {})
  list.forEach((item, index) => {
    // fixed 值为 String
    if (item.fixed === 'left' || item.fixed === 'right') {
      obj[item.fixed].push(item)
    } else {
      obj.center.push(item)
    }
  })
  const wt = fixedArr.reduce((pre, key) => {
    pre[key] = getColWidth(obj[key], fixedWidthDefault.value)
    return pre
  }, {})
  const arr = []
  Object.keys(obj).forEach(key => {
    if (obj[key].length > 0) {
      arr.push({
        titleList: getColumnArr(obj[key], maxDeep),
        col: obj[key].filter(item => !item.hasChildren),
        fixed: key,
        colWidth: wt[key], // 列宽度
        // 此处宽度需动态计算
        width: ['left', 'right'].includes(key) ? wt[key] + 'px' : `calc(100% - ${wt.left + wt.right}px)`,
        headerSlots: getHeaderSlots(obj[key]),
        colSlots: obj[key].filter(item => slots[item.key]),
        shadow: key !== 'center' ? isXScroll.value : false
      })
    }
  })
  // 最后一列添加类名
  const objeFirst = arr[0]
  objeFirst.isFirstCol = true
  const objeEnd = arr[arr.length - 1]
  objeEnd.isLastCol = true
  return arr
})
</script>

title文件 th标签添加 rowspan、colspan 属性

 // th标签样式
    const thDom = col => {
      return col.map((item, index, arr) => {
        return (
          <th
            key={index}
            rowspan={item.rowSpan}
            colspan={item.colSpan}
            class={[
              { 'show-border': props.showBorder },
              { 'border-bottom': props.borderBottom },
              { 'column-last': item.isLastCol }
            ]}
            style={{
              'z-index': 'fixed' in item ? '60' : '',
              'text-align': item.align || (item.hasChildren ? 'center' : 'left'),
              background: props.headerBackground || undefined
            }}
            onClick={ e => changeSort(item)}
          >{tableTitleCell(item)}</th>
        )
      })
    }

测试组件

<template>
  <navbar 
    @backHandler="backHandler"
    navTitle="单元格可以合并的表格"
  ></navbar>
  <div class="page-home"> 
    <!-- showBorder -->
    <table-list
      :columns="titleList"
      :data="dataList"
      showBorder
    >
      <template #slotDate="{item, titleItem}">
        <div class="user-info">
          <span 
            class="ellipsis"
          >{{ titleItem.title }}</span>
        </div>
      </template>
    </table-list>
  </div>
 
</template>
<script setup lang="ts">
import Mock from 'mockjs'
import { useRouter } from 'vue-router'
const router = useRouter()
import tableList from '@/components/tablePro/index.vue'
import { setData } from '@/utils/getTableData.ts'
const titleList = [
    {
      title: '日期',
      width: 100,
      key: 'date',
      ellipsis: true,
      fixed: 'left',
      slotHeader: 'slotDate'
    },
    {
      title: '基本信息',
      key: 'baseInfo',
      children: [
        {
          title: '姓名',
          width: 95,
          key: 'name',
          sort: 2,
          fieldName: 1,
          align: 'right',
          commaSplice: true,
          // formatter
        },
        {
          title: 'other',
          key: 'other',
          children: [
            {
              title: '年龄',
              width: 95,
              key: 'age',
              sort: 2,
              fieldName: 1,
              align: 'right'
            },
            {
              title: '公司',
              width: 95,
              key: 'company',
              sort: 2,
              fieldName: 1,
              align: 'right'
            },
          ]
        }
      ]
    },
    {
      title: '岗位',
      width: 95,
      key: 'work',
      sort: 2,
      fieldName: 1,
      align: 'right',
      commaSplice: true,
    },
    {
      title: '城市',
      width: 130,
      key: 'city',
      align: 'right',
      commaSplice: true,
      sort: 0,
      fieldName: 3,
      ellipsis: true,
      // fixed: 'right',
    }
  ]
  const dataObj = {
    date: '@date',
    name: "@cname",
    age: '@integer(24, 65)',
    company: '@pick(["阿里", "腾讯", "字节"])',
    work: '@pick(["产品", "运营", "ui", "前端", "后端", "测试", "运维"])',
    city: '@city()'
  }
  const dataList = setData(Mock.mock('@natural(50, 100)'), dataObj)
</script>

实现效果

20241018-092730.jpg

组件参数

传参

参数说明类型默认值
showTitle是否展示表头booleantrue
showList是否展示列表booleantrue
columns表格列的配置描述见下表array[]
data列表数据array[]
showBorder是否显示单元格边框booleanfalse
isHeaderFixed表头是否吸顶booleantrue
headerFixedTopValue页面划动,表头吸顶top值Number88 (单位px)
height行高度number、string50(单位px)

columns配置

参数说明类型默认值
title表头展示内容string (支持传html)''
width单元格宽度number70(单位px)
widthAuto宽度自适应,会把剩余宽度按比例分配给设置了该字段的列booleanfalse
key当前列要展示的字段string 每一个 表头选项,key要保持惟一-(无默认值)
fixed列是否固定,可选 'left' 'right'string-(无默认值)
align单元格对齐方式, 可选 left、center、 rightstringleft
ellipsis超过宽度将自动省略booleanfalse
slotHeadertitle 插槽key,可使用此字段添加插槽string-
headerBackground表头背景色string'#fff'
formatter列表单元格格式化函数Function(h, row, column, index)-
util单位number、string

插槽: 表头插槽使用 columns 属性 slotHeader自定义表头内容 内容插槽使用 columns 属性key自定义单元格内容

事件

参数说明类型
handlerClickrow 点击事件Function(row, index)
handlerCellClickcell 点击事件Function(row, column)

总结

本文主要讲解了基于原生table实现表头合并,分析了单元格合并原理以及单元格所占的行数和列数,推导出表头单元格rowspan和colspan的计算方法,另外还实现了表头置顶、表头自定义功能,由于篇幅过长没有叙述,具体实现方法可以去gitee仓库看一下源码,后续代码也会传到git, 表格主体单元格合并后面会单独写一篇文档进行讲解。

如果觉得文章对你有帮助,也欢迎一键三连👏👏👏,你的鼓励是支持我持续创作的动力~