You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

15 KiB

25表格如何设计一个表格组件

你好,我是大圣。

上一讲我们实现了树形组件,树形组件的主要难点就是对无限嵌套数据的处理。今天我们来介绍组件库中最复杂的表格组件,表格组件在项目中负责列表数据的展示,尤其是在管理系统中,比如用户信息、课程订单信息的展示,都需要使用表格组件进行渲染。

关于表单的具体交互形式和复杂程度,你可以访问ElementPlusNaiveUiAntDesignVue这三个主流组件库中的表格组件去体验,并且社区还提供了单独的复杂表格组件,这一讲我就给你详细说说一个复杂表格组件如何去实现。

表格组件

大部分组件库都会内置表格组件这是总后台最常用的组件之一用于展示大量的结构化的数据。html也提供了内置的表格标签由  、 、 、 、

 这些标签来组成一个最简单的表格标签。

我们先研究一下html的table标签。下面的代码中table 标签负责表格的容器thead负责表头信息的容器tbody负责表格的主体tr标签负责表格的每一行th和td分别负责表头和主体的单元格。

其实标准的表格系列标签,跟 div+css 实现是有很大区别的。比如表格在做单元格合并时,要提供原生属性,这时候用 div 就很麻烦了。另外,它们的渲染原理上也有一定的区别,每一列的宽度会保持一致。

<table>
  <thead>
    <tr>
      <th>课程</th>
      <th>价格</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>重学前端</td>
      <td>129</td>
    </tr>
    <tr>
      <td>玩转Vue3全家桶</td>
      <td>129</td>
    </tr>
  </tbody>
</table>

简单的表格数据渲染并不需要组件,我们直接使用标准的 table 系列标签就可以。但有的时候,除了呈现数据,也会带有一些额外的功能要求,比如嵌套列、性能优化等。这时候组件的好处就很明显了,它能帮我们省去这些基础的工作。

表格组件的使用风格从设计上说也分为了两个方向。一个方向是完全由数据驱动这里我们可以参考Naive Ui的使用方式n-data-table标签负责容器直接通过data属性传递数据通过columns传递表格的表头配置。

下面的代码中我们在colums中去配置每行需要显示的属性通过render函数可以返回定制化的结果再使用h函数返回Button渲染出对应的按钮。

<template>
  <n-data-table :columns="columns" :data="data" :pagination="pagination" />
</template>
<script>
import { h, defineComponent } from 'vue'
import { NTag, NButton, useMessage } from 'naive-ui'
const createColumns = ({ sendMail }) => {
  return [
    {
      title: 'Name',
      key: 'name',
      align: 'center'
    },
    {
      title: 'Age',
      key: 'age'
    },
    {
      title: 'Action',
      key: 'actions',
      render (row) {
        return h(
          NButton,
          {
            size: 'small',
            onClick: () => sendMail(row)
          },
          { default: () => 'Send Email' }
        )
      }
    }
  ]
}
const createData = () => [
  {
    key: 0,
    name: 'John Brown',
    age: 32,
    tags: ['nice', 'developer']
  },
  {
    key: 1,
    name: 'Jim Green',
    age: 42,
  },
  {
    key: 2,
    name: 'Joe Black',
    age: 32
  }
]
export default defineComponent({
  setup () {
    const message = useMessage()
    return {
      data: createData(),
      columns: createColumns({
        sendMail (rowData) {
          message.info('send mail to ' + rowData.name)
        }
      }),
      pagination: {
        pageSize: 10
      }
    }
  }
})
</script>


还有一种是Element3现在使用的风格配置数据之后具体数据的展现形式交给子元素来决定把columns当成组件去使用我们仍然通过例子来加深理解。

下面的代码中我们配置完data后使用el-table-colum组件去渲染组件的每一列通过slot的方式去实现定制化的渲染。这两种风格各有优缺点我们后面还会结合Elemnt3的源码进行讲解.

<el-table :data="tableData" border style="width: 100%">
  <el-table-column fixed prop="date" label="日期" width="150">
  </el-table-column>
  <el-table-column prop="name" label="姓名" width="120"> </el-table-column>
  <el-table-column prop="province" label="省份" width="120"> </el-table-column>
  <el-table-column prop="city" label="市区" width="120"> </el-table-column>
  <el-table-column prop="address" label="地址" width="300"> </el-table-column>
  <el-table-column prop="zip" label="邮编" width="120"> </el-table-column>
  <el-table-column fixed="right" label="操作" width="100">
    <template v-slot="scope">
      <el-button @click="handleClick(scope.row)" type="text" size="small"
        >查看</el-button
      >
      <el-button type="text" size="small">编辑</el-button>
    </template>
  </el-table-column>
</el-table>
<script>
  export default {
    methods: {
      handleClick(row) {
        console.log(row)
      }
    },
    data() {
      return {
        tableData: [
          {
            date: '2016-05-02',
            name: '王小虎',
            province: '上海',
            city: '普陀区',
            address: '上海市普陀区金沙江路 1518 弄',
            zip: 200333
          },
          {
            date: '2016-05-04',
            name: '王小虎',
            province: '上海',
            city: '普陀区',
            address: '上海市普陀区金沙江路 1517 弄',
            zip: 200333
          },
          {
            date: '2016-05-01',
            name: '王小虎',
            province: '上海',
            city: '普陀区',
            address: '上海市普陀区金沙江路 1519 弄',
            zip: 200333
          },
          {
            date: '2016-05-03',
            name: '王小虎',
            province: '上海',
            city: '普陀区',
            address: '上海市普陀区金沙江路 1516 弄',
            zip: 200333
          }
        ]
      }
    }
  }
</script>

表格组件的扩展

复杂的表格组件需要对表格的显示和操作进行扩展。

首先是从表格的显示上扩展我们可以支持表头或者某一列的锁定在滚动的时候锁定列不受影响。一个table标签很难实现这个效果这时候我们就需要分为table-head和table-body两个组件进行维护通过colgroup组件限制每一列的宽度实现表格的效果而且表头还需要支持表头嵌套。

下面的示意图中,表头就是被分组显示的。

图片

我们还是先分析一下需求。对于表格的操作来说,首先要和树组件一样,每一样支持复选框进行选中,方便进行批量的操作。另外,表头还需要支持点击事件,点击后对当前这一列实现排序的效果,同时每一列还可能会有详情数据的展开,甚至表格内部还会有树形组件的嵌套、底部的数据显示等等。

把这些需求组合在一起表格就成了组件库中最复杂的组件。我们需要先分解需求把组件内部拆分成table、table-column、table-body、table-header组件我们挨个来看一下。

首先在table组件的内部我们使用table-body和table-header构成组件。table提供了整个表格的标签容器hidden-columns负责隐藏列的显示并且通过table-store进行表格内部的状态管理。每当table中的table-store被修改后table-header、table-body都需要重新渲染。

<template>
  <div class="el-table">
    <div class="hidden-columns" ref="hiddenColumns">
      <slot></slot>
    </div>
    <div class="el-table__header-wrapper"
         ref="headerWrapper">
      <table-header ref="tableHeader"
                    :store="store">
      </table-header>
    </div>
    <div class="el-table__body-wrapper"
         ref="bodyWrapper">
      <table-body :context="context"
                  :store="store">                  
      </table-body>
    </div>
  </div>
</template>

然后在table组件的初始化过程中我们首先使用createStore创建表格的store数据管理并且通过TableLayout创建表格的布局然后把store通过属性的方式传递给table-header和table-body。

let table = getCurrentInstance()
    const store = createStore(table, {
      rowKey: props.rowKey,
      defaultExpandAll: props.defaultExpandAll,
      selectOnIndeterminate: props.selectOnIndeterminate,
      // TreeTable 的相关配置
      indent: props.indent,
      lazy: props.lazy,
      lazyColumnIdentifier: props.treeProps.hasChildren || 'hasChildren',
      childrenColumnName: props.treeProps.children || 'children',
      data: props.data
    })
    table.store = store
    const layout = new TableLayout({
      store: table.store,
      table,
      fit: props.fit,
      showHeader: props.showHeader
    })
    table.layout = layout


再接着table-header组件内部会接收传递的store并且提供监听的事件包括clickmousedown等鼠标操作后计算出当前表头的宽高等数据进行显示。

const instance = getCurrentInstance()
    const parent = instance.parent
    const storeData = parent.store.states
    const filterPanels = ref({})
    const {
      tableLayout,
      onColumnsChange,
      onScrollableChange
    } = useLayoutObserver(parent)
    const hasGutter = computed(() => {
      return !props.fixed && tableLayout.gutterWidth
    })
    onMounted(() => {
      nextTick(() => {
        const { prop, order } = props.defaultSort
        const init = true
        parent.store.commit('sort', { prop, order, init })
      })
    })
    const {
      handleHeaderClick,
      handleHeaderContextMenu,
      handleMouseDown,
      handleMouseMove,
      handleMouseOut,
      handleSortClick,
      handleFilterClick
    } = useEvent(props, emit)
    const {
      getHeaderRowStyle,
      getHeaderRowClass,
      getHeaderCellStyle,
      getHeaderCellClass
    } = useStyle(props)
    const { isGroup, toggleAllSelection, columnRows } = useUtils(props)

    instance.state = {
      onColumnsChange,
      onScrollableChange
    }
    // eslint-disable-next-line
    instance.filterPanels = filterPanels


在table-body中也是类似的实现方式和效果。不过table-body和table-header中的定制需求较多我们需要用render函数来实现定制化的需求。

下面的代码中我们利用h函数返回el-table__body的渲染通过state中读取的columns数据依次进行数据的显示。

render() {

    return h(
      'table',
      {
        class: 'el-table__body',
        cellspacing: '0',
        cellpadding: '0',
        border: '0'
      },
      [
        hColgroup(this.store.states.columns.value),
        h('tbody', {}, [
          data.reduce((acc, row) => {
            return acc.concat(this.wrappedRowRender(row, acc.length))
          }, []),
          h(
            ElTooltip,
            {
              modelValue: this.tooltipVisible,
              content: this.tooltipContent,
              manual: true,
              effect: this.$parent.tooltipEffect,
              placement: 'top'
            },
            {
              default: () => this.tooltipTrigger
            }
          )
        ])
      ]
    )
  }
  

整体表格组件的渲染逻辑和过程比较复杂。为了帮你抽丝剥茧这节课我重点给你说说Element3中table标签的渲染过程至于具体的表格实现代码你可以课后参考Element3的源码。

表格组件除了显示的效果非常复杂、交互非常复杂之外还有一个非常棘手的性能问题。由于表格是二维渲染而且表格组件如果想支持表头或者某一列锁定的定制效果内部需要渲染不止一个table标签。一旦数据量庞大之后表格就成了最容易导致性能瓶颈的组件那这种场景如何去做优化呢

这里我们要快速回顾一下性能优化那一讲的思路性能优化主要的思路就是如何能够减少计算量。比如我们的表格如果有1000行要显示但是我们浏览器最多只能显示100条其他的需要通过滚动条的方式进行滚动显示屏幕之外成千上万个dom元素就成了性能消耗的主要原因。

针对这种情况我们可以考虑类似图片懒加载的方案对屏幕之外的dom元素做懒渲染也就是非常常见的虚拟列表解决方案

在虚拟列表解决方案中我们首先要获取窗口的高度、元素的高度以及当前滚动的距离通过这些数据计算出当前屏幕显示出来的数据。然后创建这些元素标签设置元素的transform属性模拟滚动效果。这样表面看是1000条数据在表格里显示实际只渲染了屏幕中间的这100行数据当我们滚动鼠标的同时去维护这100个数据列表这样就完成了标签过多的性能问题。

如果表格内部每一行的高度不同的话,我们就需要对每一个元素的高度进行估计。具体操作时,先进行渲染,然后等待渲染完毕之后获取高度并且缓存下来,即可实现虚拟列表元素高度的自适应。

总结

今天要我们学习了表格组件如何实现,我给你做个总结吧。

表格组件是组件库中最复杂的组件,核心的难点除了数据的嵌套渲染复杂的交互之外,复杂的dom节点也是表格的特点之一。我们通过对table-header、table-body和table-footer的组件分析掌握了表格组件设计思路的实现细节。

除此之外,表格也是最容易导致页面卡顿的组件,所以我们除了数据驱动渲染之外,还需要考虑通过虚拟滚动的方式进行渲染的优化,这也是列表数据常见的优化策略,属于懒渲染的解决方案。

无论数据有多少行我们只渲染用户可视窗口之内的控制top的属性来模拟滚动效果通过computed计算出需要渲染的数据。最后我还想提醒你注意虚拟滚动也是面试的热门解决方案你一定要手敲一遍才能加深理解。

思考题

最后留个思考题吧,你现在基础的复杂项目或者组件库中,有哪些组件适合用虚拟滚动做性能优化呢?欢迎你在评论区分享你的答案,也欢迎你把这一讲分享给你的同事和朋友们,我们下一讲再见