Element-ui table

1,590 阅读5分钟

Element-ui table

Table 用于展示多条结构类似的数据

Table Attributes

参数说明类型可选值默认值
data显示的数据Array
fit列的宽度是否自撑开booleantrue
show-header是否显示表头booleantrue
row-key行数据的Key,用来优化Table的渲染Function(row) / string
empty-text空数据时显示的文本内容,也可以通过 slot="empty" 设置String暂无数据

Table-column Attributes

参数说明类型可选值默认值
label显示的标题string
prop对应列内容的字段名string
width对应列宽string

基础的表格展示用法

<template>
    <el-table
      :data="tableData"
      style="width: 100%">
      <el-table-column prop="date" label="日期" width="180"></el-table-column>
      <el-table-column prop="name" label="姓名" width="180"></el-table-column>
      <el-table-column prop="address" label="地址"></el-table-column>
    </el-table>
  </template>

Table 结构

实现思路

  • table.vue 控制表格渲染, table-column 控制列的渲染, table-header 表头渲染, table-body 表体渲染,table-layout 定义整体表格共享的属性和方法, table-observer 表格观察器(整体表格发生某些变化后的回调, 通知表头和表体做出相应的更新,保证 table-header 和 table-body 同步变化);
  • 在 table.vue 中创建表格状态管理器 store, 使组件 table-column、table-header、table-body 共享之;
  • table-column 组件将列的相关属性和方法插入到 store.states.columns, table-header 和 table-body 根据 store 进行单元格的渲染;
  • 通过设置表格的 col 的 width 属性保证表头和表体单元格同宽,无需重复设置每个单元格的 width;

目录结构

  • table.vue
  • table-column.js
  • table-header.js
  • table-body.js
  • table-layout.js
  • table-observer.js

table.vue

实现 table 的渲染

tamplate 渲染
<template>
    <!-- table组件是如何渲染子组件tabel-column的呢? 
        y-table-column职责: 收集列的属性,并将其提交到公共数据池中的columns
        y-table通过prop得到的data: 提交到公共数据池中的data
        接下来, 如何展示呢? -- 表格布局

        样式----------------------
        如何保证th和td同宽? -- 通过table下的colgroup的col宽度的设置, 这样便不用重复设置每个单元格的宽度
        给table-column添加width属性
    -->
    <div class="y-table"
        :class="[{
            'y-table__border': border
        }]">
        <!-- 原生-子节点-列的渲染 -->
        <div class="hidden-columns" ref="hiddenColumns">
            <slot></slot>
        </div>
        <!-- 表头 -->
        <div v-if="showHeader"
            class="y-table__header-wrapper"
            ref="headerWrapper">
            <table-header
                ref="tableHeader"
                :store="store"
                :style="{
                    width: bodyWidth
                }"></table-header>
        </div>
        <!-- 表体 -->
        <div
            class="y-table__body-wrapper"
            ref="bodyWrapper">
            <table-body
                :store="store"
                :style="{
                    width: bodyWidth
                }"></table-body>
        </div>
        <!-- 表格数据为空 -->
        <div
            v-if="!data || data.length === 0"
            class="y-table__empty-block"
            ref="emptyBolck">
            <span class="y-table__empty-text">
                <!-- 自定义表格为空时的数据展示 -->
                <slot name="empty">{{emptyText}}</slot>
            </span>
        </div>
        <!-- 表尾 -->
    </div>
</template>
script
<script>
import TableLayout from './table-layout';
import TableHeader from './table-header';
import TableBody from './table-body';
import {
    createStore, 
    mapStates
} from './store/helper';
import {
    addResizeListener,
    removeEventListener
} from '../../../src/utils/resize-event';

let tableIdSeed = 1;
export default {

    name: 'YTable',

    components: {
        TableHeader,
        TableBody,
    },

    props: {
        // data需为数组类型Array, 且默认值为空数组[]
        data: {
            // 指定data的数组类型, 如果传入的data不是数组类型, 则vue会报错
            type: Array,
            // 对象或数组默认值必须从一个工厂函数获取
            default: function () {
                return [];
            },
        },

        // border需为布尔类型Boolean
        border: Boolean,

        // 是否显示表头, 布尔类型, 默认为true
        showHeader: {
            type: Boolean,
            default: true
        },

        // 行数据的key,用来优化table的渲染
        rowKey: [String, Function],

        // 列的宽度是否自动撑开
        fit: {
            type: Boolean,
            default: true
        },

        /**
         * 表格数据为空时的文字
         * 空数据时显示的文本内容,也可以通过 slot="empty" 设置
         */
        emptyText: {
            type: String,
            default: '暂无数据',
        },

    },

    computed: {
        /**
         * 对象展开运算符
         * 参考:https://vuex.vuejs.org/zh/guide/state.html#mapstate-%E8%BE%85%E5%8A%A9%E5%87%BD%E6%95%B0
         * 需要安装插件:babel-plugin-transform-object-rest-spread
         * 配置.bablrc文件:{"plugins": ["transform-object-rest-spread"]}
         */
        ...mapStates({
            columns: 'columns',
            tableData: 'data',
        })
    },

    data() {
        this.store = createStore(this, {
            rowKey: this.rowKey,
        });
        // TableLayout 设置表格的相关属性
        const layout = new TableLayout({
            store: this.store,
            table: this,
            showHeader: this.showHeader,
        });
        return {
            layout,
            resizeState: {
                width: null,
                height: null,
            }
        }
    },

    methods: {
        doLayout() {
            this.layout.updateColumnsWidth();
        }
    },
    watch: {
        // 监听data
        data: {
            // 立即监听
            immediate: true,
            handler(value) {
                // 提交data到公共数据池
                this.store.commit('setData', value);
            }
        }
    },

    /**
     * 子组件: table-header和table-body
     * $children: [] 空数组
     */
    created() {
        // debugger;
        this.tableId = 'y-table_' + tableIdSeed++;
    },

    beforeMount() {
        // debugger;
    },

    /**
     * $children: [table-column组件, table-header组件, table-body组件]
     */
    mounted() {
        // debugger;
        
        // 更新列
        this.store.updateColumns();

        // 设置 col 的 width
        this.doLayout();

        this.resizeState = {
            width: this.$el.offsetWidth,
            height: this.$el.offsetHeight,
        }
        
        this.$ready = true;
    },
}
</script>

table-column.js

/**
 * table-column:table子组件,列
 * 
 * 注意: 该文件不是vue文件,而是js文件
 * 使用jsx语言渲染dom(需要安装相应的插件以支持JSX)
 * 官方文档: https://cn.vuejs.org/v2/guide/render-function.html
 * 该js是table-column组件,那么在使用该组件时,会传递过来两个属性prop和label
 * 这两个属性要如何处理才能往columns中添加该列的属性呢?
 */
import {
    mergeOptions, 
    parseWidth,
    parseMinWidth,
    compose,
} from './utils';

import {
    defaultRenderCell,
} from './config';

let columnIdSeed = 1;

export default {
    name: 'YTableColumn',
    
    // prop传值
    props: {
        // 表格数据项字段名
        prop: String,
        property: String,
        // 标题
        label: String,
        // 列宽
        width: {},
        minWidth: {},
    },

    data() {
        return {
            isSubColumn: false,
            columns: [],
        }
    },

    // 计算属性,有缓存作用
    computed: {
        // owner汉语意为主人,即父级的table组件
        owner() {
            let parent = this.$parent;
            while (parent && !parent.tableId) {
                parent = parent.$parent;
            }
            return parent;
        },
        /**
         * 该table-column的父级组件是table还是table-column
         * 如果是table-column,因该是要做单元格合处理的
         */
        columnOrTableParent() {
            let parent = this.$parent;
            while (parent && !parent.tableId && !parent.columnId) {
                parent = parent.$parent;
            }
            return parent;
        },

        // 宽度
        realWidth() {
            return parseWidth(this.width);
        },
        
        // 最小宽度
        realMinWidth() {
            return parseMinWidth(this.minWidth);
        }
    },
    
    methods: {
        // reduce 两两比较操作,数组之间的拼接
        getPropsData(...props) {
            return props.reduce((prev,cur) => {
                if(Array.isArray(cur)) {
                    cur.forEach((key) => {
                        prev[key] = this[key]; // 将数据中的key对应的值赋值
                    })
                }
                return prev;
            }, {});
        },

        // 获取child在children的位置
        getColumnElIndex(children, child) {
            // console.log('getColumnElIndex', children, child);
            return [].indexOf.call(children, child);
        },

        // 设置列宽
        setColumnWidth(column) {
            if(this.realWidth) {
                column.width = this.realWidth;
            }

            if(this.realMinWidth) {
                column.minWidth = this.realMinWidth;
            }

            if(!column.minWidth) {
                column.minWidth = 80;
            }

            column.realWidth = column.width === undefined ? column.minWidth : column.width;
            
            return column;
        },

        /**
         * 在table-column中设置列的渲染函数
         * 列的渲染是通过table-header调用的
         * @param {Object} column 
         * @returns column
         */
        setColumnRenders(column) {
            // console.log('setColumnRenders', column);
            let originRenderCell = column.renderCell;
            // console.log('setColumnRenders', originRenderCell, defaultRenderCell)

            originRenderCell = originRenderCell || defaultRenderCell;

            // 对renderCell进行包装
            column.renderCell = (h, data) => {
                let children = null;
                // console.log('renderCell', h, data, this.$scopeSlots);

                // console.log('renderCell', this.$scopedSlots, this)

                // 如果该列存在默认插槽, 则渲染默认插槽, 这样的话便使得每列的自定义渲染方式整理到table-body的每列上
                // 充分说明, table-body中单元格的渲染需要单独分离出来, 调用每列的渲染函数, 这样便使table-column中每列的自定义同步到table-body下
                // 否则只展示该列对应的数据值
                if(this.$scopedSlots.default) {
                    // vm.$scopedSlots 范文作用域插槽, 并传入数据
                    children = this.$scopedSlots.default(data);
                }else {
                    children = originRenderCell(h, data);
                }

                const props = {
                    class: 'cell',
                    style: {},
                };

                return (
                    <div {...props}>
                        {children}
                    </div>
                )
            }

            return column;
        },
    },

    // 赋初值
    beforeCreate() {
        // debugger;
        this.column = {};
        this.columnId = '';
    },

    // 赋初值
    created() {
        // debugger;
        
        // 定义table-column组件的columnId
        const parent = this.columnOrTableParent;
        // 是否是子列, 如果两者不相等,则是子列(直接父组件也是table-column)
        this.isSubColumn = this.owner !== parent;
        this.columnId = (parent.tableId || parent.columnId) + '_column_' + columnIdSeed++;

        // 定义默认属性,暂时只有id
        const defaults = {
            id: this.columnId,
            // 对应列内容的字段名, 也可以使用 property 属性
            property: this.prop || this.property,
        }

        // 基础属性
        // const basicProps = ['label', 'prop',];
        const basicProps = ['label',];
        
        // 收集column属性
        let column = this.getPropsData(basicProps);
        column = mergeOptions(defaults, column);

        // 注意compose(组成,构成)执行顺序是从右到左,现在chains就相当于一个函数
        // const chains = compose(this.setColumnRenders, this.setColumnWidth, this.setColumnForcedProps);
        const chains = compose(this.setColumnRenders, this.setColumnWidth);
        column = chains(column);

        this.columnConfig = column;
    },
    
    mounted() {
        // debugger;
        const owner = this.owner;
        const parent = this.columnOrTableParent;

        // vm.$children 当前实例的直接子组件(虚拟节点)
        // vm.$el.children 当前dom节点的直接子节点(真实dom节点)
        // 如果直父组件是table-column
        const children = this.isSubColumn ? parent.$el.children : parent.$refs.hiddenColumns.children;
        
        const columnIndex = this.getColumnElIndex(children, this.$el);
        // 甚为关键,将该列的属性存入到table的公共池中
        // 父组件table的store
        owner.store.commit('insertColumn', this.columnConfig, columnIndex, this.isSubColumn ? parent.columnConfig : null);
    },

    // render渲染
    render(h) {
        console.log('table-column', h,  this.$slots);
        return h('div', this.$slots.default);
    }
}

[注意]:

  • 关键代码:将该列的属性存入到table的公共池中
owner.store.commit('insertColumn', this.columnConfig, columnIndex, this.isSubColumn ? parent.columnConfig : null);
  • render 渲染函数
render(h) {
    return h('div', this.$slots.default);
}

table-header.js

JSX 渲染

render(h) {
    let columnRows = convertToRows(this.columns);
    return (
        <table
             class="y-table__header"
             cellspacing="0"
             cellpadding="0"
             border="0">
             <colgroup>
                {
                  this.columns.map(column => <col name={column.id} key={column.id} />)
                }
             </colgroup>
             <thead>
                 <tr>
                    {
                        this._l(columnRows, (column, cellIndex) => (
                           <th
                              key={cellIndex}
                              colspan={column.colspan}
                              rowspan={column.rowspan}>
                              <div
                                 class="cell">
                                 {column.label}
                              </div>
                            </th>
                        ))
                    }
                 </tr>
            </thead>
       </table>
    )
}

[注意]:

  • this._l 是一个渲染列表的函数renderList

table-body.js

JSX 渲染

render(h) {
    const data = this.data || [];
    const columns = this.columns || [];
    return (
        <table
            class="y-table__body"
            cellspacing="0"
            cellpadding="0"
            border="0">
            <colgroup>
                {
                    columns.map(column => <col name={column.id} key={column.id} />)
                }
            </colgroup>
            <tbody>
                {
                    data.reduce((acc, row) => {
                       return acc.concat(this.wrappedRowRender(row, acc.length))
                    }, [])
                }
            </tbody>
       </table>
    )
}

[注意]

  • JSX 需要插件的支持
  • 官方文档:cn.vuejs.org/v2/guide/re…
  • npm install @vue/babel-preset-jsx @vue/babel-helper-vue-jsx-merge-props
  • 配置.babelrc: {"plugins": ["transform-object-rest-spread","transform-vue-jsx"]}

table-layout.js

import Vue from 'vue';

/**
 * TableLayout 定义表格的整体相关属性和方法
 * 用于table.vue整体控制table
 */

class TableLayout {
    // 构造器
    constructor(options) {
        console.log('tableLayout', options);
        this.table = null;
        this.store = null;
        this.showHeader = true;

        this.fit = true; // 何意?

        this.bodyWidth = null;

        this.observers = [];

        // 赋值
        for(let name in options) {
            if(options.hasOwnProperty(name)) {
                this[name] = options[name];
            }
        }

        if(!this.table) {
            throw new Error('table is required for Table Layout');
        }

        if(!this.store) {
            throw new Error('store is required for Table Layout');
        }
    }

    /**
     * 公共的表格处理方法
     * 如: setHeight设置高度等等
     */

    /**
     * Vue.prototype.$isServer 是什么意思?
     * Vue实例是否运行与服务器上, 属性值为true标识实例运行于服务器, 每个Vue实例都可以通过该属性判断。该属性一般用于服务器渲染, 用以区分代码是否在服务器上运行。
     * 
     * 动态计算宽度(没有分配width的列)
     * 如果这样的列只有一个, 则把剩余的宽度都分给它
     * 如果这样的列有多个, 则按照最小宽度的比例分配剩余的宽度(而非平均分配)
     * 
     * 更新columns的realWidth值
     * 更新之后, 表头和表尾dom都需要相应的发生变化
     */
    updateColumnsWidth() {

        // console.log('updateColumnsWidth', this.table.columns, Vue.prototype.$isServer);
        // console.log('updateColumnsWidth', this.table.$el);

        // 如果在服务器上运行,则返回
        if(Vue.prototype.$isServer) return;

        const fit = this.fit;
        
        const bodyWidth = this.table.$el.clientWidth;
        let bodyMinWidth = 0;

        const flattenColumns = this.table.columns;
        // filter不改变原数组,且返回数组的指针指向原数组相对应的数组项的地址
        // 所以改变返回的数组值, 原数组值也会发生相应的改变
        let flexColumns = flattenColumns.filter((column) => typeof column.width !== 'number');

        // 如果width存在且realWidth也存在, 则设置realWdith为null
        flattenColumns.forEach((column) => {
            if(typeof column.width === 'number' && column.realWidth) column.realWidth = null;
        })

        if(flexColumns.length > 0 && fit) {
            flattenColumns.forEach((column) => {
                bodyMinWidth += column.width || column.minWidth || 80;
            })

            const scrollYWidth = 0;

            // 如果没有滚动条
            // 通过bodyMinWidth来判断
            // console.log('999', bodyMinWidth <= bodyWidth - scrollYWidth)
            if(bodyMinWidth <= bodyWidth - scrollYWidth) {

                // 没有分配宽度的列的总宽
                const totalFlexWidth = bodyWidth - scrollYWidth - bodyMinWidth;

                if(flexColumns.length === 1) {
                    flexColumns[0].realWidth = (flexColumns[0].minWidth || 80) + totalFlexWidth;
                }else {
                    // 计算所有没有宽度的列的最小宽度之和
                    const allColumnsWidth = flexColumns.reduce((prev, column) => prev + (column.minWidth || 80), 0);
                    // console.log('allColumnsWidth', flexColumns, allColumnsWidth)
                    
                    // 计算倍数
                    const flexWidthPerPixel = totalFlexWidth / allColumnsWidth;
                    // console.log('3333', flexWidthPerPixel, allColumnsWidth, totalFlexWidth);

                    // 非第一个列的宽度
                    let noneFirstWidth = 0;

                    flexColumns.forEach((column, index) => {
                        if(index === 0) return;
                        // console.log('7', column, index)

                        // 取不大于该值的整数,这样会把多余的宽度都分配到第一列上
                        const flexWidth = Math.floor((column.minWidth || 80) * flexWidthPerPixel);
                        // console.log('7', flexWidth, flexWidthPerPixel)
                    
                        noneFirstWidth += flexWidth;
                        // console.log('7', noneFirstWidth)
                        
                        column.realWidth = (column.minWidth || 80) + flexWidth;
                    })

                    // 计算第一个的realWidth
                    flexColumns[0].realWidth = (flexColumns[0].minWidth || 80) + totalFlexWidth - noneFirstWidth;
                }

            }

            this.bodyWidth = Math.max(bodyMinWidth, bodyWidth);
            this.table.resizeState.width = this.bodyWidth;

            // console.log('999', this.table.columns)
            // console.log('999', bodyWidth, scrollYWidth, bodyMinWidth, bodyWidth - scrollYWidth - bodyMinWidth)
        } else {
            // 有水平滚动条
        }
    }
}

export default TableLayout; 

table-observer.js

/**
 * 混入布局观察器
 * LayoutObserver布局观察器
 * 用于表格布局组件: table-header 和 table-body
 */
export default {

    computed: {
        // 表格布局属性
        tableLayout() {
            let layout = this.layout;
            // console.log('tableLayout', layout);

            if(!layout && this.table) {
                layout = this.table.layout;
            }

            // 如果还是没有则抛出错误
            if(!layout) {
                throw new Error('Can not find table layout.');
            }

            return layout;
        }
    },

    methods: {
        /**
         * 设置colgroup中的col的width属性
         * 通过控制colgroup中的col的宽度来绑定table单元格的宽度
         * 通过使用colgroup标签, 向整个列应用样式, 而不需要重复为每个单元格或每一行设置样式
         * col属性:
         * - width:规定单元格的宽度
         * th/td属性高:
         * - colspan:规定单元格可横跨的列数
         * - rowspan:规定单元格可横跨的行数
         * 
         * vue:获取当前组件所在的真实dom this.$el
         * js:获取dom节点方式之一
         * js:获取匹配指定css选择器的所有元素 querySelectorAll (querySelector获取匹配到的第一个元素)
         * js:获取dom属性 getAttribute
         * js:设置dom属性 setAttribute
         * @param {Object} layout 
         * @returns undefined
         */
        onColumnsChange(layout) {
            // console.log('layout', layout);

            // 获取真实dom, 通过querySelectorAll筛选
            // querySelectorAll方法返回文档中匹配指定 CSS 选择器的所有元素,返回 NodeList 对象
            const cols = this.$el.querySelectorAll('colgroup > col');
            // console.log('77', cols, this.columns);
            
            const flattenColumns = this.columns;
            const columnsMap = {};

            flattenColumns.forEach((column) => {
                columnsMap[column.id] = column;
            })
            // console.log('flatcolumnsMaptenColumns', columnsMap)
            
            if(!cols.length) return;

            for(let i = 0, j = cols.length; i < j; i++) {
                const col = cols[i];
                const name = col.getAttribute('name');
                const column = columnsMap[name];
                // console.log(i, col, name, columnsMap, column);
                
                if(column) {
                    col.setAttribute('width', column.realWidth || column.width);
                }
            }
        }
    },
    mounted() {
        this.onColumnsChange(this.tableLayout);
    },
    
    updated() {
        // console.log('模块更新', this.$el);
        this.onColumnsChange(this.tableLayout);
    },
}

总结

实现思路

  • table.vue 控制表格渲染, table-column 控制列的渲染, table-header 表头渲染, table-body 表体渲染,table-layout 定义整体表格共享的属性和方法, table-observer 表格观察器(整体表格发生某些变化后的回调, 通知表头和表体做出相应的更新,保证 table-header 和 table-body 同步变化);
  • 在 table.vue 中创建表格状态管理器 store, 使组件 table-column、table-header、table-body 共享之;
  • table-column 组件将列的相关属性和方法插入到 store.states.columns, table-header 和 table-body 根据 store 进行单元格的渲染;
  • 通过设置表格的 col 的 width 属性保证表头和表体单元格同宽,无需重复设置每个单元格的 width;

目录结构

  • table.vue
  • table-column.js
  • table-header.js
  • table-body.js
  • table-layout.js
  • table-observer.js