[Element Plus 源码解析] Descriptions 描述列表

8,776 阅读2分钟

一、组件介绍

官网链接:Descriptions 组件 | Element (gitee.io)

el-descriptions组件是一个展示类组件,其使用表格展示多个字段的信息;需要和el-descriptions-item子组件搭配使用。

1.1 descriptions 属性

  • border: boolean类型,是否展示边框;
  • direction:string类型,label与content的排列方向,即label是在左方或上方,可选值:horizontal/vertical,默认值:horizontal
  • size: string类型,列表的尺寸,可选值medium/small/mini,默认值:medium
  • column: number类型,一行中展示多少个单位的descriptions-item,默认值:3;
  • title:string类型,标题,展示在左上方;
  • extra: string类型,操作区文本,展示在右上方;

1.2 descriptions-item 属性

  • label: string类型,标签文本;
  • span: number类型,此列所占的宽度单位数量,与descriptions的column相关;
  • width: number/string类型,宽度,不同行相同列的按最大值设定;
  • min-width: number/string类型,列最小宽度,min-width 会把剩余宽度按比例分配给设置了 min-width 的列;
  • align/label-align: string类型,列内容/标签的对齐方式,可选值:left / center / right,默认值:left
  • class-name/label-class-name:: string类型,列内容/标签的自定义class名称;

二、源码分析

2.1 descriptions源码

2.1.1 template部分

<template>
  <div class="el-descriptions">
    <!-- 头部,左侧展示标题,右侧展示操作区 -->
    <div v-if="title || extra || $slots.title || $slots.extra" class="el-descriptions__header">
      <!-- 标题  -->
      <div class="el-descriptions__title">
        <slot name="title">{{ title }}</slot>
      </div>
      <!-- 操作区 -->
      <div class="el-descriptions__extra">
        <slot name="extra">{{ extra }}</slot>
      </div>
    </div>

    <div class="el-descriptions__body">
      <!-- 使用table进行展示 -->
      <table
        :class="['el-descriptions__table', { 'is-bordered': border }, descriptionsSize ? `el-descriptions--${descriptionsSize}` : '']"
      >
        <!-- 没有thead -->
        <tbody>
          <!-- 循环rows -->
          <template v-for="(row, index) in getRows()" :key="index">
            <el-descriptions-row :row="row" />
          </template>
        </tbody>
      </table>
    </div>
  </div>
</template>

2.1.2 script部分

setup(props, { slots }) {
    // 向子组件提供数据
    provide(elDescriptionsKey, props)

    const $ELEMENT = useGlobalConfig()
    // 组件的size
    const descriptionsSize = computed(() => {
      return props.size || $ELEMENT.size
    })

    // 数据扁平化方法:使用递归,使嵌套的数据变成一维数组
    const flattedChildren = children => {
      const temp = Array.isArray(children) ? children : [children]
      const res = []
      temp.forEach(child => {
        if (Array.isArray(child.children)) {
          res.push(...flattedChildren(child.children))
        } else {
          res.push(child)
        }
      })
      return res
    }
    // 主要是根据当前行的剩余宽度单位设置这一列的宽度单位
    const filledNode = (node, span, count, isLast = false) => {
      if (!node.props) {
        // 避免node.props是undefined,方便后续设置span
        node.props = {}
      }
      if (span > count) {
        // 如果当前列的span设置超过了剩余的宽度单位,则设置成剩余的宽度单位
        node.props.span = count
      }
      if (isLast) {
        // 如果是最后一列的元素,设置成其本身的宽度单位
        node.props.span = span
      }
      return node
    }

    const getRows = () => {
      // 将默认插槽中的ElDescriptionsItem元素进行扁平化处理,得到一维数组
      // 一维数组中的每个元素就是一列
      const children = flattedChildren(slots.default?.()).filter(node => node?.type?.name === 'ElDescriptionsItem')
      // 行数据
      const rows = []
      // 临时数组,用于存储每一行的列
      let temp = []
      // count是指当前行剩余可分配的宽度单位
      let count = props.column
      // 累计每一列所占的宽度单位之和
      let totalSpan = 0 

      // 循环处理每一列
      children.forEach((node, index) => {
        // 当前列所占宽度单位,不传入的话,默认是1
        let span = node.props?.span || 1

        // 不是最后一个列元素时
        if (index < children.length - 1) {
          // 累积每一列所占的宽度单位
          totalSpan += (span > count ? count : span)
        }

        // 最后一个列元素
        if (index === children.length - 1) {
          // 计算最后一个元素可占的剩余宽度单位
          const lastSpan = props.column - totalSpan % props.column
          // 将最后一个列元素push到临时row中
          temp.push(filledNode(node, lastSpan, count, true))
          // 最后一个列元素也放到临时row了,将临时row push到rows中
          rows.push(temp)
          return
        }

        // 非最后一个列元素
        // 如果列宽度小于当前行剩余宽度,就放到当前临时行
        if (span < count) {
          // 当前行剩余宽度单位减去当前列元素
          count -= span
          // 当前列元素放入到当前临时行
          temp.push(node)
        } else {
          // 如果列元素宽度大于等于当前行剩余宽度
          // 将列元素放入到当前行,使用filledNode将宽度设置成当前行剩余的宽度
          temp.push(filledNode(node, span, count))
          // 当前列元素放入到当前临时行
          rows.push(temp)
          // 重置状态,开始新的一行
          // 恢复count
          count = props.column
          // 清空当前临时行
          temp = []
        }
      })

      return rows
    }

    return {
      descriptionsSize,
      getRows,
    }
  }

2.1.3 总结

  1. header部分使用插槽,提供title/extra2个插槽;
  2. 使用table展示数据,没有使用thead,使用tbody;
  3. 根据columns属性和默认插槽中的descriotions-item元素的span属性,逐行生成每一行;

2.2 descriptions-row 源码

2.2.1 script部分

<template>
  <!-- 垂直方向布局 -->
  <template v-if="descriptions.direction === 'vertical'">
    <!-- 每一个row,渲染2个tr,分别是label和content -->
    <tr>
      <!-- lable行 -->
      <template v-for="(cell, index) in row" :key="`tr1-${index}`">
        <el-descriptions-cell :cell="cell" tag="th" type="label" />
      </template>
    </tr>
    <tr>
      <!-- 内容行 -->
      <template v-for="(cell, index) in row" :key="`tr2-${index}`">
        <el-descriptions-cell :cell="cell" tag="td" type="content" />
      </template>
    </tr>
  </template>
  <!-- 水平方向布局 -->
  <!-- 每一个row,渲染1个tr -->
  <tr v-else>
    <template v-for="(cell, index) in row" :key="`tr3-${index}`">
    <!-- 有边框情况 -->
      <template v-if="descriptions.border">
        <el-descriptions-cell :cell="cell" tag="td" type="label" />
        <el-descriptions-cell :cell="cell" tag="td" type="content" />
      </template>
      <!-- 无边框情况 -->
      <el-descriptions-cell
        v-else
        :cell="cell"
        tag="td"
        type="both"
      />
    </template>
  </tr>
</template>

2.2.2 script部分

setup() {
    // 注入 descriptions组件提供的数据
    const descriptions = inject(elDescriptionsKey, {} as IDescriptionsInject)

    return {
      descriptions,
    }
  },

2.3 description-cell 源码

export default defineComponent({
  name: "ElDescriptionsCell",
  props: {
    cell: {
      type: Object,
    },
    tag: {
      type: String,
    },
    type: {
      type: String,
    },
  },
  setup() {
    // 注入descriptions组件提供的数据
    const descriptions = inject(elDescriptionsKey, {} as IDescriptionsInject);

    return {
      descriptions,
    };
  },
  render() {
    // props属性格式化
    const item = getNormalizedProps(this.cell as VNode) as IDescriptionsItemInject;

    // descriptions-item的label插槽优先于label属性
    const label = this.cell?.children?.label?.() || item.label;
    // content内容是descriptions-item的默认插槽
    const content = this.cell?.children?.default?.();
    const span = item.span;
    const align = item.align ? `is-${item.align}` : "";
    const labelAlign = item.labelAlign ? `is-${item.labelAlign}` : "" || align;
    const className = item.className;
    const labelClassName = item.labelClassName;
    const style = {
      width: addUnit(item.width),
      minWidth: addUnit(item.minWidth),
    };

    // 根据type,渲染不同的内容
    switch (this.type) {
      case "label":
        // 渲染label
        return h(
          this.tag,
          {
            style: style,
            class: [
              "el-descriptions__cell",
              "el-descriptions__label",
              { "is-bordered-label": this.descriptions.border },
              labelAlign,
              labelClassName,
            ],
            colSpan: this.descriptions.direction === "vertical" ? span : 1,
          },
          label
        );
      case "content":
        // 渲染content
        return h(
          this.tag,
          {
            style: style,
            class: ["el-descriptions__cell", "el-descriptions__content", align, className],
            colSpan: this.descriptions.direction === "vertical" ? span : span * 2 - 1,
          },
          content
        );
      default:
        // type是both时;一个td中同时包含label和content
        return h(
          "td",
          {
            style: style,
            class: [align],
            colSpan: span,
          },
          [
            h(
              "span",
              {
                class: ["el-descriptions__cell", "el-descriptions__label", labelClassName],
              },
              label
            ),
            h(
              "span",
              {
                class: ["el-descriptions__cell", "el-descriptions__content", className],
              },
              content
            ),
          ]
        );
    }
  },
});