vxe-table 源码学习 - 结构与基础用法

源码版本:4.5.0-beta.20

以下是针对 vxe-table 各功能的源码及原理的学习,有需要的同学可以直接通过目录跳转。

结构

基础表格结构

首先是最基础的表格内容,分为表头、内容和表尾。

js 复制代码
  // 完整表格
  h('div', {
    class: 'vxe-table--main-wrapper'
  }, [
    /**
     * 表头
     */
    showHeader ? h(TableHeaderComponent, {
      ref: refTableHeader,
      tableData,
      tableColumn,
      tableGroupColumn
    }) : createCommentVNode(),
    /**
     * 表体
     */
    h(TableBodyComponent as ComponentOptions, {
      ref: refTableBody,
      tableData,
      tableColumn
    }),
    /**
     * 表尾
     */
    showFooter ? h(TableFooterComponent, {
      ref: refTableFooter,
      footerTableData,
      tableColumn
    }) : createCommentVNode()
  ]),

表头和表尾的固定

从上面的代码中可以看到表头、表体和表尾是自上而下的关系。

所以只要将中间的表体设定一个高度,让内容在表体中滚动显示就可以达到表头和表尾的固定了。

固定列

表格除了常规的数据展示,还需要有固定列的功能。有些组件库中叫做冻结。先看代码:

js 复制代码
  // 固定列
  h('div', {
    class: 'vxe-table--fixed-wrapper'
  }, [
    /**
     * 左侧固定区域
     */
    leftList && leftList.length && overflowX ? renderFixed('left') : createCommentVNode(),
    /**
     * 右侧固定区域
     */
    rightList && rightList.length && overflowX ? renderFixed('right') : createCommentVNode()
  ])

可以看到如果设定了 fixed 属性且组件所在容器宽度不够,那么组件会左右各渲染一块固定列区域。

从 html 结构上可以看到固定列区服分为左右两片区域,每片区域都进行了 header、body 和 footer 的渲染。

小知识: createCommentVNode() 函数是 vue 提供的,可以创建一串 <!----> 的注释字符串。

空数据

空数据的渲染就很简单,知识渲染一段空数据提示内容。

js 复制代码
    /**
     * 空数据
     */
    h('div', {
      ref: refEmptyPlaceholder,
      class: 'vxe-table--empty-placeholder'
    }, [
      h('div', {
        class: 'vxe-table--empty-content'
      }, renderEmptyContenet())
    ]),

而空数据的显示与否的判断在于父容器的 is--empty 类。

json 复制代码
{
    class: [{
        ...,
        'is--empty': !loading && !tableData.length,
    }]
}

加载中

js 复制代码
    /**
     * 加载中
     */
    h(VxeLoading, {
      class: 'vxe-table--loading',
      modelValue: loading,
      icon: loadingOpts.icon,
      text: loadingOpts.text
    }, loadingSlot ? {
      default: () => loadingSlot({ $table: $xetable, $grid: $xegrid })
    } : {}),

交互

固定列和基础表格的同步滚动

既然是同步滚动,就必然需要监听滚动事件。可以在组件代码中搜索滚动监听事件来快速定位。

javascript 复制代码
el.addEventListener('scroll', (event) => {
  // 处理滚动事件
});

el.onscroll = (event) => {
  // 处理滚动事件
};

于是找到了如下代码:

js 复制代码
onMounted(() => {
  nextTick(() => {
    ...
    el.onscroll = scrollEvent
    el._onscroll = scrollEvent
  })
})

/**
 * 滚动处理
 * 如果存在列固定左侧,同步更新滚动状态
 * 如果存在列固定右侧,同步更新滚动状态
 */
const scrollEvent = (evnt: Event) => {
  // 获取各种位置信息
  const { fixedType } = props
  const { highlightHoverRow } = tableProps
  const { scrollXLoad, scrollYLoad } = tableReactData
  const { elemStore, lastScrollTop, lastScrollLeft } = tableInternalData
  const rowOpts = computeRowOpts.value
  const tableHeader = refTableHeader.value
  const tableBody = refTableBody.value
  const tableFooter = refTableFooter.value
  const leftBody = refTableLeftBody.value
  const rightBody = refTableRightBody.value
  const validTip = refValidTooltip.value
  const scrollBodyElem = refElem.value
  const headerElem = tableHeader ? tableHeader.$el as HTMLDivElement : null
  const footerElem = tableFooter ? tableFooter.$el as HTMLDivElement : null
  const bodyElem = tableBody.$el as XEBodyScrollElement
  const leftElem = leftBody ? leftBody.$el as XEBodyScrollElement : null
  const rightElem = rightBody ? rightBody.$el as XEBodyScrollElement : null
  const bodyYRef = elemStore['main-body-ySpace']
  const bodyYElem = bodyYRef ? bodyYRef.value : null
  const bodyXRef = elemStore['main-body-xSpace']
  const bodyXElem = bodyXRef ? bodyXRef.value : null
  const bodyHeight = scrollYLoad && bodyYElem ? bodyYElem.clientHeight : bodyElem.clientHeight
  const bodyWidth = scrollXLoad && bodyXElem ? bodyXElem.clientWidth : bodyElem.clientWidth
  let scrollTop = scrollBodyElem.scrollTop
  const scrollLeft = bodyElem.scrollLeft
  const isRollX = scrollLeft !== lastScrollLeft
  const isRollY = scrollTop !== lastScrollTop

  // 记录最后一次滑动的位置和时间
  tableInternalData.lastScrollTop = scrollTop
  tableInternalData.lastScrollLeft = scrollLeft
  tableReactData.lastScrollTime = Date.now()

  // 关闭悬浮高亮
  if (rowOpts.isHover || highlightHoverRow) {
    $xetable.clearHoverRow()
  }


  if (leftElem && fixedType === 'left') {
    // 左边固定列只能上下移动,并且同步滑动
    scrollTop = leftElem.scrollTop
    syncBodyScroll(fixedType, scrollTop, bodyElem, rightElem)
  } else if (rightElem && fixedType === 'right') {
    // 右边固定列只能上下移动,并且同步滑动
    scrollTop = rightElem.scrollTop
    syncBodyScroll(fixedType, scrollTop, bodyElem, leftElem)
  } else {
    // 表格有横向滑动,处理表头表尾的横向滑动
    if (isRollX) {
      if (headerElem) {
        headerElem.scrollLeft = bodyElem.scrollLeft
      }
      if (footerElem) {
        footerElem.scrollLeft = bodyElem.scrollLeft
      }
    }
    // 如果有固定列,且有纵向滑动,则同步滑动
    if (leftElem || rightElem) {
      $xetable.checkScrolling()
      if (isRollY) {
        syncBodyScroll(fixedType, scrollTop, leftElem, rightElem)
      }
    }
  }
  if (scrollXLoad && isRollX) {
    $xetable.triggerScrollXEvent(evnt)
  }
  if (scrollYLoad && isRollY) {
    $xetable.triggerScrollYEvent(evnt)
  }
  // 移动 tip 提示框的位置
  if (isRollX && validTip && validTip.reactData.visible) {
    validTip.updatePlacement()
  }

  // 这里的所有 $xetable.event() 都是表格的事件监听触发
  $xetable.dispatchEvent('scroll', {
    type: renderType,
    fixed: fixedType,
    scrollTop,
    scrollLeft,
    scrollHeight: bodyElem.scrollHeight,
    scrollWidth: bodyElem.scrollWidth,
    bodyHeight,
    bodyWidth,
    isX: isRollX,
    isY: isRollY
  }, evnt)
}

/**
 * 同步滚动条
 */
let scrollProcessTimeout: any
const syncBodyScroll = (fixedType: VxeColumnPropTypes.Fixed, scrollTop: number, elem1: XEBodyScrollElement | null, elem2: XEBodyScrollElement | null) => {
  // 只需要同步 Y 轴的位置就好(固定列不会横向移动)
  if (elem1 || elem2) {
    if (elem1) {
      removeScrollListener(elem1)
      elem1.scrollTop = scrollTop
    }
    if (elem2) {
      removeScrollListener(elem2)
      elem2.scrollTop = scrollTop
    }
    clearTimeout(scrollProcessTimeout)
    scrollProcessTimeout = setTimeout(() => {
      restoreScrollListener(elem1)
      restoreScrollListener(elem2)
    }, 300)
  }
}

和我预想的差不多,完全是通过监听 scroll 事件,并通过修改 ele.scrollTopele.scrollLeft 来同步位置的。

复习一下 nextTick() 函数的作用:

当你在 Vue 中更改响应式状态时,最终的 DOM 更新并不是同步生效的,而是由 Vue 将它们缓存在一个队列中,直到下一个"tick"才一起执行。这样是为了确保每个组件无论发生多少状态改变,都仅执行一次更新。

nextTick() 可以在状态改变后立即使用,以等待 DOM 更新完成。你可以传递一个回调函数作为参数,或者 await 返回的 Promise。

基础表格的列宽处理

下面我们来看看表格是如何控制列宽的。

从表格的 HTML 结构中可以看到了 colgroup 这个标签。它在 mdn 中的定义为:

HTML 中的 表格列组(Column Group )标签用来定义表中的一组列表。

其实这个标签就是控制列宽的关键了。看下源码:

js 复制代码
// header.ts
/**
 * 列宽
 */
h('colgroup', {
  ref: refHeaderColgroup
}, renderColumnList.map((column, $columnIndex) => {
  return h('col', {
    name: column.id,
    key: $columnIndex
  })
}).concat(scrollbarWidth ? [
  h('col', {
    name: 'col_gutter'
  })
] : [])),

// body.ts
/**
 * 列宽
 */
h('colgroup', {
  ref: refBodyColgroup
}, (tableColumn as any[]).map((column, $columnIndex) => {
  return h('col', {
    name: column.id,
    key: $columnIndex
  })
})),

// footer.ts
/**
 * 列宽
 */
h('colgroup', {
  ref: refFooterColgroup
}, tableColumn.map((column, $columnIndex) => {
  return h('col', {
    name: column.id,
    key: $columnIndex
  })
}).concat(scrollbarWidth ? [
  h('col', {
    name: 'col_gutter'
  })
] : [])),

源码分别在 header、body 和 footer 中加上了 colgroup 来控制列宽。其中 header 和 footer 的 col_gutter 列是为了适配 body 中出现滚动条的情况。

接下来,需要知道 colgroup 的 style="width: 176px" 这段样式是从哪加上去的呢?

首先源码将 header、body 和 footer 的 colgroup ref 对象存到一个公共 store 对象中。

js 复制代码
const prefix = `${fixedType || 'main'}-header-`
elemStore[`${prefix}colgroup`] = refHeaderColgroup

const prefix = `${fixedType || 'main'}-body-`
elemStore[`${prefix}colgroup`] = refBodyColgroup

const prefix = `${fixedType || 'main'}-footer-`
elemStore[`${prefix}colgroup`] = refFooterColgroup

然后再统一处理表格宽度样式(这里隐藏了很多其他逻辑)。

js 复制代码
const updateStyle = () => {
  const containerList = ['main', 'left', 'right']

  containerList.forEach((name, index) => {
    const layoutList = ['header', 'body', 'footer']

    layoutList.forEach(layout => {
      const colgroupRef = elemStore[`${name}-${layout}-colgroup`]
      const colgroupElem = colgroupRef ? colgroupRef.value : null
      if (colgroupElem) {
        XEUtils.arrayEach(colgroupElem.children, (colElem: any) => {
          const colid = colElem.getAttribute('name')

          // 计算滚动条宽度
          if (colid === 'col_gutter') {
            colElem.style.width = `${scrollbarWidth}px`
          }
          if (fullColumnIdData[colid]) {
            const column = fullColumnIdData[colid].column
            const { showHeaderOverflow, showFooterOverflow, showOverflow } = column
            let cellOverflow

            // 这里就是设置
            colElem.style.width = `${column.renderWidth}px`
          }
        })
      }
    })
  })
  return nextTick()
}

结论是 header、body 和 footer 统一通过 updateStyle 函数获取 column 中的最终宽度值。并通过 ref 对象将样式写到 col 元素上的。

表格列宽拖动

好奇列宽拖动是怎么实现的,于是先看了表头拖动区域的 DOM 结构,发现里面有个不可见的 DOM 元素 <div class="vxe-resizable is--line"></div>。于是到源码中去查找。

js 复制代码
// header.ts
/**
 * 列宽拖动
 */
!fixedHiddenColumn && !isColGroup && (XEUtils.isBoolean(column.resizable) ? column.resizable : (columnOpts.resizable || resizable)) ? h('div', {
  class: ['vxe-resizable', {
    'is--line': !border || border === 'none'
  }],
  onMousedown: (evnt: MouseEvent) => resizeMousedown(evnt, params)
}) : null

后面的逻辑稍显复杂,我就用步骤来说吧。

  1. 在 resizeMousedown 函数中记录 vxe-resizable 元素拖动的 onMouseMove 和 onMouseUp 事件。
  2. 当触发 onMouseUp 用户松开鼠标后记录拖拽后的列宽 column.resizeWidth = resizeWidth,这里的 column 是一个全局对象。
  3. 在采集到 resizeWidth 后通过 $xetable.analyColumnWidth() 函数将表格所有列宽信息记录到全局状态 columnStore 上面。
  4. 在表格的 autoCellWidth() 函数中,会通过 columnStore 的信息开始计算表格所有列的渲染宽度 column.renderWidth = width
  5. 根据上一章获取列宽的方式通过 column.renderWidth 进行列宽的再渲染。
js 复制代码
colElem.style.width = `${column.renderWidth}px`

PS: 第四步是猜测的,因为始终没有找到如何将 columnStore 中的信息同步到 fullColumnIdData 上去的。

展开行

从 DOM 结构中看到 vxe-body--expanded-cell 这个类,全局查找后找到如下代码:

js 复制代码
// 如果行被展开了
if (isExpandRow) {
  const expandOpts = computeExpandOpts.value
  const { height: expandHeight } = expandOpts
  const cellStyle: any = {}
  if (expandHeight) {
    cellStyle.height = `${expandHeight}px`
  }
  if (treeConfig) {
    cellStyle.paddingLeft = `${(rowLevel * treeOpts.indent) + 30}px`
  }
  const { showOverflow } = expandColumn
  const hasEllipsis = (XEUtils.isUndefined(showOverflow) || XEUtils.isNull(showOverflow)) ? allColumnOverflow : showOverflow
  const expandParams = { $table: $xetable, seq, column: expandColumn, fixed: fixedType, type: renderType, level: rowLevel, row, rowIndex, $rowIndex, _rowIndex }
  rows.push(
    h('tr', {
      class: 'vxe-body--expanded-row',
      key: `expand_${rowid}`,
      style: rowStyle ? (XEUtils.isFunction(rowStyle) ? rowStyle(expandParams) : rowStyle) : null,
      ...trOn
    }, [
      h('td', {
        class: {
          'vxe-body--expanded-column': 1,
          'fixed--hidden': fixedType && !hasFixedColumn,
          'col--ellipsis': hasEllipsis
        },
        colspan: tableColumn.length
      }, [
        h('div', {
          class: {
            'vxe-body--expanded-cell': 1,
            'is--ellipsis': expandHeight
          },
          style: cellStyle
        }, [
          expandColumn.renderData(expandParams)
        ])
      ])
    ])
  )
}

原理上,展开行其实就是在原表格下增加一行 tr 来渲染自定义展开内容。

最后

看了源码的收获:

  • 满足好奇心,验证了不少我在项目中对于 vxe-table 的疑惑。
  • 学习了代码风格,写的真不戳。感觉复杂开源项目想要把代码写好也是门艺术和挑战。比业务代码看着强多了。
  • 学到了表格分组 colgroup 以及其他 HTML 表格相关知识。
  • 发现 Vue 的渲染函数 h() 写代码还挺清晰的。我一直以为大家都会去用 jsx 呢。果然存在即合理。

后续会继续学习它复杂功能的实现,敬请期待。

相关推荐
一枚小小程序员哈1 小时前
基于Vue + Node能源采购系统的设计与实现/基于express的能源管理系统#node.js
vue.js·node.js·express
一枚小小程序员哈6 小时前
基于Vue的个人博客网站的设计与实现/基于node.js的博客系统的设计与实现#express框架、vscode
vue.js·node.js·express
定栓6 小时前
vue3入门-v-model、ref和reactive讲解
前端·javascript·vue.js
LIUENG7 小时前
Vue3 响应式原理
前端·vue.js
wycode8 小时前
Vue2实践(3)之用component做一个动态表单(二)
前端·javascript·vue.js
wycode9 小时前
Vue2实践(2)之用component做一个动态表单(一)
前端·javascript·vue.js
第七种黄昏9 小时前
Vue3 中的 ref、模板引用和 defineExpose 详解
前端·javascript·vue.js
pepedd86410 小时前
还在开发vue2老项目吗?本文带你梳理vue版本区别
前端·vue.js·trae
前端缘梦10 小时前
深入理解 Vue 中的虚拟 DOM:原理与实战价值
前端·vue.js·面试
HWL567910 小时前
pnpm(Performant npm)的安装
前端·vue.js·npm·node.js