El-Table 源码解析(二)——探究多级表头实现原理


theme: devui-blue
highlight: atom-one-dark

继上次给 Element Plusel-table 组件提了个 pr 后,总感觉源码看了这么多只提一个 pr 太亏了,根本对不起我 debugger 的时间…于是我决定,继续加大亏本的幅度,再提一个!毕竟提 pr 一时爽,一直提一直爽!

由于笔者这次打算提的 pr 主要是针对 <el-table-column /> 这个组件issue,所以着重地看了这个组件的一部分源码,本文会围绕跟这个组件息息相关的功能点进行讲解——多级表头的源码实现(源码版本:2.2+)。先让我们看看多级表头长啥样:
image.png

可以说在整个 ToB 的业务开发中我们需要用到 table 时写得最多的就是 el-table-column 这个组件了,毕竟你写一个 el-table 最少都得写上好些个 table-column (写 v-for 的除外)🤪,由此可见这个组件在整个 table 的实现中是尤其重要的。事不宜迟,赶紧跟大家一起看看 column 组件是如何协助 el-table 组件来实现多级表头的!

一、原生<table> 实现多级表头

讲到多级表头的实现,我们不得不回归一下原生的H5代码,因为框架底层的实现就是原生的标签。长期习惯使用框架的我们,一边在获取框架带我们便利的同时,一边在不知不觉地逐渐远离底层实现,以至于我们可能根本不知原生 table 要怎么写;还有一些不为人知的标签比如:<colgroup><col>

接下来,我们一起看下实现一个原生的多级表头的 table 需要用到哪些必要的标签:

  • <table>:元素表示表格数据——即通过二维数据表表示的信息。
  • <tr>:定义表格中的。同一行可同时出现 <th> 、 <td> 元素。
  • <th>:定义表格内的表头单元格。划重点「表头」
  • <td>:定义了一个包含数据的表格单元格。划重点「表格」

现在我们就按照 el-table 的多级表头demo,通过使用原生标签来将其实现。由于要实现的表头效果是三级表头,所以我们需要有三个 tr (三行)的表头。笔者先照着 截图demo 整体的结构写了个 dom 的雏形,在码上掘金看到的效果图如下:
image.png
可以看出现在表格中共有三行表头,有一行表格内容,目前看起来跟截图中的效果还是差了点(这不根本就不是一个东西吗?)

为了让表头的效果接近截图的样子,紧接着我们需要实现表头单元格之间的行、列扩展。这里就需要引入 <th> 标签中的两个重要的属性:

  • colspan:指示单元格扩展了多少列,默认值是 1。
  • rowspan:指示单元格扩展了多少行,默认值是 1。

这么看文字真的很抽象,接下来我们直接把 Date 这个单元格设置 rowspan="3" 看看效果,只要看一眼就明白这两个属性的作用了:
image.png
不难发现,此时已经有点多级表头那样了~剩下的只需要根据截图的 demo 中的展示去配置 colspanrowspan 就能实现一个原生的多级表头的 table 了,笔者就不一一赘述了。这里把最终实现的代码的码上掘金贴出来,感兴趣的朋友可以在上面操作操作自己感受下~

代码片段

二、<el-table> 多级表头基础

相信已经了解原生的多级表头实现后,大家心里都大概有了想法。其实 el-table 最终呈现出来多级表头的 dom 基本就是那样,至于 el-table 是如何从一个 vue组件 渲染成最终的 dom 的,笔者在之前的一篇文章「el-table渲染章节」就已经详细分析过了,想详细了解的可以去看看,这里不会进行过多的展开,本文将聚焦在 table-column 组件处理数据源的源码实现上。

如果说整个 <el-table> 组件, <table-header> 是负责表头部分渲染的;<table-body> 是负责表格内容渲染的;那 <table-column> 组件就是他们两个组件所需要渲染内容的源数据来源
image.png

怎么理解源数据的来源呢?这里带大家大概回顾一下 <table-header> 组件的 render 函数,大家集中关注 columnRows 即可:

// table-header 组件
return h(
  'thead',
  // 这个 columnRows 作为数据源负责了整个 header 的 render函数
  // columnRows.map 会返回 <tr> 的 vnode
  columnRows.map((subColumns, rowIndex) =>
    h(
      'tr',
      // subColumns.map 会返回 <th> 的 vnode
      subColumns.map((column, cellIndex) => {
        return h(
          'th',
          // 这一层开始木有循环了,看来就是单个表头单元格的结构了
          [
            h(
              'div',
              {
                // 这个class="cell"熟悉了吧,不就是在dom上看到的放表头内容的地方吗
                class: ['cell'], 
              },
              /* --- 下面就是具体表头单元格内容的vnode了,可以不用关注 --- */
              [...]
            ),
          ]
        )
      })
    )
  )
)

回顾表头的 render 函数后,其实整个 thead 的最终生成的关键都源于对数据 columnRows 的循环,所以说这个源数据很重要。具体的源数据由来,笔者之前的文章已经有一个详细的追溯过程了,本文不会展开。这里再次啰嗦一句,如果需要了解基础的 el-table 实现原理可以回看笔者的文章:从 Issue 看 El-Table 源码,给 Element+ 提 Pr 的背后竟如此坎坷!,本文将在原本的源码解析基础上继续深入解析,两篇一起结合着看可能效果会更好。

1. 了解 columnConfig

本节基本围绕着多级表头的数据源讲解,所以我们先来看一看所谓的“数据”长啥样。

每一个 column组件setup 阶段都会初始化当前 column 的配置数据—— columnConfig。我们可以简单看看它的默认配置(配置有很多):
image.png
默认配置只是其中,之后还有一些外部传入的属性如 labelprop 这种,最终他们会跟这个默认的配置进行一个 merge 以组成最终的 columnConfig

每一个 columnConfig 汇集在一起就组成了整个 table 的数据源,不管是 theadtbody 的渲染,都是依赖这个数据源来完成最终内容生成的,就比如上述提到的 columnRows,他就是由每个 columnConfig 组成的 [ [ columnConfig1, columnConfig2 ... ] ]

这里我们看看多级表头 demo 中的其中一个 columnConfig
image.png

相信到这里,应该对 columnConfig数据源 这种概念有一定的了解了,那接下来我们一起揭秘多级表头时候如何生成的吧!

2. 多级表头的设计(树形结构)

这一小节将着重解析: <el-table> 多级表头的设计原理以及源数据处理。这么说可能比较抽象,我们可以换个方式理解:开发者如何设计用法以及将其实现。首先看一下多级表头是如何使用的,去官网文档中扒下来的一段 demo 代码:

  <el-table :data="tableData" style="width: 100%">
    <el-table-column prop="date" label="Date" width="150" />
    <el-table-column label="Delivery Info">
      <el-table-column prop="name" label="Name" width="120" />
      <el-table-column label="Address Info">
        <el-table-column prop="state" label="State" width="120" />
        <el-table-column prop="city" label="City" width="120" />
        <el-table-column prop="address" label="Address" />
        <el-table-column prop="zip" label="Zip" width="120" />
      </el-table-column>
    </el-table-column>
  </el-table>

根据用法上看,不难发现开发者将其设计成 <el-table-column> 互相嵌套的写法,以此来实现一个多级表头的效果。这种组件嵌套的方式,在 vue 的组件里面称为插槽,也就是往 column 这个组件的插槽里插入 column 自己。

但是印象中 column 的插槽好像有很多种用法以实现不同的效果,并非只有实现多级表头的,于是我们在这里先预热一下 column 组件传入不同插槽时候的不同表现形式:

  1. 多级表头

    • 代码如上

    • 效果:

      image.png

  2. 自定义列模板(单元格可以使用组件或其他HTML片段)

    • 代码:

      <el-table-column label="Date" width="180">
        <template #default="scope">
          <div style="display: flex; align-items: center">
            <el-icon><timer /></el-icon>
            <span style="margin-left: 10px">{{ scope.row.date }}</span>
          </div>
        </template>
      </el-table-column>
      
    • 效果:

      image.png

  3. 自定义表头(表头可以使用组件或其他HTML片段)

    • 代码:
          <el-table-column align="right">
            <template #header>
              <el-input v-model="search" size="small" placeholder="Type to search" />
            </template>
          </el-table-column>
      
    • 效果:
      image.png

可以发现,el-table-column 组件的插槽内容设计得非常丰富,传入不同的插槽能得到不同的结果。笔者啰嗦了这么多不为别的!就为了下文突出 table 组件实现多级表头时怎么应对如此丰富的插槽内容?这里先埋个伏笔,具体如何应对的我们等下来看。这里我们回到多级表头的使用层面的代码上来。

根据多级表头用法上的嵌套写法,我们很容易就联想到了树形的数据结构。有了树形的数据结构,上述提到的 <table-header> 组件的 render函数 很自然而然就能根据这个树形数据来生成一个多级表头所需要的 dom 结构(也就是码上掘金中的dom结构)。这么说可能很抽象,笔者先把树形结构的伪代码给整出来:

[
  // 第一层
  { label: 'Date' } , 
  { label: 'Delivery Info', 
    children: [
      // 第二层
      { label: 'Name' }, 
      { label: 'Address Info', 
        children: [
          // 第三层
          { label: 'State' },
          { label: 'City' },
          { label: 'Address' },
          { label: 'Zip' }
        ] 
      }
    ]
  }
]

为了让大家更直白的看出来,笔者把树形的数据结构用图的方式表示出来(已经跟码上掘金的样子很像了,就差设置行、列的扩展了):
多级表头树形结构.png

到这里,我们再次回顾 <table-header> 组件的 render 函数的核心代码:

columnRows.map((subColumns, rowIndex) => {
  subColumns.map((column, cellIndex) => {
   ...
  })
}   

有的朋友看到这里就会有疑问了,仅仅两层循环怎么就把树形的数据结构渲染成多级表头了呢?总感觉有些地方对不上。有这样的想法是对的,也证明你理解了开发者的设计,其实在表头执行 render 之前是有对数据结构进行处理的,你总该听过:树形数据拍平成数组吧?没错,就是那让人烦恼的面试手写题!具体的拍平实现这里笔者不会进行展开,感兴趣的自己手写练练吧,说不定面试就考到了

这里我们直接看树形转换成数组后的结构:

[
  [ { label: 'Date', id: 1 }, { label: 'Delivery Info', id: 2 } ],
  [ { label: 'Name', id: 3, pid: 2 }, { label: 'Address Info', id: 4, pid: 2 } ],
  [ { label: 'State', id: 5, pid: 4 }, { label: 'City', id: 6, pid: 4 }, { label: 'Address', id: 7, pid: 4 }, { label: 'Zip', id: 8, pid: 4 } ]
]

可以发现通过 idpidid 、pid这里只是方便大家理解,其实树形转数组原理基本如此)来表示树形中的父子关系后,很轻松的就能把一棵树拍平成一个数组了。有了这个数组,两层循环是不是就能把整个表头的 dom 结构给渲染出来啦?!不信的可以回去上面的码上掘金来比对比对~

三、 <el-table> 多级表头数据源插入

在上个小节多级表头的设计中,笔者基本讲解了整个多级表头的实现原理,这里简单总结一下:开发者设计出嵌套的 table-column 写法以获取表头所需要的树形数据,再根据树形的结构渲染出多级表头的真实 dom 结构。以此来看,获取到期望的数据将会是多级表头的实现核心,这个小节将会聚焦在数据获取的源码实现上。

为什么说是获取到期望的数据?回顾上个小节笔者罗列了一堆 table-column 的插槽用法你或许就会明白,开发者必须揭开这些迷雾才能知道是不是 column 组件的互相嵌套,是不是需要生成多级表头。具体如何实现?我们接着往下看。

笔者在 从 Issue 看 El-Table 源码,给 Element+ 提 Pr 的背后竟如此坎坷! 这篇文章中的 el-table渲染小节 中有提到,table 内部的 stroe 数据中,关于列数据的插入是在 table-column 组件的 onMounted 阶段,也就是如下这段代码:
image.png

但其实细心的朋友会发现,执行插入函数前有个 && 运算符,也就是只有在条件 columnIndex > -1 时才会执行 insertColumnmutations,这个我们放后面说。此时如果忽视这个条件的话,以 demo 的代码为例,总共有8个 column 组件,在他们各自的 onMounted 钩子执行后,就会执行8次的数据插入,最终形成前文中提到的 树形数据结构

那么讲到这里算是到本文的重点部分了,既然在 column 组件的 mounted 阶段会执行数据插入,那是怎么插入成一个树形结构的呢?一来就直接讲树形的可能比较难理解,我们先不急着回答这个问题,可以先从简单的情况入手便于理解,往下看!

1. 一级表头数据源插入

这里我们只需要明确一级表头的表头数据如何插入即可,主要是为了便于理解后文会讲的树形数据插入。

试想一下,如果是一级表头的数据源就很简单了,只需要按顺序插入即可。换句话说,按照 vue组件化的流程,压根不需要对代码进行任何处理,只要执行到 column 组件的 mounted 那就无脑插入呗,肯定不会有问题的。

以基础表格为例子:

<el-table :data="tableData" style="width: 100%">
  <el-table-column prop="date" label="Date" width="180" />
  <el-table-column prop="name" label="Name" width="180" />
  <el-table-column prop="address" label="Address" />
</el-table>

其渲染到界面是这样的:
image.png
其实对于一级表头来说,直接删掉 onMounted 的里面的其他代码,只保留 插入数据 那段都不会有问题,笔者这里就不演示了(亲测删了其他代码后并不影响一级的 table 运行)。

整个数据源到渲染的流程大概可以表示成下图:
table数据源.png

由上述介绍来看,对于一级表头的 table 来说,数据源插入非常简单,以至于我们都不用做任何处理,那换个角度来看,如果解决了树形数据源的插入问题,那多级表头的实现也就不在话下了?

2. 探究 column组件render函数

了解完一级表头的插入后,我们可以进入树形数据的插入理解了。

首先,讲到多级表头的数据源插入以及如何保证其树形的数据结构,一段颇为隐晦的代码进入了笔者的视线,它就是 table-column 组件的 render 函数。源代码如下(位于:components/table/src/table-column/index.ts):

render() {
  try {
    const renderDefault = this.$slots.default?.({
      row: {},
      column: {},
      $index: -1,
    })
    const children = []
    if (Array.isArray(renderDefault)) {
      for (const childNode of renderDefault) {
        if (
          childNode.type?.name === 'ElTableColumn' ||
          childNode.shapeFlag & 2
        ) {
          children.push(childNode)
        } else if (
          childNode.type === Fragment &&
          Array.isArray(childNode.children)
        ) {
          childNode.children.forEach((vnode) => {
            // No rendering when vnode is dynamic slot or text
            if (vnode?.patchFlag !== 1024 && !isString(vnode?.children)) {
              children.push(vnode)
            }
          })
        }
      }
    }
    const vnode = h('div', children)
    return vnode
  } catch {
    return h('div', [])
  }
}

代码很简单,获取到默认插槽的内容并进行了一些条件过滤出 children,并将其传入了当前组件 render函数children 处。对于这段代码我们核心关注两个问题即可:

  1. 为什么要在此执行 $slot.default()
  2. 代码中的 ifelse if 是为了处理什么?

如果还不是很熟悉 render函数 的朋友可以先看看 渲染函数 这里再回来看本文。为了回答上述的问题,得先回顾笔者的上一篇文章,其中有讲到 table-column 组件并不直接负责 table 组件的有关可视区域的渲染,其被包裹在 .hidden-columns 这个 div 里,而此 div 在页面上是隐藏的。所以,在这里获取插槽的内容并不是直接为了 table 的渲染的

还记不记得上述笔者提到的 table-column多种插槽用法呢?在此,我们结合上述两个问题一起看,首先获取到默认插槽的内容,并且经过一些条件的过滤,把符合条件的插槽内容 push 到变量 children 中,而这个 过滤后的 children 就是当前 column 组件的 children

比如我们可以看看多级表头 demo 出来的 dom 结构:
image.png
由上图可以看出,每一个 column组件 渲染到浏览器上都对应一个空 div,并且这个 dom 结构正是一个树形结构(这跟我们前面分析的数据结构是不是已经非常相似了?)。至此,我们可以得出一个结论来回答第一个问题:在此执行 $slot.default() 获取插槽内容的目的是将用户编写的嵌套 el-table-column 代码结构保留到 dom

至于第二个问题,稍微留意下代码内容比如:childNode.type?.name === 'ElTableColumn' 我们就不难猜测到,其是为了过滤掉不属于 table-column 组件的内容,以防止最终的 dom树 结构被破坏。为了验证这个想法,笔者使用一个 自定义列模版的 demo 来给大家演示一下:

  1. 首先看其渲染出来的 table-columndom结构
    image.png
    由此可见,每个 div 都是空的,并没有任何子节点
  2. 笔者改造下 column组件 的render函数如下:
    image.png
    改动很简单,笔者把那堆判断逻辑去掉了,直接把插槽的内容放到 column组件children 里。
  3. 没有任何过滤的 table-columndom结构
    image.png
    很明显,这里的每个 div 都有了子节点。为了更直观的看出来,笔者把隐藏元素的代码都去掉再截图出来:
    image.png
    显然,如果没有了那段过滤的代码,column组件 的任何默认插槽的内容都会被插入到最终的 dom结构

如果说这样的单个测试不够说服力,在如上第2点的改动下(没有任何过滤 children )跑当前的测试用例会触发一个错误提示
image.png
回到 __tests__ 的代码中查看该测试条件可知:
image.png
开发者设定的条件是 .hidden-columns 不允许插入文本节点。从这一点更印证了那段 if else 的代码就是为了过滤掉除 column 组件的其他内容的。

在此我们总结一下 columnrender函数 的作用:获取且过滤子组件vnode,最终得到一个只属于 column组件 组成的 dom树

3. 多级表头数据源插入

vue组件化 流程可知,执行 render 后能得到组件的 vNode,然后会进入 vNode 转换成真实 dom 的阶段,也就是 patch 流程,也就是在这个阶段中会到组件生命周期的 mount 阶段。很明显,这里我们就进入到 mounted 的环节,还记得前面提到的插入流程之前的 && 操作符吗?

笔者再次贴上 onMounted 源码,代码也比较简单,直接看注释吧大家:

onMounted(() => {
  // parent 仅在嵌套 column 的时候是 column组件,普通情况是 table
  const parent = columnOrTableParent.value 
  // isSubColumn.value: 仅嵌套内的组件为true,如 demo 中的 Name 、Address Info 等
  const children = isSubColumn.value 
    ? parent.vnode.el.children // 父组件的 children dom list
    : parent.refs.hiddenColumns?.children // .hidden-column div 的 children dom list
  // 计算当前组件的 dom 在 children 中的 index(用的是 indexOf)
  const getColumnIndex = () =>
    getColumnElIndex(children || [], instance.vnode.el)
  columnConfig.value.getColumnIndex = getColumnIndex
  const columnIndex = getColumnIndex()
  // > -1 其实就是当前组件的 dom 处于 children 中
  columnIndex > -1 &&
    owner.value.store.commit(
      'insertColumn',
      columnConfig.value,
      isSubColumn.value ? parent.columnConfig.value : null
    )
})

笔者总结一下这段代码的一个关键点:仅在 table-column组件dom 元素(也就是那个空 div)处于当前层级,才往 store 中插入列数据,也就是 columnIndex > -1 && ... 这个条件。

方便理解,笔者整了个案例,比如我们把多级表头的 demo 代码稍作修改,笔者用一个类名为 sbdiv 包裹了 Nametable-column 组件:

<el-table :data="tableData" style="width: 100%">
  <el-table-column prop="date" label="Date" width="150" />
  <el-table-column label="Delivery Info">
    <!-- 在 table-column 外包了一层 div 让其不符合 render 的 children 条件 -->
    <div class="sb">
      <el-table-column prop="name" label="Name" width="120" />
    </div>
    <el-table-column label="Address Info">
      ...
    </el-table-column>
  </el-table-column>
</el-table>

此时的界面就缺失了 Name 的那列,如图所示:
image.png
原因就是: Nametable-columnrender 阶段不符合那段 if elsechildren 条件被过滤了,并没有插入到 dom 中,于是在其 mounted 阶段的时候找不到当前组件的 elchildren 中(columnIndex-1),所以列数据也没有被插入到 tablestore

哦豁,搞这么多原来是为了限制用户的行为,规范大家的组件使用行为。所以大家啊,一切行动听指挥~🤓

最后!我们再看看插入数据的实现吧。上面代码如果符合 columnIndex 的条件,就会执行插入的代码(注意这里如果是 嵌套column 会传入 parentcolumnConfig):

owner.value.store.commit(
  'insertColumn',
  columnConfig.value,
  // 嵌套 column 时会传入 父columnConfig 以此来形成一个树形结构的 columnConfig 
  isSubColumn.value ? parent.columnConfig.value : null 
)

接下来我们看看 insertColumn 的代码实现(components/table/src/store/index.ts):

insertColumn(
  states: StoreStates,
  column: TableColumnCtx<T>,
  parent: TableColumnCtx<T>
) {
  const array = unref(states._columns)
  let newColumns = []
  if (!parent) {
    // 一级的情况下直接插入
    array.push(column)
    newColumns = array
  } else {
    // 多级情况插入到 parent 的 children 中
    if (parent && !parent.children) {
      parent.children = []
    }
    parent.children.push(column)
    newColumns = replaceColumn(array, parent)
  }
  // 根据 dom 的顺序排序插入 store 的顺序
  sortColumn(newColumns)
  // 将排序好的数据赋值给 store 的 states
  states._columns.value = newColumns
  ...
}

其实也很简单,核心就是 插入排序。一级的数据直接插入,多级的数据就插入到传入的 parent.children 中,其中的排序是按照当前等级的 dom 顺序来排,通俗点说就是 table-column 渲染出来的 dom 结构是怎么样,columnConfig 的结构就是怎么样。

这里笔者将插入时候的数据打印出来,可以发现以 demo 为例的数据总共插入了8次,最终的数据是这样的:
image.png
可以发现,上述的多级表头数据源其实跟 column组件dom树 结构是一样的,以此也能看出 column组件 对于 table 来说是多么的重要,而 columnrender函数 的处理也是如此的重要。

回顾前文提到的 <table-header>组件 render 的时候依赖的数据源 columnRows,其实它就是上述 columnConfig 树形结构拍平的二维数组。而对这个二维数组进行双重循环就能渲染出最终的多级表头的 tablethead,已经忘了的朋友可以往上翻再回顾回顾 theadrender 函数。

最后总结一下整个 el-table 多级表头的源码实现:

  1. 设计出嵌套 column 的用法
  2. 组件内部得到多级表头的树形数据
  3. 树转数组后通过循环完成多级表头的渲染

其实在整个 table 的实现中,数据源都是一个比较核心的实现,可以说 table 的所有渲染都依赖这个数据源,所以 column组件store 对于 el-table 来说真的很重要。

写在最后

el-table 源码解析的初衷一方面是为了自己学习、提 pr 用,另一方面也是希望能为社区留点干货,以便有时间、有精力想投入开源社区的你可以在源码阅读、理解中少走弯路,算是给走过的路都留下个小小的指示牌吧。毕竟有些源码的作用确实比较隐晦,开发者也不会处处都留注释告诉你每一行代码的作用,也许这样会让很多初次阅读源码的朋友都很疑惑,其中也劝退不少开发者……不多说了,我要开始提 pr 了。

© 版权声明
THE END
喜欢就支持一下吧
点赞5 分享
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

取消
昵称表情代码图片

    暂无评论内容