Skip to content

尝试用 Vue 写一个表格组件

前言

工作中接到一个小程序表格展示信息的需求,Taro UI Vue3 这个框架根本没有表格组件。

幸好有很多成功的框架珠玉在前,比如 Antd,所以我仿照 Antd 的表格组件实现了一个。

给阅读这篇博客的人提个醒,我没看过 Antd 源码,以下实现代码是我自己写的。😂

需求

  1. 表格的结构和数据分离
  2. 默认表格宽度根据容器宽度均分,有 width 属性使用自定义宽度
  3. 无数据提示
  4. 控制表格边框展示
  5. 斑马纹

实现

释义

讲一下我用到的不太常见的属性。

1. display: grid

设置元素为网格布局。

2. grid-template-columns

这个属性的书面定义是基于网格列的维度,去定义网格线的名称和网格轨道的尺寸大小,用口语就是设置网格一个方向的宽度并可以对其命名。

思路

首先数据结构我仿照 Antd 分为两个数组,分为 columns(表头结构)和 dataSource(表身数据)两个对象数组。

js
export default {
  ......
  props: {
    columns: {
      type: Array,
      default: () => [],
    },
    dataSource: {
      type: Array,
      default: () => [],
    },
  }
}

然后将表头和表身分开渲染,表头直接循环 columns 渲染;表身先循环 dataSource 取出对象,再循环 columns 取值渲染。

html
<template>
  ......

  <view class="table-header grid" :style="colStyle">
    <view
      v-for="column in columns"
      :id="column.dataIndex"
      :key="column"
      class="header-item"
    >
      {{ column.title }}
    </view>
  </view>
  <view class="table-content">
    <view
      v-for="(data, index) in dataSource"
      :key="data"
      class="content-col grid"
      :style="colStyle"
    >
      <view
        v-for="dataColumn in columns"
        :key="index + dataColumn.dataIndex"
        class="col-item table-font"
      >
        {{ data[dataColumn.dataIndex] }}
      </view>
    </view>
  </view>
  ......
</template>

计算列宽度,通过动态添加 grid-template-columns 属性实现宽度自适应。

这个 1fr 属性就像是 flex 弹性布局子元素的 flex: 1 属性,自动填充剩下的宽度,如果有其他相同属性的元素,则按设定数值的比例分配宽度。

js
const colStyle = computed(() => {
  const number = props.columns.length;
  return {
    "grid-template-columns": setWidth(),
  };
});

const setWidth = () => {
  return props.columns
    .map((item) => {
      return item.width ? item.width : "1fr";
    })
    .join(" ");
};

最后添加斑马纹、边框、无数据提示,就完成了。

html
<template>
  <!-- showBorder 边框 -->
  <view
    class="table"
    :class="{
      'border-left': showBorder,
    }"
  >
    <view class="table-header grid" :style="colStyle">
      <view
        v-for="column in columns"
        :id="column.dataIndex"
        :key="column"
        class="header-item"
        :class="{
          'border-right': showBorder,
        }"
      >
        {{ column.title }}
      </view>
    </view>
    <view class="table-content">
      <view
        v-for="(data, index) in dataSource"
        :key="data"
        class="content-col grid"
        :style="colStyle"
      >
        <!-- zebra-color 斑马纹 -->
        <view
          v-for="dataColumn in columns"
          :key="index + dataColumn.dataIndex"
          class="col-item table-font"
          :class="{
            'zebra-color': index % 2 !== 0,
            'border-right': showBorder,
          }"
        >
          {{ data[dataColumn.dataIndex] }}
        </view>
      </view>
      <!-- 无数据提示 -->
      <view v-if="dataSource.length === 0 && showEmpty" class="tips">
        <image class="tips-img" src="./list.png" mode="aspectFit" />
        <view class="tips-text"> 暂无数据 </view>
      </view>
    </view>
  </view>
</template>
js
export default {
  ......
  props: {
    ......
    showEmpty: {
      type: Boolean,
      default: true,
    },
    showBorder: {
      type: Boolean,
      default: false,
    },
  }
}

最后我们来看看最终实现效果。

拓展

fixed 属性

产品总会提出一些很“合理”的需求,比如右边列固定。

没办法了,继续薅 Antd 的羊毛吧,在 columns 对象数组里面的对象上添加 fixed 属性。

方案

实现右边列固定我想过两个方案,最后选择 方案 2

1. 两个表格,一个表格固定在右边,用来展示有fixed属性的列

实现简单,不用修改原有组件,但是我发现,会出现左右两个表格高度不一,所以放弃。

2. position:sticky,粘性定位

这是一个很完美的 css 属性,需要注意的是生效先决条件比较多。

建议看看网上的 探究 position-sticky 失效问题 这篇博客后,再开始使用粘性定位。

问题

实现途中还是出现了一些问题。

1. 元素堆叠

我发现粘性定位会堆叠在一起,需要你手动调整元素位置,所以我选择将 fixed 列都放到一起,用一个容器包裹,这样只需要控制父容器位置就可以了。

那父容器的宽度如何设置呢,特别是 fixed 列单位有多种,如 %px ,很自然的,我想到了计算属性 calc(),直接使用计算属性得出总宽度即可。

2. 宽度失准

我在外面设置的列宽度是根据表格宽度来设置的,但是用父容器包裹以后,锚点就从表格变成了父容器了,会出现宽度失准的问题。

但其实父容器的宽度就等于 fixed 列的总宽度,我们只要保证在父容器内部,各列的比例不变就可以实现宽度校准,用 flex 弹性布局的 flex 属性就可以实现这个效果。

具体代码

判断左右,控制组件展示

html
<!-- 表头 -->
<template>
  ......

  <view class="table-header grid" :style="colStyle">
    <template v-if="!!fixedLeftCol.length">
      <view style="left: 0px" class="fixed">
        <view
          v-for="column in fixedLeftCol"
          :id="column.dataIndex"
          :key="column"
          class="header-item"
          :style="{ flex: `calc(${columns.width}) / ${fixedLeftColWidthStr}` }"
          :class="{
            'border-right': showBorder,
          }"
        >
          {{ column.title }}
        </view>
      </view>
    </template>
    ......

    <template v-if="!!fixedRightCol.length">
      <view style="right: 0px" class="fixed">
        <view
          v-for="column in fixedRightCol"
          :id="column.dataIndex"
          :key="column"
          :style="{ flex: `calc(${columns.width}) / ${fixedRightColWidthStr}` }"
          class="header-item"
          :class="{
            'border-right': showBorder,
          }"
        >
          {{ column.title }}
        </view>
      </view>
    </template>
  </view>

  <!-- 表身 -->
  <view
    v-for="(data, index) in dataSource"
    :key="data"
    class="content-col grid"
    :style="colStyle"
  >
    <template v-if="!!fixedLeftCol.length">
      <view style="left: 0px" class="fixed">
        <view
          v-for="dataColumn in fixedLeftCol"
          :key="`${index}${dataColumn.dataIndex}fixed`"
          :style="{ flex: `calc(${columns.width}) / ${fixedLeftColWidthStr}` }"
          class="col-item table-font"
          :class="{
            'zebra-color': index % 2 !== 0,
            'border-right': showBorder,
          }"
        >
          {{ data[dataColumn.dataIndex] }}
        </view>
      </view>
    </template>
    ......

    <template v-if="!!fixedRightCol.length">
      <view style="right: 0px" class="fixed">
        <view
          v-for="dataColumn in fixedRightCol"
          :key="`${index}${dataColumn.dataIndex}fixed`"
          :style="{ flex: `calc(${columns.width}) / ${fixedRightColWidthStr}` }"
          class="col-item table-font"
          :class="{
            'zebra-color': index % 2 !== 0,
            'border-right': showBorder,
          }"
        >
          {{ data[dataColumn.dataIndex] }}
        </view>
      </view>
    </template>
  </view>
  ......
</template>

添加粘性定位,flex弹性布局等css样式属性。

css
.fixed {
  position: sticky;
  height: inherit;

  display: flex;

  & > view {
    flex: 1;
  }
}

通过js动态计算出父容器宽度。

js
const fixedLeftCol = props.columns.filter((i) => i.fixed === "left");
const fixedLeftColWidthArr = fixedLeftCol.map((item) => {
  return item.width ? item.width : "1fr";
});
const fixedLeftColWidthStr = `calc(${fixedLeftColWidthArr.join(" + ")})`;

const fixedRightCol = props.columns.filter((i) => i.fixed === "right");
const fixedRightColWidthArr = fixedRightCol.map((item) => {
  return item.width ? item.width : "1fr";
});
const fixedRightColWidthStr = `calc(${fixedRightColWidthArr.join(" + ")})`;

const setWidth = () => {
  const normalcolWidth = props.columns
    .filter((i) => !i.fixed)
    .map((item) => {
      return item.width ? item.width : "1fr";
    });

  let colWidth = [];
  !!fixedLeftCol.length && colWidth.push(fixedLeftColWidthStr);
  colWidth.push(...normalcolWidth);
  !!fixedRightCol.length && colWidth.push(fixedRightColWidthStr);

  return colWidth.join(" ");
};

代码

html
<template>
  <view
    class="table"
    :class="{
      'border-left': showBorder,
    }"
  >
    <view class="table-header grid" :style="colStyle">
      <template v-if="!!fixedLeftCol.length">
        <view style="left: 0px" class="fixed">
          <view
            v-for="column in fixedLeftCol"
            :id="column.dataIndex"
            :key="column"
            class="header-item"
            :style="{
              flex: `calc(${columns.width}) / ${fixedLeftColWidthStr}`,
            }"
            :class="{
              'border-right': showBorder,
            }"
          >
            {{ column.title }}
          </view>
        </view>
      </template>
      <view
        v-for="column in columns.filter((i) => !i.fixed)"
        :id="column.dataIndex"
        :key="column"
        class="header-item"
        :class="{
          'border-right': showBorder,
        }"
      >
        {{ column.title }}
      </view>
      <template v-if="!!fixedRightCol.length">
        <view style="right: 0px" class="fixed">
          <view
            v-for="column in fixedRightCol"
            :id="column.dataIndex"
            :key="column"
            :style="{
              flex: `calc(${columns.width}) / ${fixedRightColWidthStr}`,
            }"
            class="header-item"
            :class="{
              'border-right': showBorder,
            }"
          >
            {{ column.title }}
          </view>
        </view>
      </template>
    </view>
    <view class="table-content">
      <view
        v-for="(data, index) in dataSource"
        :key="data"
        class="content-col grid"
        :style="colStyle"
      >
        <template v-if="!!fixedLeftCol.length">
          <view style="left: 0px" class="fixed">
            <view
              v-for="dataColumn in fixedLeftCol"
              :key="`${index}${dataColumn.dataIndex}fixed`"
              :style="{
                flex: `calc(${columns.width}) / ${fixedLeftColWidthStr}`,
              }"
              class="col-item table-font"
              :class="{
                'zebra-color': index % 2 !== 0,
                'border-right': showBorder,
              }"
            >
              {{ data[dataColumn.dataIndex] }}
            </view>
          </view>
        </template>
        <view
          v-for="dataColumn in columns.filter((i) => !i.fixed)"
          :key="`${index}${dataColumn.dataIndex}`"
          class="col-item table-font"
          :class="{
            'zebra-color': index % 2 !== 0,
            'border-right': showBorder,
          }"
        >
          {{ data[dataColumn.dataIndex] }}
        </view>
        <template v-if="!!fixedRightCol.length">
          <view style="right: 0px" class="fixed">
            <view
              v-for="dataColumn in fixedRightCol"
              :key="`${index}${dataColumn.dataIndex}fixed`"
              :style="{
                flex: `calc(${columns.width}) / ${fixedRightColWidthStr}`,
              }"
              class="col-item table-font"
              :class="{
                'zebra-color': index % 2 !== 0,
                'border-right': showBorder,
              }"
            >
              {{ data[dataColumn.dataIndex] }}
            </view>
          </view>
        </template>
      </view>
      <view v-if="dataSource.length === 0 && showEmpty" class="tips">
        <image class="tips-img" src="./list.png" mode="aspectFit" />
        <view class="tips-text"> 暂无数据 </view>
      </view>
    </view>
  </view>
</template>

<script>
  import { computed } from "vue";

  export default {
    props: {
      columns: {
        type: Array,
        default: () => [],
      },
      dataSource: {
        type: Array,
        default: () => [],
      },
      showEmpty: {
        type: Boolean,
        default: true,
      },
      showBorder: {
        type: Boolean,
        default: false,
      },
    },
    setup(props) {
      const colStyle = computed(() => {
        return {
          "grid-template-columns": setWidth(),
        };
      });

      const fixedLeftCol = props.columns.filter((i) => i.fixed === "left");
      const fixedLeftColWidthArr = fixedLeftCol.map((item) => {
        return item.width ? item.width : "1fr";
      });
      const fixedLeftColWidthStr = `calc(${fixedLeftColWidthArr.join(" + ")})`;

      const fixedRightCol = props.columns.filter((i) => i.fixed === "right");
      const fixedRightColWidthArr = fixedRightCol.map((item) => {
        return item.width ? item.width : "1fr";
      });
      const fixedRightColWidthStr = `calc(${fixedRightColWidthArr.join(
        " + "
      )})`;

      const setWidth = () => {
        const normalcolWidth = props.columns
          .filter((i) => !i.fixed)
          .map((item) => {
            return item.width ? item.width : "1fr";
          });

        let colWidth = [];
        !!fixedLeftCol.length && colWidth.push(fixedLeftColWidthStr);
        colWidth.push(...normalcolWidth);
        !!fixedRightCol.length && colWidth.push(fixedRightColWidthStr);

        return colWidth.join(" ");
      };

      return {
        colStyle,
        fixedLeftCol,
        fixedLeftColWidthStr,
        fixedRightCol,
        fixedRightColWidthStr,
      };
    },
  };
</script>

<style lang="scss">
  .table {
    overflow-y: scroll;
    .table-header {
      text-align: center;
      background-color: #f0f3ff;
      .header-item {
        background-color: #f0f3ff;

        color: #4a6cf7;
        font-size: 20px;
        height: 60px;
        line-height: 60px;
        font-weight: 400;
      }
    }
    .table-content {
      width: 100%;

      .content-col {
        width: 100%;
        position: relative;

        .col-item {
          text-align: center;
          // text-overflow: ellipsis;
          white-space: nowrap;
          background-color: #ffffff;
          height: 60px;
          line-height: 60px;
          padding: 0px 10px;
          // flex: 1;
        }
        .zebra-color {
          background-color: #f0f3ff;
        }
      }
      .tips {
        display: flex;
        flex-direction: column;
        align-items: center;
        justify-content: center;
        height: 300px;

        .tips-img {
          width: 210px;
          height: 100px;
          margin-bottom: 16px;
        }
        .tips-text {
          font-size: 20px;
          font-weight: 500;
          color: #999999;
        }
      }
    }
  }

  .grid {
    display: grid;
  }
  .table-font {
    font-size: 20px;
    color: #333333;
    font-weight: 400;
  }
  .border-right {
    border-right: 1px solid #e6e6e6;
  }
  .border-left {
    border-left: 1px solid #e6e6e6;
  }
  .fixed {
    position: sticky;
    height: inherit;

    display: flex;

    & > view {
      flex: 1;
    }
  }
</style>

Released under the MIT License.