Element UI table组件部分源码解读(table-header部分)

4,444 阅读3分钟

上一篇文章解读了Table组件的store部分,今天来介绍一下主体的构成

回顾一下table.vue的构成:

// 隐藏列
<div class="hidden-columns" ref="hiddenColumns"><slot></slot></div>
// 表头部分
<div class="el-table__header-wrapper"><table-header></table-header></div>
// 主体部分
<div class="el-table__body-wrapper"><table-body></table-body></div>
// 占位块,没有数据时渲染
<div class="el-table__empty-block"></div>
// 插入至表格最后一行之后的内容 slot插入
<div class="el-table__append-wrapper"><slot="append"></slot></div>
// 表尾部分
<div class="el-table__footer-wrapper"><table-foot></table-foot></div>
// 左侧固定列部分
<div class="el-table__fixed"></div>
// 右侧固定列部分
<div class="el-table__fixed-right"></div>
// 右侧固定列部分补丁(滚动条)
<div class="el-table__fixed-right-patch"></div>
// 用于列宽调整的代理
<div class="el-table__column-resize-proxy"></div>

table-header

table-header较为复杂,是表格中的核心组成部分: 先看一下模板,这里使用的是jsx语法,提供了一个render函数:

render(h) {
    const originColumns = this.store.states.originColumns;
    const columnRows = convertToRows(originColumns, this.columns);
    // 是否拥有多级表头
    const isGroup = columnRows.length > 1;
    if (isGroup) this.$parent.isGroup = true;
    return (...)

首先通过store获取到columns ,这里originColumns是什么?查找store源码不难发现:

originColumns = fixedColumns + notFixedColumns + rightFixedColumns 其实就是左侧固定列+普通列+右侧固定列的集合,因为这里可能有嵌套等复杂情况的表头,需要调用convertToRows将所有的列信息转换成表头的行信息,包含层级,colspan等详细信息,根据转换后的columnRows判断是否存在多级表头。

下面是渲染的部分,简化后如下:

 <table>
     <colgroup></colgroup>
     <thead>
        <tr>
            <th>
                <div>...</div>
            </th>
        <tr>
     </thead>
 </table>

可以看到,实际上table-head也是一个独立的table

<colgroup>
    {
        this.columns.map(column => <col name={ column.id } key={column.id} />)
    }
    {
        this.hasGutter ? <col name="gutter" /> : ''
    }
</colgroup>

colgroup通过column遍历返回一个col的列表,最后判断一下hasGutter,即不存在固定列的时候,需要多加一列用于调整宽度,否则会造成表头和表体错位

hasGutter() {
    return !this.fixed && this.tableLayout.gutterWidth;
}

往下看thead部分,信息量有点大:

 <thead class={ [{ 'is-group': isGroup, 'has-gutter': this.hasGutter }] }>
    {
        this._l(columnRows, (columns, rowIndex) =>
            <tr
                style={ this.getHeaderRowStyle(rowIndex) }
                class={ this.getHeaderRowClass(rowIndex) }
            >
            {
                columns.map((column, cellIndex) = (<th>...</th>)
            }
            {
                this.hasGutter ? <th class="gutter"></th> : ''
            }
            </tr>
        )
    }
</thead>

isGroup在上面说了,是否多级表头。这里有个神奇的this._l方法,找了好久,最后在vue源码找到,这是vue内置的一个renderList方法的别名, 其实就是遍历,可以理解为 v-for 或者 Array.map。 getHeaderRowStylegetHeaderRowClass是暴露出来的两个props,允许用户自定义样式和class。

下面看下column的渲染部分,比较多,直接看源码

columns.map((column, cellIndex) => (<th
    // 提前计算好的colSpanrowSpan
    colspan={ column.colSpan }
    rowspan={ column.rowSpan }
    // 处理鼠标事件
    on-mousemove={ ($event) => this.handleMouseMove($event, column) }
    on-mouseout={ this.handleMouseOut }
    on-mousedown={ ($event) => this.handleMouseDown($event, column) }
    on-click={ ($event) => this.handleHeaderClick($event, column) }
    on-contextmenu={ ($event) => this.handleHeaderContextMenu($event, column) }
    // 处理th的样式
    style={ this.getHeaderCellStyle(rowIndex, cellIndex, columns, column) }
    class={ this.getHeaderCellClass(rowIndex, cellIndex, columns, column) }
    key={ column.id }>
        <div class={ ['cell', column.filteredValue && column.filteredValue.length > 0 ? 'highlight' : '', column.labelClassName] }>
            {
                column.renderHeader
                    ? column.renderHeader.call(this._renderProxy, h, { column, $index: cellIndex, store: this.store, _self: this.$parent.$vnode.context })
                    : column.label
            }
            {
                // 是否允许排序,如果允许出现排序图标和绑定事件
                column.sortable ? (<span
                    class="caret-wrapper"
                    on-click={ ($event) => this.handleSortClick($event, column) }>
                    <i class="sort-caret ascending"
                        on-click={ ($event) => this.handleSortClick($event, column, 'ascending') }>
                    </i>
                    <i class="sort-caret descending"
                        on-click={ ($event) => this.handleSortClick($event, column, 'descending') }>
                    </i>
                </span>) : ''
            }
            {
                // 是否允许过滤,如果允许出现过滤图标和绑定事件
                column.filterable ? (<span
                    class="el-table__column-filter-trigger"
                    on-click={ ($event) => this.handleFilterClick($event, column) }>
                    <i class={ ['el-icon-arrow-down', column.filterOpened ? 'el-icon-arrow-up' : ''] }></i>
                </span>) : ''
            }
        </div>
    </th>)
    }

需要注意的是这里:

column.renderHeader ? column.renderHeader.call(this._renderProxy, h, { column, $index: cellIndex, store: this.store, _self: this.$parent.$vnode.context }): column.label

renderHeader实际上是一个渲染函数,为外部传入,通过该渲染函数实现自定义表头的渲染。如果没有就直接显示label

到这里模板部分分析结束,看下其他属性:

 computed: {
    // 返回el-table实例
    table() {
      return this.$parent;
    },
    // 上面分析过,判断是否存在滚动条,存在需要进行宽度补丁
    hasGutter() {
      return !this.fixed && this.tableLayout.gutterWidth;
    },
    // 一些常用的全局变量
    ...mapStates({
      columns: 'columns',
      isAllSelected: 'isAllSelected',
      leftFixedLeafCount: 'fixedLeafColumnsLength',
      rightFixedLeafCount: 'rightFixedLeafColumnsLength',
      columnsCount: states => states.columns.length,
      leftFixedCount: states => states.fixedColumns.length,
      rightFixedCount: states => states.rightFixedColumns.length
    })
  },
methods: {
    // 判断当前单元格是否需要隐藏,根据colspan和固定的列数量计算
    isCellHidden(index, columns) {}
    // 调用外部的同名方法
    getHeaderRowStyle(rowIndex) {}
    // 调用外部的同名方法
    getHeaderRowClass(rowIndex) {}
    // 调用外部的同名方法
    getHeaderCellStyle(rowIndex, columnIndex, row, column) {}
    // 根据是否隐藏,排序等,计算出一个class列表
    getHeaderCellClass(rowIndex, columnIndex, row, column) {}
    // 切换全选
    toggleAllSelection(event) {}
    // 处理过滤器的点击事件,出现过滤器面板
    handleFilterClick(event, column) {}
    // 处理表头的点击事件,判断是sort还是filter
    handleHeaderClick(event, column) {}
    // 处理contextmenu(鼠标右键)事件,直接抛出给用户
    handleHeaderContextMenu(event, column) {}
    // 鼠标按下事件,内部主要有拖动宽度相关逻辑的处理
    handleMouseDown(event, column) {}
    // mousemove处理器,处理拖动逻辑
    handleMouseMove(event, column) {}
    // mouseout 事件,主要是失去焦点
    handleMouseOut() {}
}

handleMouseDown实现了表头的拖动,这里详细分析下:

handleMouseDown(event, column) {
    // 服务端渲染直接返回
    if (this.$isServer) return;
    // 如果是嵌套形式的表头,也不能拖动(只允许拖动子级)
    if (column.children && column.children.length > 0) return;
    /* istanbul ignore if */
    // 如果允许拖动宽度并且存在border的话
    if (this.draggingColumn && this.border) {
        // 拖动标志位
        this.dragging = true;
        // 用来控制table上的resizeProxy是否显示
        this.$parent.resizeProxyVisible = true;
        
        
        // 计算table Vue实例,dom实例,左侧偏移
        const table = this.$parent;
        const tableEl = table.$el;
        const tableLeft = tableEl.getBoundingClientRect().left;
        // 寻找当前拖动的元素
        const columnEl = this.$el.querySelector(`th.${column.id}`);
        const columnRect = columnEl.getBoundingClientRect();
        // 剩余最小宽度的计算,最少需要预留30px
        const minLeft = columnRect.left - tableLeft + 30;
        
        addClass(columnEl, 'noclick');
        
        //定义一个临时状态
        this.dragState = {
            // 起始x坐标
            startMouseLeft: event.clientX,
            // 起始元素右侧剩余量
            startLeft: columnRect.right - tableLeft,
            // 起始元素左侧剩余量
            startColumnLeft: columnRect.left - tableLeft,
            tableLeft
        };
        
        // resizeProxy为一个拖动的元素的显示,动态改变其位置
        const resizeProxy = table.$refs.resizeProxy;
        resizeProxy.style.left = this.dragState.startLeft + 'px';
        
        document.onselectstart = function() { return false; };
        document.ondragstart = function() { return false; };
    
        const handleMouseMove = (event) => {
            // deltaLeft拖动的偏移量为鼠标当前位置 - 起始位置
            const deltaLeft = event.clientX - this.dragState.startMouseLeft;
            // 需要移动的距离
            const proxyLeft = this.dragState.startLeft + deltaLeft;
            // 改变resizeProxy的位置
            resizeProxy.style.left = Math.max(minLeft, proxyLeft) + 'px';
        };
    
        const handleMouseUp = () => {
            if (this.dragging) {
                // 获取起始状态
                const {
                    startColumnLeft,
                    startLeft
                } = this.dragState;
                // 计算最终鼠标位置,转换为整型
                const finalLeft = parseInt(resizeProxy.style.left, 10);
                // 最终拖动的宽度
                const columnWidth = finalLeft - startColumnLeft;
                // 改变column的宽度
                column.width = column.realWidth = columnWidth;
                table.$emit('header-dragend', column.width, startLeft - startColumnLeft, column, event);
                // ***触发一下页面重新布局更新dom*** 【非常重要】,这里完成了对表体的重新布局
                this.store.scheduleLayout();

                document.body.style.cursor = '';
                this.dragging = false;
                this.draggingColumn = null;
                this.dragState = {};

                table.resizeProxyVisible = false;
            }
            // 移除事件绑定
            document.removeEventListener('mousemove', handleMouseMove);
            document.removeEventListener('mouseup', handleMouseUp);
            document.onselectstart = null;
            document.ondragstart = null;

          setTimeout(function() {
            removeClass(columnEl, 'noclick');
          }, 0);
        };
        
        // 绑定事件
        document.addEventListener('mousemove', handleMouseMove);
        document.addEventListener('mouseup', handleMouseUp);
      }
    },