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 呢。果然存在即合理。

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

相关推荐
LCG元6 小时前
Vue.js组件开发-使用vue-pdf显示PDF
vue.js
哥谭居民00017 小时前
将一个组件的propName属性与父组件中的variable变量进行双向绑定的vue3(组件传值)
javascript·vue.js·typescript·npm·node.js·css3
烟波人长安吖~7 小时前
【目标跟踪+人流计数+人流热图(Web界面)】基于YOLOV11+Vue+SpringBoot+Flask+MySQL
vue.js·pytorch·spring boot·深度学习·yolo·目标跟踪
PleaSure乐事8 小时前
使用Vue的props进行组件传递校验时出现 Extraneous non-props attributes的解决方案
vue.js
土豆炒马铃薯。9 小时前
【Vue】前端使用node.js对数据库直接进行CRUD操作
前端·javascript·vue.js·node.js·html5
赵大仁10 小时前
深入解析 Vue 3 的核心原理
前端·javascript·vue.js·react.js·ecmascript
bidepanm10 小时前
Vue.use()和Vue.component()
前端·javascript·vue.js
Ashore_12 小时前
从简单封装到数据响应:Vue如何引领开发新模式❓❗️
前端·vue.js
顽疲12 小时前
从零用java实现 小红书 springboot vue uniapp (6)用户登录鉴权及发布笔记
java·vue.js·spring boot·uni-app
&活在当下&12 小时前
ref 和 reactive 的用法和区别
前端·javascript·vue.js