源码版本: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.scrollTop
和 ele.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
后面的逻辑稍显复杂,我就用步骤来说吧。
- 在 resizeMousedown 函数中记录
vxe-resizable
元素拖动的 onMouseMove 和 onMouseUp 事件。 - 当触发 onMouseUp 用户松开鼠标后记录拖拽后的列宽
column.resizeWidth = resizeWidth
,这里的 column 是一个全局对象。 - 在采集到
resizeWidth
后通过$xetable.analyColumnWidth()
函数将表格所有列宽信息记录到全局状态columnStore
上面。 - 在表格的
autoCellWidth()
函数中,会通过columnStore
的信息开始计算表格所有列的渲染宽度column.renderWidth = width
。 - 根据上一章获取列宽的方式通过
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 呢。果然存在即合理。
后续会继续学习它复杂功能的实现,敬请期待。