Element-UI Table组件用户自定义配置方案分享-自定义列宽、排序、显隐

Posted by Yinode on Thursday, July 16, 2020

TOC

起因

最近有接受到这样的一个需求

  • 用户够能自定义表格列的 宽度/排序/显隐
  • 用户的设置能够持久化(保存到后端)

目前该项目主要由 Vue.js 2.0 + Element-UI 构成,在经过一些实际的考虑之后,我定义了如下技术上的目标

  • 不希望过强的侵入式,避免过高的改造成本
  • 改造后继续遵循Element-UI Table组件的使用方法
  • 使用起来应该尽可能的简单

方案选择

在思考过后,我选择了对Element-UI Table组件进行二次开发,扩展其中的方法,并发布到公司私有NPM仓库的方法。

并且定义了 Table 实例上的三个方法,以及传递的数据格式

const config = {
  id:'' // table id 标识符 app内全局唯一
  columnList:[
    {
      prop: '', // 属性key
      sortIndex: '' // 用户主动配置的排序索引
      width:'', // 用户主动配置的宽度
      isHidden: true, // 用户主动配置的是否显示 需要做过滤
      isFixed: false // 该列是否为固定列 只读
    }
  ]
}

_addHeaderWidthChangeHandler(callback) // 表格宽度变动回调

_readConfig(config) // 读取表格配置

_loadConfig():config // 更新表格配置

具体扩展

首先Element Table组件本身是一个相当的庞大的组件,这意味着你想要通过传入的配置来动态更改整个表格的实际展示是一件相当复杂的事情。为了降低修改成本,我这里采用了一个取巧的方式。

首先在 Element-UItable-column 会在自己挂载完成之后,把自己的各种属性通过$parent属性 传递给 table 组件.

// table-column
export default {
  computed: {
    owner() {
      let parent = this.$parent;
      while (parent && !parent.tableId) {
        parent = parent.$parent;
      }
      return parent;
  },
  mounted(){
     owner.store.commit('insertColumn', this.columnConfig, columnIndex, this.isSubColumn ? parent.columnConfig : null);
  }
}

接下我们在来看看 table.store.commit 方法

  insertColumn(states, column, index, parent) {
    let array = states._columns;
    if (parent) {
      array = parent.children;
      if (!array) array = parent.children = [];
    }

    if (typeof index !== 'undefined') {
      array.splice(index, 0, column);
    } else {
      array.push(column);
    }

    if (column.type === 'selection') {
      states.selectable = column.selectable;
      states.reserveSelection = column.reserveSelection;
    }

    if (this.table.$ready) {
      this.updateColumns(); // hack for dynamics insert column
      this.scheduleLayout();
    }
  }

我们可以看到,所有的列配置都会放到_column数组中,所以这个数组本质上就是最原始的数据,当然还有很多将这个数组通过里面的属性进行二次拆分的过程,比如固定列等逻辑,但是这些我们无需关心,我这次就选择对这个属性进行动态。

我的基本思路是利用 Object.definePropertiesstore.states._columns 属性进行劫持,所有的get都会经过我的逻辑,进行重排序,属性重写等功能。

我会在table组件的mounted周期调用我的劫持初始化方法

export default {
  methods:{
    // 劫持store.state.columns 做到过滤 等功能
    initHijackColumns() {
      this.store.states.__col = this.store.states.columns;
      let that = this;
      Object.defineProperty(this.store.states, '_columns', {
        get: () => {
          const config = that._tableConfig;
          let columns = that.store.states.__col;

          // 对table数据源按照外部配置进行清洗
          if (config && config.columnList.length > 0) {
            columns = that._columnFilterByConfig(columns, config.columnList);
            columns = that._columnReSortByConfig(columns, config.columnList);
            columns = that._columnWidthByConfig(columns, config.columnList);
          }

          return columns;
        },
        set: (value) => {
          this.store.states.__col = value;
        }
      });

      this.store.updateColumns();
      this.store.scheduleLayout();
    },

    _addHeaderWidthChangeHandler(callback) {
      // table-header > header-dragend 触发后会调用该回调
      this._onHeaderWidthChange = callback;
    },

    _onHeaderWidthChange() {
      // noop
    },

    // 根据配置对列进行隐藏过滤
    _columnFilterByConfig(arr, configList) {
      return arr.filter((v) => {
        let matchConfig = this._findMatchColumn(configList, v);

        if (matchConfig && matchConfig.isHidden === true) {
          return false;
        } else {
          return true;
        }
      });
    },

    // 根据配置对列进行重排序
    _columnReSortByConfig(arr, configList) {
      return arr.sort((a, b) => {
        const aMatchConfig = this._findMatchColumn(configList, a);
        const bMatchConfig = this._findMatchColumn(configList, b);

        if (aMatchConfig && bMatchConfig) { // 双匹配 走配置排序
          return aMatchConfig.sortIndex - bMatchConfig.sortIndex;
        } else if (aMatchConfig && !bMatchConfig) { // 单匹配 有匹配的在前面
          return -1;
        } else if (!aMatchConfig && bMatchConfig) { // 单匹配 有匹配的在前面
          return 1;
        } else { // 无匹配
          return -1;
        }
      });
    },

    // 根据配置对列进行宽度重设
    _columnWidthByConfig(arr, configList) {
      return arr.map(v => {
        let matchConfig = this._findMatchColumn(configList, v);

        if (matchConfig && matchConfig.width > 0) {
          return {
            ...v,
            width: matchConfig.width
          };
        } else {
          return v;
        }
      });
    },

    // a {element column data}
    // b {custom column config data}
    _isEqColumn(a, b) {
      return a.property === b.prop && a.label === b.label;
    },

    _findMatchColumn(arr, target) {
      for (let i = 0, len = arr.length; i < len; i++) {
        let cur = arr[i];
        if (this._isEqColumn(target, cur)) {
          return cur;
        }
      }
      return null;
    },

    // 读取配置 分状态读取
    _readConfig() {
      return this.store.states.columns.map((col, index) => {
        return {
          label: col.label,
          prop: col.property,
          sortIndex: index,
          width: col.width,
          isHidden: false,
          isFixed: col.fixed
        };
      });
    },

    // 外部修改配置后 调用该方法 重新渲染表单
    _loadConfig(newConfig) {
      this._tableConfig = newConfig;
      this.store.updateColumns();
      this.store.scheduleLayout();
    }
  },
  mounted(){
    // ...
    this.initHijackColumns();
  }
}

如此一来我们可以在项目使用该组件的时候,保持原来的结构,减少其侵入性,但是为了灵活我依然没有选择把配置表格相关的功能移入到table组件中。最终的调用形态大概是这样

      <el-table id="carrierPool_listTable" stripe border :data="carrierList" height="1" ref="table">
        <el-table-column label="操作" width="80" fixed="left">
          <template slot-scope="scope">
            <el-button type="text" @click="handleClickReceiving(scope.row)">领取</el-button>
          </template>
        </el-table-column>
        <el-table-column label="账户" prop="account" width="120"></el-table-column>
        <!-- 一些列 ... -->
        <template slot="empty">暂无相关数据</template>
      </el-table>
      <div class="d-flex ai-end jc-end pt-10">
        <paging
          ref="paging"
          :total="total"
          @changePage="handleChangePageOrRefresh"
          @refreshData="handleChangePageOrRefresh"
        ></paging>
        <!-- 通过table 进行连接 -->
        <table-setting tableId="carrierPool_listTable"/> 
      </div>

table-setting 组件内部会通过 table的 id 查找找对应table组件实例

initTableInstance () {
  this.table = vueExt.findComponentDownward(this.$parent, 'ElTable', (com => {
    const id = com.$el.getAttribute('id')
    if (id === this.tableId) {
      return true
    } else {
      return false
    }
  })
}

至于具体的配置组件代码我就不贴了。

由于 table 是一个全局唯一的东西,所以建议上一个工具链,在代码提交的时候,进行全局搜索检查,如果出现重复直接拒绝提交