从源码 解析vxe-table 虚拟滚动的实现一

1,487 阅读3分钟

前言

大数据table渲染的时候会变得很卡顿,我们经常会改用虚拟表格来做优化。 主要原理是在可视区内在展示相关的数据,看不到的就不做渲染,根据滚动条移动的位置计算出可视区内元素集合。通过计算出的startIndex/endIndex截取真实数据列表拿出来做数据渲染。 image.png

虚拟滚动其他参考

别再抱怨后端一次性传给你 1w 条数据了,几行代码教会你虚拟滚动!
「前端进阶」高性能渲染十万条数据(虚拟列表)

我也研究了下公司用的vxe-table第三方组件,与大家分享~

vxe-table 组件解析

一.vxe-table 主体内容

image.png

vxe-table组件主要分为: X滚动条,Y滚动条,table左固定,table主体内容,table右固定 每个又内容块都是通过table组装好拼起来的

主要更新数据的手段: 1.table y轴滚动条vxe-table--scroll-y-handle元素绑定onScroll=$xeTable.triggerVirtualScrollYEvent做更新, 2.vxe-table--viewport-wrapper元素绑定滚轮wheel事件$xeTable.triggerBodyWheelEvent做更新, 3.table-bodyvxe-table--body-inner-wrapper元素绑定onScroll=$xeTable.triggerBodyScrollEvent事件做数据更新 这几个手段 => 做到vxe-table的y轴数据更新

1.主要结构

    <div class="vxe-table">
        <!-- a)主体内容 -->
        <div class="vxe-table--render-wrapper"> 
           <!-- a-1)renderBody部分 -->
           <div class="vxe-table--layout-wrapper">
               <div class="vxe-table--viewport-wrapper">
                   <!-- table主体内容 -->
                   <div class="vxe-table--main-wrapper">
                       <!-- 表头 -->
                       <TableHeaderComponent ref="refTableHeader" tableData tableColumn tableGroupColumn />
                       <!-- 表体 -->
                       <TableBodyComponent ref="refTableBody" tableData tableColumn />
                       <!-- 表尾 -->
                       <TableFooterComponent ref="refTableFooter" footerTableData tableColumn />
                   </div>
               </div>
           </div>
            
            <!-- a-2)renderScrollY Y轴滚动条 -->
            <div class="vxe-table--scroll-y-virtual">
               <div class="vxe-table--scroll-y-wrapper">
                    <!-- renderScrollY Y轴滚动条-滚动事件 -->
                   <div class="vxe-table--scroll-y-handle" ref="refScrollYHandleElem" onScroll={triggerVirtualScrollYEvent}>  
                        <!-- renderScrollY Y轴滚动条-长度 -->
                        <div class="vxe-table--scroll-y-space" ref="refScrollYSpaceElem"></div> 
                   </div>  
                </div>
            </div>
        </div>
        
        <!-- b)renderScrollX X轴滚动条 -->
        <div class="vxe-table--scroll-y-virtual">
            <div class="vxe-table--scroll-x-wrapper">  
                <!-- renderScrollY X轴滚动条-滚动事件 -->
                <div class="vxe-table--scroll-x-handle" ref="refScrollXHandleElem" onScroll="triggerVirtualScrollXEvent">  
                <!-- renderScrollY X轴滚动条-长度 -->
                <div class="vxe-table--scroll-x-space" ref="refScrollXSpaceElem"></div>  
                </div>  
            </div>
        </div>
    </div>

2.主要事件

由于table 的x轴滚动条renderScrollX,y轴滚动条renderScrollY是额外新增的, 通过 滚动x/y轴滚动条触发table的滚动更新需要onScroll手动触发 (x轴$xeTable.triggerVirtualScrollXEvent, y轴$xeTable.triggerVirtualScrollYEvent) 做更新, vxe-table--viewport-wrapper元素绑定滚轮事件$xeTable.triggerBodyWheelEvent做更新

3.核心逻辑

<div class="vxe-table">  
    <!-- 主体内容 -->  
    <div class="vxe-table--render-wrapper">  
        <!-- 根据 props.scrollbarConfig 配置的 {x: {position: 'top'}} 控制横向x滚动条在table上(默认下) -->
        { scrollbarXToTop ? [renderBody(), renderScrollX()] : [renderScrollX(), renderBody()] }
    </div>
</div>
  
<script lang="tsx">  
// table壳 渲染  
const renderBody = () => {  
    // 根据 props.scrollbarConfig 配置的 {y: {position: 'left'}} 控制纵向y滚动条在table上(默认右) 
    const scrollbarYToLeft = computeScrollbarYToLeft.value  
    return <div class="vxe-table--layout-wrapper">  
        {scrollbarYToLeft ? [  
        renderScrollY(),  
        renderViewport()  
        ] : [  
        // 真实table  
        renderViewport(),  
        // 纵向滚动条  
        renderScrollY()  
        ]}  
        </div>  
}  
// table 渲染  
const renderViewport = () => {  
    const { showHeader, showFooter } = props  
    return <div ref="refTableViewportElem" class="vxe-table--viewport-wrapper">  
        {/*渲染table主体内容*/}  
        <div class="vxe-table--main-wrapper">  
        {/*表头*/}  
        {  
        showHeader ? <TableHeaderComponent ref="refTableHeader" tableData tableColumn tableGroupColumn /> : ''  
        }  
        {/*表体*/}  
        <TableBodyComponent ref="refTableBody" tableData tableColumn />  
        {/*表尾*/}  
        {  
        showHeader ? <TableFooterComponent ref="refTableFooter" footerTableData tableColumn /> : ''  
        }  
        </div>  
        {/*渲染table左/右固定*/}  
        <div class="vxe-table--fixed-wrapper">  
            {leftList.length && overflowX ? renderFixed('left') : ''}  
            {rightList.length && overflowX ? renderFixed('right') : ''}  
        </div>
    </div>  
}  
// 渲染 左右侧 固定的 columns  
const renderFixed = (fixedType: 'left' | 'right') => {  
    const isFixedLeft = fixedType === 'left'  
    return <div class={`vxe-table--fixed-${fixedType}-wrapper`} ref={isFixedLeft ? refLeftContainer : refRightContainer}>  
        {/*表头*/}  
        {  
        showHeader ? <TableHeaderComponent ref={isFixedLeft ? refTableLeftHeader : refTableRightHeader} fixedType tableData tableColumn tableGroupColumn fixedColumn /> : ''  
        }  
        {/*表体*/}  
        <TableBodyComponent ref={isFixedLeft ? refTableLeftBody : refTableRightBody} tableData tableColumn fixedColumn />  
        {/*表尾*/}  
        {  
        showHeader ? <TableFooterComponent ref={isFixedLeft ? refTableLeftFooter : refTableRightFooter} fixedType footerTableData tableColumn fixedColumn /> : ''  
        }  
    </div>  
}  
// x轴滚动条  
const renderScrollX = () => {  
    return <div ref="refScrollXVirtualElem" class="vxe-table--scroll-x-virtual">  
        <div class="vxe-table--scroll-x-left-corner" ref="refScrollXLeftCornerElem"></div>  
        <div class="vxe-table--scroll-x-wrapper" ref="refScrollXWrapperElem">  
            <div class="vxe-table--scroll-x-handle" ref="refScrollXHandleElem" onScroll="triggerVirtualScrollXEvent">  
                <div class="vxe-table--scroll-x-space" ref="refScrollXSpaceElem"></div>  
            </div>  
        </div>  
        <div class="vxe-table--scroll-x-right-corner" ref="refScrollXRightCornerElem"></div>  
    </div>  
}  
// y轴滚动条  
const renderScrollY = () => {  
return <div ref="refScrollYVirtualElem" class="vxe-table--scroll-y-virtual">  
    <div class="vxe-table--scroll-y-top-corner" ref="refScrollYTopCornerElem"></div>  
    <div class="vxe-table--scroll-y-wrapper" ref="refScrollYWrapperElem">  
        <div class="vxe-table--scroll-y-handle" ref="refScrollYHandleElem" onScroll={triggerVirtualScrollYEvent}>  
            <div class="vxe-table--scroll-y-space" ref="refScrollYSpaceElem"></div>  
        </div>  
    </div>  
    <div class="vxe-table--scroll-y-bottom-corner" ref="refScrollYBottomCornerElem"></div>  
    </div>  
}  
// 触发纵向y虚拟滚动事件,通知外部组件滚动状态变化  
const triggerVirtualScrollYEvent = (evnt) => {  
    const { elemStore, inWheelScroll, lastScrollTop, inHeaderScroll, inBodyScroll, inFooterScroll } = internalData  
    // 过滤掉 body滚动  
    if (inHeaderScroll || inBodyScroll || inFooterScroll) {  
        return  
    }  
    // 过滤掉 滚轮 滚动  
    if (inWheelScroll) {  
        return  
    }  

    // 虚拟y滚动  
    if (scrollYLoad) {  
        $xeTable.triggerScrollYEvent(evnt)  
    }  
    $xeTable.handleScrollEvent(evnt, isRollY, isRollX, scrollTop, scrollLeft, {  
        type: 'table',  
        fixed: ''  
    })  
}  
onMounted(() => {  
    const tableViewportEl = refTableViewportElem.value  
    if (tableViewportEl) {  
        // 绑定 滚轮事件 主要兼容 renderFixed('left' | 'right') table 渲染的部分  
        tableViewportEl.addEventListener('wheel', /*$xeTable.triggerBodyWheelEvent*/triggerBodyWheelEvent, { passive: false })  
        // $xeTable.triggerBodyWheelEvent  
        const triggerBodyWheelEvent = (evnt) => {  
        if (scrollYLoad) {  
            $xeTable.triggerScrollYEvent(evnt)  
        }  
        $xeTable.handleScrollEvent(evnt, isRollY, isRollX, currTopNum, bodyScrollElem.scrollLeft, {  
            type: 'table',  
            fixed: ''  
            })  
        }  
    }  
})  
  
const $xeTable = {  
    triggerScrollYEvent() {  
        // 纵向 Y 可视渲染处理  
        loadScrollYData()  
        const loadScrollYData = () => {  
            // 计算(存储在 scrollYStore) visibleStartIndex visibleEndIndex, startIndex endIndex  
            $xeTable.updateScrollYData()  
        }  
    },  
    // 计算 展示的 data数据  
    handleTableData(force?: boolean) {  
        const tableData = scrollYLoad ? fullList.slice(scrollYStore.startIndex, scrollYStore.endIndex) : fullList.slice(0)  
        reactData.tableData = tableData  
    },  
    // 更新y数据  
    updateScrollYData() {  
        $xeTable.handleTableData()  
        $xeTable.updateScrollYSpace()  
    },  
    // 更新纵向 Y 可视渲染上下剩余空间大小  
    updateScrollYSpace() {  
        const containerList = ['main', 'left', 'right']  
        if (bodyTableElem) {  
        bodyTableElem.style.transform = `translate(${reactData.scrollXLeft || 0}px, ${scrollYTop}px)`  
        }  
        containerList.forEach(name => {  
            const layoutList = ['header', 'body', 'footer']  
            layoutList.forEach(layout => {  
                const ySpaceElem = getRefElem(elemStore[`${name}-${layout}-ySpace`])  
                if (ySpaceElem) {  
                    ySpaceElem.style.height = ySpaceHeight ? `${ySpaceHeight}px` : ''  
                }  
            })  
        })  
    },  
    // 处理滚动事件,计算新的 startIndex 和 endIndex,并触发重新渲染  
    handleScrollEvent (evnt, isRollY, isRollX, scrollTop, scrollLeft, params) {  
        // 更新 scrollTop  
        internalData.lastScrollTop = scrollTop  
        // 派发滚动条事件  
        dispatchEvent('scroll', evntParams, evnt)  
    }  
}  
</script>  

详细源码参考: packages/table/src/table.ts

二.vxe-table TableBodyComponent(body.ts)

1.主要结构

    <div class="vxe-table--body-wrapper">
        <!-- 主体内容 -->
        <div class="vxe-table--body-inner-wrapper" ref="refBodyScroll" onScroll={$xeTable.triggerBodyScrollEvent}> 
          <!-- a)Y轴占位高度 -->
          <div class="vxe-body--y-space"></div>
          <!-- b)table-body内容区 -->
          <div class="vxe-table--body"> 
             <!-- b-1)列宽设置 -->
             <colgroup>  
                {/* columns配置渲染 */}  
                {renderColumnList.map((column, $columnIndex) => {  
                return <col style={`width: ${column.renderWidth}px`}></col>  
                })}  
            </colgroup>
             <!-- b-2)内容 -->
             <tbody>
                 <!-- tr>td渲染 renderRows(fixedType, isOptimizeMode, renderDataList, renderColumnList) -->
                 {renderColumnList.map(v => {
                     const tdVNs = tableColumn.map((column, $columnIndex) => {
                         // td
                         return <td class="vxe-body--column">
                             // 单元格内容
                             <div class="vxe-cell">
                                 // td 真实渲染区域
                                 <div class="vxe-cell--wrapper">  
                                     {column.renderCell(cellParams)}  
                                 </div>  
                            </div>
                         </td>  
                        })
                     return <tr class="vxe-body--row">
                         {tdVNs}
                     </tr>
                 })}
             </tbody>
            </div>
        </div>
    </div>

2.主要事件

table-body 内置了onScroll事件 触发$xeTable.triggerBodyScrollEvent做数据更新

3.核心逻辑

<script lang="tsx">
    // 渲染主体
    const renderVN = () => {  
        const { fixedColumn, fixedType, tableColumn } = props  
        // table 的 $xeTable.triggerBodyScrollEvent  
        const triggerBodyScrollEvent = (evnt, fixedType) => {  
            // 标记body 滚动  
            internalData.inBodyScroll = true  
            if (scrollYLoad) {  
                $xeTable.triggerScrollYEvent(evnt)  
            }  
            $xeTable.handleScrollEvent(evnt, isRollY, isRollX, scrollTop, scrollLeft, {  
                type: 'body',  
                fixed: fixedType  
            })  
        }  
        const ons = {  
            onScroll(evnt){  
                // $xeTable.triggerBodyScrollEvent(evnt, fixedType)  
                triggerBodyScrollEvent(evnt, fixedType)  
            }  
        }  
        return <div class={['vxe-table--body-wrapper', fixedType ? `fixed-${fixedType}--wrapper` : 'body--wrapper']}>  
            <div class="vxe-table--body-inner-wrapper" >  
            {  
            fixedType ? '' : <div class="vxe-body--x-space"></div>  
            }  
            <div class="vxe-body--y-space"></div>  
            <table class="vxe-table--body">  
                {/*列宽*/}  
                <colgroup>  
                    {/* renderColumnList: 即 调整后的tableColumn */}  
                    {renderColumnList.map((column, $columnIndex) => {  
                        return <col style={`width: ${column.renderWidth}px`}></col>  
                    })}  
                </colgroup>  
                {/*内容*/}  
                <tbody ref="refBodyTBody">  
                    {renderRows(fixedType, isOptimizeMode, renderDataList, renderColumnList)}  
                </tbody>  
            </table>  
            </div>  
        </div>  
    }  
    
    const renderRows = (fixedType: 'left' | 'right' | '', isOptimizeMode: boolean, tableData: any[], tableColumn: VxeTableDefines.ColumnInfo[]) => {  
        const rows: any[] = []  
        tableData.forEach((row, $rowIndex) => {  
            const tdVNs = tableColumn.map((column, $columnIndex) => { 
                // 渲染tds列
                return renderTdColumn(seq, rowid, fixedType, isOptimizeMode, rowLevel, row, rowIndex, $rowIndex, _rowIndex, column, $columnIndex, tableColumn, tableData)  
            })  
            rows.push({  
            <tr class="vxe-body--row">{tdVNs}</tr>  
            })  
        }  
        return rows  
    }  
    
    // 渲染列  
    const renderTdColumn = (  
        seq: number | string,  
        rowid: string,  
        fixedType: 'left' | 'right' | '',  
        isOptimizeMode: boolean,  
        rowLevel: number,  
        row: any,  
        rowIndex: number,  
        $rowIndex: number,  
        _rowIndex: number,  
        column: VxeTableDefines.ColumnInfo,  
        $columnIndex: number,  
        columns: VxeTableDefines.ColumnInfo[],  
        items: any[]  
    ) => {  
        // 判断是否是隐藏列  
        let fixedHiddenColumn = fixedType ? column.fixed !== fixedType : column.fixed && overflowX  
        const tdVNs: any[] = []  
        // 渲染单元格  
        // 若在该 column 不在body里面渲染(被安排到固定列渲染的部分 只作占位)  
        if (fixedHiddenColumn) {  
            tdVNs.push(<div class="vxe-cell"></div>)  
        } else {  
            tdVNs.push(<div class="vxe-cell">  
                <div class="vxe-cell--wrapper">  
                    {column.renderCell(cellParams)}  
                </div>  
            </div>  
            )  
        }
        // ... // 单元格宽度拖拽 // 单元格高度拖拽 其他相关逻辑...
        return <td class="vxe-body--column">{tdVNs}</td>
    }
</script>

参考源码: packages/table/src/body.ts

三.vxe-table TableHeaderComponent(header.ts)

1.主要结构

    <div class="vxe-table--header-wrapper">
        <!-- 主体内容 -->
        <div class="vxe-table--header-inner-wrapper"> 
          <!-- a)table-header内容区 -->
          <table ref="refHeaderTable" class="vxe-table--header">
          <!-- a-1)列宽设置 -->
             <colgroup>  
                {/* columns配置渲染 */}  
                {renderColumnList.map((column, $columnIndex) => {  
                return <col style={`width: ${column.renderWidth}px`}></col>  
                })}  
            </colgroup>
             <!-- a-2)头部 -->
             <thead>
                 <!-- tr>thead渲染 renderHeads(isGroup, isOptimizeMode, renderHeaderList) -->
                 <tr class="vxe-header--row">
                     {renderColumnList.map(column => {
                         return <th class="vxe-header--column">
                             <div class="vxe-cell">
                                 <div class="vxe-cell--wrapper">{column.renderHeader()}</div>
                             </div>
                         </th>
                     })}
                 </tr>
             </thead>
          </table>
        </div>
    </div>

2.核心逻辑

<script lang="tsx">
// 渲染主体  
const renderVN = () => {  
    const { fixedType, fixedColumn, tableColumn } = props  
    let renderHeaderList = [renderColumnList]
    return <div class={['vxe-table--header-wrapper', fixedType ? `fixed-${fixedType}--wrapper` : 'body--wrapper']}>  
        <div class="vxe-table--header-inner-wrapper" onScroll={event => triggerHeaderScrollEvent(event, fixedType)}>  
        {  
        fixedType ? '' : <div class="vxe-body--x-space"></div>  
        }  
        <table ref="refHeaderTable" class="vxe-table--header">  
        {/*列宽*/}  
        <colgroup>  
            {/* renderColumnList: 即 调整后的tableColumn */}  
            {renderColumnList.map((column, $columnIndex) => {  
                return <col style={`width: ${column.renderWidth}px`}></col>  
            })}  
        </colgroup>  
        {/*头部*/}  
        <thead ref="refHeaderTHead">  
            {renderHeads(isGroup, isOptimizeMode, renderHeaderList)}  
        </thead>  
        </table>
        </div>  
    </div>  
}  
  
const renderHeads = (isGroup: boolean, isOptimizeMode: boolean, headerGroups: VxeTableDefines.ColumnInfo[][]) => {  
    const { fixedType } = props  

    return headerGroups.map((cols, $rowIndex) => {  
        return <tr class="vxe-header--row">  
        {/* @renderRows 逻辑 */}  
        {  
        cols.map((column, $columnIndex) => {  
            return <th class="vxe-header--column">  
                <div class="vxe-cell">  
                    {/* 如果是隐藏列 只留占位不做渲染 */}  
                    {  
                    isVNPreEmptyStatus || (isOptimizeMode && fixedHiddenColumn) ? '' : <div class="vxe-cell--wrapper">{column.renderHeader()}</div>  
                    }
                </div>
            </th>  
        })  
        }  
        </tr>  
    })  
  
}
</script>

参考源码: packages/table/src/header.ts

四.vxe-table TableFooterComponent(footer.ts)

逻辑同上TableHeaderComponent

1.主要结构

    <div class="vxe-table--footer-wrapper">
        <!-- 主体内容 -->
        <div class="vxe-table--footer-inner-wrapper"> 
          <!-- a)table-footer内容区 -->
          <table ref="refFooterTable" class="vxe-table--footer">
              <!-- a-1)列宽设置 -->
             <colgroup>  
                {/* columns配置渲染 */}  
                {renderColumnList.map((column, $columnIndex) => {  
                    return <col style={`width: ${column.renderWidth}px`}></col>  
                })}  
            </colgroup>
             <!-- a-2)底部 -->
             <tfoot>
                 <!-- tr>tfoot渲染 renderHeads(isOptimizeMode, renderHeaderList) -->
                 {footerTableData.map(v => {
                     return <tr class="vxe-footer--row">
                         {tableColumn.map((column, $columnIndex) => {
                             return <td class="vxe-footer--column">
                                 // 单元格内容
                                 <div class="vxe-cell">
                                     <div class="vxe-cell--wrapper">  
                                         {column.renderFooter()}  
                                     </div>  
                                </div>
                             </td>  
                        })}
                     </tr>
                 })}
             </tfoot>
          </table>
        </div>
    </div>

2.核心逻辑

<script lang="tsx">
// 渲染主体  
const renderVN = () => {  
    const { fixedType, fixedColumn, tableColumn } = props  
    return <div class={['vxe-table--footer-wrapper', fixedType ? `fixed-${fixedType}--wrapper` : 'body--wrapper']}>  
        <div class="vxe-table--footer-inner-wrapper" onScroll={event => triggerFooterScrollEvent(event, fixedType)}>  
            {  
            fixedType ? '' : <div class="vxe-body--x-space"></div>  
            }  
            <table ref="refFooterTable" class="vxe-table--footer">  
                {/*列宽*/}  
                <colgroup>  
                    {/* renderColumnList: 即 调整后的tableColumn */}  
                    {renderColumnList.map((column, $columnIndex) => {  
                        return <col style={`width: ${column.renderWidth}px`}></col>  
                    })}  
                </colgroup>  
                {/*底部*/}  
                <tfoot ref="refFooterTFoot">  
                    {renderHeads(isOptimizeMode, renderHeaderList)}  
                </tfoot>  
            </table>  
        </div>  
    </div>  
}

const renderHeads = (isOptimizeMode: boolean, renderColumnList: VxeTableDefines.ColumnInfo[]) => {  
    const { fixedType, footerTableData } = props  
    return footerTableData.map((row, $rowIndex) => {  
        return <tr class="vxe-footer--row">  
            {/* @renderRows 逻辑 */}  
            {  
            tableColumn.map((column, $columnIndex) => {  
                return <td class="vxe-footer--column">  
                    <div class="vxe-cell">  
                        {/* 如果是隐藏列 只留占位不做渲染 */}  
                        {  
                        isVNPreEmptyStatus ? '' : <div class="vxe-cell--wrapper">{column.renderFooter()}</div>  
                        }
                    </div>
                </td>  
                })
            }  
        </tr>  
    })  
}
</script>

参考源码: packages/table/src/footer.ts

五.vxe-table 源码

github.com/x-extends/v…

结语

感谢阅读,由于篇幅问题 后面会出一篇根据vxe-table的理解,写一个简易版的vxe-table再做分享

# 从源码 解析vxe-table 虚拟滚动的实现二(实现miniVxeTable)