likes
comments
collection
share

从封装一个表格组件开始,带你深入学习vue3前言:最近开发的一个移动端项目使用的技术栈是vue3+vant4,需求开发中

作者站长头像
站长
· 阅读数 43

前言:最近开发的一个移动端项目使用的技术栈是vue3+vant4,需求开发中需要以表格的形式展示信息,在vant官方文档找了半天没看到table组件,移动端就不该展示表格?再重新引入一个ui框架有点小题大作,于是就自己封装一下吧。

实现效果

从封装一个表格组件开始,带你深入学习vue3前言:最近开发的一个移动端项目使用的技术栈是vue3+vant4,需求开发中

仓库地址

Gitee: gitee.com/sz-liunian/…

需求分析

我们主要实现的功能包括:

  1. 列展示:使用columns配置,具体参数请看下表。

  2. 表头吸顶:表头和内容分别使用table实现,表头吸顶使用sticky实现。

  3. 列固定:固定展示在页面的左侧或者右侧,不随页面的滚动而滚动,我们可以把固定的列和非固定列分别作为一个表格展示,非固定列在水平方向超出可视区滚动,具体实现请查看代码

  4. 列宽度设置:使用 <colgroup> 实现,通过使用 <colgroup> 标签,可以向整个列应用样式,而不需要重复为每个单元格或每一行设置样式。

  5. 单元格边框:移动端1px会比较粗,我们使用css scale缩放50%,这样看起来会比较细。

  6. 表头自定义:可以给表头设置插槽,给要自定义的列设置slotHeader:true,然后自定义插槽内容

  7. 每一列自定义: 使用插槽实现,插槽name值为每一列key值。

  8. 单元格点击事件:监听事件,使用emit触发事件。

    其他实现的功能都比较简单,就不一一叙述了,具体参数请看表格:

传的参数

参数说明类型默认值
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

事件

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

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

index组件

html代码如图:

<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}`]"
        :style="{
          width: item.width
        }"
      >
        <tableTitle 
          v-if="showTitle" 
          v-bind="$attrs" 
          :columns="item.titleList" 
          :rowWidth="item.colWidth"
          :fixedCol="item.fixed"
          :isXScroll="isXScroll"
          :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.titleList"
          :data="data"
          :rowWidth="item.colWidth"
          :fixedCol="item.fixed"
          :isXScroll="isXScroll"
          :id="item.fixed === 'center' ? rowListId : ''"
          @scroll="e => onscrollHandler(e, rowTitleId)"
        >
        </list>
      </div>
    </div>
  </div>
</template>

列数据处理

从html结构中我们可看出,表头和主题内容封装了两个组件,通过遍历columnArr展示左侧固定列、中间列和右侧固定列,我们要关注的是columnArr,下面我们就来看一下怎么处理的

<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
  }
})
// 设置一个屏幕默认宽度
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 rowWidth = computed(() => {
  return props.columns.reduce((pre, cur) => {
    pre += cur.width ? +cur.width : fixedWidthDefault
    return pre
  }, 0)
})
// x轴宽度超出屏幕宽度
const isXScroll = computed(() => {
  return rowWidth.value - defaultConst.viewWidth > 0
})

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) {
  return arr.filter(item => item.slotHeader && slots[item.slotHeader])
}

// 渲染的列包括左侧固定列,中间列,右侧固定列
const fixedArr = ['left', 'center', 'right'] 

// 最终遍历的数据
const columnArr = computed(() => {
  let obj = fixedArr.reduce((pre, key) => {
    pre[key] = []
    return pre
  }, {})
  props.columns.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: obj[key], // 左中右每一列数据
        col: obj[key],
        fixed: key,
        colWidth: wt[key], // 列宽度
        // 中间可视区宽度需动态计算
        width: ['left', 'right'].includes(key) ? wt[key] + 'px' : `calc(100% - ${wt.left + wt.right}px)`,
        headerSlots: getHeaderSlots(obj[key]), 
        shadow: key !== 'center' ? isXScroll : false
      })
    }
  })
  // 最后一列添加类名,添加类名是为了设置边框样式
  const obje = arr[arr.length - 1]
  const setLastCol = function(colArr: Array) {
    colArr.forEach((item, index) => {
      if (colArr.length - 1 === index) {
        item.isLastCol = true
      }
    })
  }
  if (obje) {
    setLastCol(obje.col)
    setLastCol(obje.titleList)
  }
  return arr
})
</script>
<style lang="less" scoped>
.table-block {
  width: 100%;
  max-width: 100%;
  .table-block-list {
    width: 100%;
    max-width: 100%;
    padding: 0 10px;
    display: flex;
    justify-content: flex-start;
    &-content {
      position: relative;
      &.content-left::before,
      &.content-right::before {
        content: '';
        position: absolute;
        width: 8px;
        height: 100%;
        top: 0;
        bottom: 0;
        right: -7px;
        background: linear-gradient(270deg, rgba(255, 255, 255, 0) 0%, #f1f1f1 100%);
        z-index: 80;
      }
      &.content-right::before {
        left: -1px;
      }
    }
  }
}
</style>

title组件:

通过tsx实现表头渲染,tsx相比template运行更快,根据参数添加不同类或样式,通过类名控制表格样式,这里需要注意的是使用插槽slots要先判断该插槽是否存在,不然会报错,具体代码如下

<script lang="tsx">
import { type ExtractPropTypes, computed, defineComponent } from 'vue'
const listProps = {
  // 标题
  columns: {
    type: Array,
    default: []
  },
  // 数据
  data: {
    type: Array,
    default: () => []
  },
  // 列表渲染中key使用的字段,不传rowKey默认使用index
  rowKey: {
    type: String,
    default: ''
  },
  // 是否显示边框
  showBorder: {
    type: Boolean,
    default: false
  },
  // 是否显示底部边框
  borderBottom: {
    type: Boolean,
    default: true
  },
  rowWidth: {
    type: Number,
    default: 375
  },
  // 行高
  height: {
    type: [Number, String],
    default: 50 // 'auto' 默认 0.5rem
  },
  fixedCol: {
    type: String,
    default: 'center'
  },
  // 合并单元格方法
  mergeCellMethods: {
    type: Function,
    default: () => [1, 1]
  },
  // 单元格默认宽度
  fixedWidthDefault: {
    type: [Number, String],
    default: 70
  },
  // 固定在左侧的长度
  fixedLeftLength: {
    type: Number,
    default: 0
  },
  // 未固定的列长度
  centerColumnLength: {
    type: Number,
    default: 0
  }
}
export default defineComponent({
  props: listProps,
  setup(props, { slots, attrs, emit }) {
    // 自定义单元格展示内容
    const liCellSpan = (item: Object, v: Object, rowIndex: Number) => {
      let val = ![null, undefined, ''].includes(v[item.key]) ? v[item.key] : ''
      val = val ? val + (item.util || '') : ''
      return (
        <span
          class="li-cell-span"
          v-html={typeof item.formatter === 'function' ? item.formatter(h, v, item, rowIndex) : val || '-'}
        ></span>
      )
    }
    // 插槽
    const cellContent = (item: Object, v: Object, rowIndex: Number) => {
      const slotName = slots[item.key]
      return (
        <div
          class={['li-cell-content', { ellipsis: item.ellipsis }]}
          style={{
            'max-height': props.height === 'auto' ? 'auto' : props.height + 'px'
          }}
        >
         // 这里要先判断插槽是否存在
          {typeof slotName === 'function'
            ? slotName({
                item: v,
                titleItem: item
              })
            : liCellSpan(item, v, rowIndex)}
        </div>
      )
    }

    // 单元格样式
    const tdCell = (v: Object, rowIndex: Number) => {
      return props.columns.map((item, index, arr) => {
        let val = ![null, undefined, ''].includes(v[item.key]) ? v[item.key] : ''
        return (
          <td
            key={item.key}
            class={[
              'li-cell',
              item.key,
              { 'show-border': props.showBorder },
              { 'border-bottom': props.borderBottom },
              { 'column-last': item.isLastCol }
            ]}
            style={{
              'text-align': item.align || 'left'
            }}
            onClick={e => props.$emit('handlerCellClick', v, item, e)}
          >
            {cellContent(item, v, index)}
          </td>
        )
      })
    }
    // 行样式
    const tableTbody = () => {
      return (
        <tbody class="table-tbody">
          {props.data.map((item, index) => {
            return (
              <tr
                key={props.rowKey in item ? item[props.rowKey] : index}
                style={{
                  height: props.height === 'auto' ? 'auto' : props.height + 'px'
                }}
                onClick={e => props.$emit('handlerClick', item, index)}
              >
                {tdCell(item, index)}
              </tr>
            )
          })}
        </tbody>
      )
    }
    // 列样式
    const colgroups = () => {
      return (
        <colgroup>
          {props.columns.map((item, index) => {
            let colWidth = 100
            try {
              colWidth = (+item.width || props.fixedWidthDefault).toFixed(2)
            } catch (e) {}
            return (
              <col
                key={index}
                attrs={{
                  key: item.key
                }}
                style={{
                  width: props.fixedCol === 'center' && item.widthAuto ? 'auto' : colWidth + 'px'
                }}
              ></col>
            )
          })}
        </colgroup>
      )
    }
    return () => (
      <div class={['table-list', { 'table-list-scroll': props.fixedCol === 'center' }]}>
        <table
          style={{
            width: props.rowWidth + 'px'
          }}
        >
          {[colgroups(), tableTbody()]}
        </table>
      </div>
    )
  }
})
</script>
<style lang="less" scoped>
.table-title {
  max-width: 100%;
  &.fixed-top {
    position: sticky;
    left: 0;
    top: 88px;
    z-index: 70;
  }
  &-center {
    overflow: scroll;
    &::-webkit-scrollbar {
      display: none;
      width: 0;
      background: transparent;
    }
  }
  table {
    min-width: 100%;
    min-height: 30px;
    table-layout: fixed;
    background-color: #fff;
    // position: relative;
    .title-top-border::before {
      content: '';
      width: 100%;
      height: 1px;
      background-color: #f1f1f1;
      position: absolute;
      top: 0;
      left: 0;
      z-index: 80;
      transform: scale(1, 0.5);
    }

    th {
      background-color: #fff;
      position: relative;
      line-height: 1;
      vertical-align: inherit;
      img {
        width: 11px;
        height: 14px;
        margin-left: 4px;
        vertical-align: middle;
      }
      .table-title-cell {
        padding: 4px 8px;
        &-sort {
          display: flex;
          align-items: center;
          &-block {
            display: inline-block;
          }
        }
        &-span {
          display: inline-block;
          vertical-align: middle;
        }
      }
      &.border-bottom::after {
        content: '';
        width: 100%;
        height: 1px;
        background-color: #f1f1f1;
        position: absolute;
        bottom: 0;
        left: 0;
        z-index: 10;
        -webkit-transform: scale(1, 0.5);
        transform: scale(1, 0.5);
      }
      &.show-border::after {
        content: '';
        position: absolute;
        left: 0;
        top: 0;
        width: 200%;
        height: 200%;
        background-color: transparent;
        // border-top: 1px solid #F1F1F1;
        border-left: 1px solid #f1f1f1;
        border-bottom: 1px solid #f1f1f1;
        transform-origin: 0 0;
        transform: scale(0.5);
        box-sizing: border-box;
        pointer-events: none;
      }
      &.show-border.column-last::after {
        border-right: 1px solid #f1f1f1;
      }
    }
  }
}
</style>

list组件

表格主题同样使用了tsx语法,根据参数添加不同类或样式,通过类名控制表格样式,具体代码如下

<script lang="tsx">
import { type ExtractPropTypes, computed, defineComponent } from 'vue'
const listProps = {
  // 标题
  columns: {
    type: Array,
    default: []
  },
  // 数据
  data: {
    type: Array,
    default: () => []
  },
  // 列表渲染中key使用的字段,不传rowKey默认使用index
  rowKey: {
    type: String,
    default: ''
  },
  // 是否显示边框
  showBorder: {
    type: Boolean,
    default: false
  },
  // 是否显示底部边框
  borderBottom: {
    type: Boolean,
    default: true
  },
  rowWidth: {
    type: Number,
    default: 375
  },
  // 行高
  height: {
    type: [Number, String],
    default: 50 // 'auto' 默认 0.5rem
  },
  fixedCol: {
    type: String,
    default: 'center'
  },
  // 合并单元格方法
  mergeCellMethods: {
    type: Function,
    default: () => [1, 1]
  },
  // 当前1rem对应px值
  htmlFontSize: {
    type: [Number, String],
    default: 100
  },
  // 单元格默认宽度
  fixedWidthDefault: {
    type: [Number, String],
    default: 70
  },
  // 固定在左侧的长度
  fixedLeftLength: {
    type: Number,
    default: 0
  },
  // 未固定的列长度
  centerColumnLength: {
    type: Number,
    default: 0
  }
}
export default defineComponent({
  props: listProps,
  setup(props, { slots, attrs, emit }) {
    // 自定义单元格展示内容
    const liCellSpan = (item: Object, v: Object, rowIndex: Number) => {
      let val = ![null, undefined, ''].includes(v[item.key]) ? v[item.key] : ''
      val = val ? val + (item.util || '') : ''
      return (
        <span
          class="li-cell-span"
          v-html={typeof item.formatter === 'function' ? item.formatter(h, v, item, rowIndex) : val || '-'}
        ></span>
      )
    }
    // 插槽
    const cellContent = (item: Object, v: Object, rowIndex: Number) => {
      const slotName = slots[item.key]
      return (
        <div
          class={['li-cell-content', { ellipsis: item.ellipsis }]}
          style={{
            'max-height': props.height === 'auto' ? 'auto' : props.height + 'px'
          }}
        >
          {typeof slotName === 'function'
            ? slotName({
                item: v,
                titleItem: item
              })
            : liCellSpan(item, v, rowIndex)}
        </div>
      )
    }

    // 单元格样式
    const tdCell = (v: Object, rowIndex: Number) => {
      return props.columns.map((item, index, arr) => {
        let val = ![null, undefined, ''].includes(v[item.key]) ? v[item.key] : ''
        return (
          <td
            key={item.key}
            class={[
              'li-cell',
              item.key,
              { 'show-border': props.showBorder },
              { 'border-bottom': props.borderBottom },
              { 'column-last': item.isLastCol }
            ]}
            style={{
              'text-align': item.align || 'left'
            }}
            onClick={e => props.$emit('handlerCellClick', v, item, e)}
          >
            {cellContent(item, v, index)}
          </td>
        )
      })
    }
    // 行样式
    const tableTbody = () => {
      return (
        <tbody class="table-tbody">
          {props.data.map((item, index) => {
            return (
              <tr
                key={props.rowKey in item ? item[props.rowKey] : index}
                style={{
                  height: props.height === 'auto' ? 'auto' : props.height + 'px'
                }}
                onClick={e => props.$emit('handlerClick', item, index)}
              >
                {tdCell(item, index)}
              </tr>
            )
          })}
        </tbody>
      )
    }
    // 列样式
    const colgroups = () => {
      return (
        <colgroup>
          {props.columns.map((item, index) => {
            let colWidth = 100
            try {
              colWidth = (+item.width || props.fixedWidthDefault).toFixed(2)
            } catch (e) {}
            return (
              <col
                key={index}
                attrs={{
                  key: item.key
                }}
                style={{
                  width: props.fixedCol === 'center' && item.widthAuto ? 'auto' : colWidth + 'px'
                }}
              ></col>
            )
          })}
        </colgroup>
      )
    }
    return () => (
      <div class={['table-list', { 'table-list-scroll': props.fixedCol === 'center' }]}>
        <table
          style={{
            width: props.rowWidth + 'px'
          }}
        >
          {[colgroups(), tableTbody()]}
        </table>
      </div>
    )
  }
})
</script>
<style lang="less" scoped>
.table-list {
  max-width: 100%;
  &-scroll {
    overflow: scroll;
    &::-webkit-scrollbar {
      display: none;
      width: 0;
      background: transparent;
    }
  }
  table {
    min-width: 100%;
    background-color: #fff;
    table-layout: fixed;
    tr {
      min-width: 100%;
      min-height: 30px;
      flex-direction: row;
      align-items: center;
      font-size: 14px;
      font-weight: 400;
      color: #222222;
      box-sizing: border-box;
      position: relative;

      .li-cell {
        text-align: left;
        font-size: 14px;
        line-height: 20px;
        font-weight: 400;
        color: #222222;
        background-color: #fff;
        align-items: center;
        position: relative;
        vertical-align: middle;
        &-content {
          padding: 4px 8px;
          max-width: 100%;
          overflow: hidden;
          // white-space: nowrap;
          &.ellipsis {
            max-width: 100%;
            overflow: hidden;
            text-overflow: ellipsis;
            word-break: break-all;
            white-space: nowrap;
          }
        }
        &.border-bottom::after {
          content: '';
          width: 100%;
          height: 1px;
          background-color: #f1f1f1;
          position: absolute;
          bottom: 0;
          left: 0;
          z-index: 10;
          -webkit-transform: scale(1, 0.5);
          transform: scale(1, 0.5);
        }
        &.show-border::after {
          content: '';
          position: absolute;
          left: 0;
          top: 0;
          width: 200%;
          height: 200%;
          background-color: transparent;
          border-left: 1px solid #f1f1f1;
          border-bottom: 1px solid #f1f1f1;
          transform-origin: 0 0;
          transform: scale(0.5);
          box-sizing: border-box;
          pointer-events: none;
        }
        &.show-border.column-last::after {
          border-right: 1px solid #f1f1f1;
        }

        &.box-right {
          text-align: right;
          padding-right: 10px;
        }
      }
    }
  }
}
</style>

使用组件

首先引入组件,然后绑定参数和渲染的数据,这里使用mockjs模拟数据

<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>
<script setup lang="ts">
import Mock from 'mockjs'
import tableList from '@/components/table/index.vue'
// 设置数据
function setData(len, obj) {
  return Array.from({length: len}).map(item => {
    return Mock.mock(obj)
  })
}

const titleList = [
    {
      title: '日期',
      width: 100,
      key: 'date',
      ellipsis: true,
      fixed: 'left',
      slotHeader: 'slotDate'
    },
    {
      title: '姓名',
      width: 95,
      key: 'name',
      sort: 2,
      fieldName: 1,
      align: 'right',
      commaSplice: true,
      // formatter
    },
    {
      title: '年龄',
      width: 95,
      key: 'age',
      sort: 2,
      fieldName: 1,
      align: 'right',
      commaSplice: true,
    },
    {
      title: '公司',
      width: 95,
      key: 'company',
      sort: 2,
      fieldName: 1,
      align: 'right',
      commaSplice: true,
    },
    {
      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
    }
  ]
  const dataObj = {
    date: '@date',
    name: "@cname",
    age: '@integer(24, 65)',
    company: '@pick(["阿里", "腾讯", "字节"])',
    work: '@pick(["产品", "运营", "ui", "前端", "后端", "测试", "运维"])',
    city: '@city()'
  }
  const dataList = setData(Mock.mock('@natural(15, 25)'), dataObj)
</script>
转载自:https://juejin.cn/post/7423318322367004707
评论
请登录