Element Plus 虚拟化表格组件的使用(排序、筛选、自定义单元格渲染、多级表头) - 个人使用总结

07-19 800阅读

前言

element-plus@2.2.0 后提供虚拟化表格组件,解决表格数据过大导致的卡顿等性能问题。相对于表格组件,用法上区别还是挺大的,尤其是一些附加的功能,例如排序、筛选、自定义单元格/表头渲染等等。

Element Plus 虚拟化表格组件的使用(排序、筛选、自定义单元格渲染、多级表头) - 个人使用总结
(图片来源网络,侵删)

本文参照官网文档、示例,结合个人使用总结,演示虚拟化表格的基本使用,记录上述附加功能的基本实现。除组件的相关接口需要按照官网规范使用外,示例中的其它具体实现的方法仅作参考,提供使用思路。

创建了一个项目收纳本文的一些demos:

Git Page 演示

element-plus-tablev2-demo

element-plus-tablev2-demo (gitee)


一、Element Plus 表格基础

官方介绍:

“在前端开发领域,表格一直都是一个高频出现的组件,尤其是在中后台和数据分析场景。 但是,对于 Table V1来说,当一屏里超过 1000 条数据记录时,就会出现卡顿等性能问题,体验不是很好。

通过虚拟化表格组件,超大数据渲染将不再是一个头疼的问题。”

官方提示:

TIP

该组件仍在测试中,生产环境使用可能有风险。 若您发现了 bug 或问题,请于 GitHub 报告给我们以便修复。 同时,有一些 API 并未在此文档中提及,因为部分还没有开发完全,因此我们不在此提及。

即使虚拟化的表格是高效的,但是当数据负载过大时,网络和内存容量也会成为您应用程序的瓶颈。 因此请牢记,虚拟化表格永远不是最完美的解决方案,请考虑数据分页、过滤器等优化方案。

TIP

在 SSR 场景下,您需要将组件包裹在 之中 (如: Nuxt) 和 SSG (例如: VitePress).

属性

详见官网,这里只说几点需要注意的地方

  • 表格属性: width, height 必填(可使用 AutoResizer 组件使表格自动调整大小,使用方式参照官网)
  • 表格属性 columns 为列 column 的配置数组,这是与表格组件最大的差异之一
  • column 的配置中,可定义很多之前定义在 column 模板中的属性
  • column 的配置属性中,cellRenderer 自定义单元格渲染是最大的差异(模板 ----> js)

    简单使用

    表格组件 el-table (TableV1):

    const columns = [
    	{ prop: 'name', label: 'Name', width: 100 },
    	{ prop: 'age', label: 'Age', width: 100 },
    	{ prop: 'gender', label: 'Gender', width: 100 },
    	{ prop: 'tel', label: 'Tel', width: 100 }
    ]
    const tableData = [
    	{ name: '', age: '', gender: '', tel: '' },
    	// ...
    ]
    
    
      
      	
      
    
    

    虚拟化表格组件 el-table-v2 (TableV2):

    const columns = [
    	{ key: 'name', dataKey: 'name', title: 'Name', width: 100 },
    	{ key: 'age', dataKey: 'age', title: 'Age', width: 100 },
    	{ key: 'gender', dataKey: 'gender', title: 'Gender', width: 100 },
    	{ key: 'tel', dataKey: 'tel', title: 'Tel', width: 100 }
    ]
    const tableData = [
    	{ name: '', age: '', gender: '', tel: '' },
    	// ...
    ]
    
    
      
    
    

    后续的示例基于 element-plus@2.2.17


    二、自定义单元格渲染

    jsx/tsx 或 vue 渲染函数

    注意,Element Plus 的虚拟化表格组件(TableV2)提供的自定义单元格、表头单元格渲染器都要求返回 VNode。需要使用 jsx/tsx 或者 vue 渲染函数 实现。如无需使用上述两个单元格渲染器,仅作基本数据展示、排序等基本功能的话,可以像 TableV1 一样直接在 vue 单文件组件内使用。

    准备工作

    本文采用 jsx 实现, Vue CLI 创建的项目可直接在vue单文件组件的 script 标签中添加 lang=“jsx” ()

    “create-vue 和 Vue CLI 都有预置的 JSX 语法支持。如果你想手动配置 JSX,请参阅 @vue/babel-plugin-jsx 文档获取更多细节。”

    • jsx 用法可参考:

      Vue 3 Babel JSX 插件

      vue官网 - 渲染函数 & JSX - JSX / TSX

      element-plus虚拟化表格组件el-table-v2渲染自定义组件的其中两种方式(js和jsx)及注意事项

      在Vue中使用JSX,很easy的

      需掌握最基本的 插值、v-if、v-for、v-on、事件修饰符、组件的 jsx 语法,以及组件的插槽语法

      • Element Plus官方文档:

        Element Plus - Virtualized Table 虚拟化表格

        需了解该组件的常用属性方法、Column属性

        本节的重点是单元格自定义渲染,在于 cellRenderer 方法,其参数类型如下:

        type CellRenderProps = {
          cellData: T
          column: Column
          columns: Column[]
          columnIndex: number
          rowData: any
          rowIndex: number
        }
        

        分别为 单元格值、项、所有项、项下标、行数据、行下标

        渲染方式对比(el-table vs el-table-v2)

        el-table 中常用的自定义单元格渲染方式(定义在表格 column 模板中):

          
        		
        			
        			{{ scope.row[col.prop] }}
        			
        				{{ scope.row[col.prop] }}
        		        {{ scope.row[col.prop] }}
        			
        			{{ scope.row[col.prop] }}
        		
          
        
        

        el-table-v2 中常用的自定义单元格渲染方式(定义在 column 配置列表中):

        vue单文件组件需要在script中加上lang="jsx"

        const columns = [
        	{
        		key: 'link',
        		title: 'Link',
        		dataKey: 'link',
        		width: 100,
        		cellRenderer: ({ cellData, rowData }) => (
        			 rowData.link } target="_blank"Go
        		)
        	},
        	// ...
        ]
        
        

        实例列举

        为了对照,会分别放上 el-table 与 el-table-v2 的自定义单元格渲染代码

        为了精简代码,下述 el-table-v2 的示例均只展示 cellRenderer 函数

        • 组件
            {{ scope.row[col.prop] || '-' }}
            {{ scope.row[col.prop] || '-' }}
          
          
          const cellRenderer = ({ cellData, rowData: row }) => {
            const tmp = row.id && row.view && (row.view?.includes($store.getters.userId) || $checkRolePermission(row.view))
            return tmp
              ?  { name: 'Target', params: { tid: row.id } } }
                class="gene-text"
              { cellData ?? '-' }
              : { cellData ?? '-' }
          }
          

          包含了插值、v-if、组件。全局注册的组件可直接在 jsx 中使用

          • v-for
              
                {{ tag }}
              
            
            
            const cellRenderer = ({ cellData, rowData: row }) => {
              return {
                cellData?.map(tag => (
                   { name: 'TargetAnalysis', params: { tid: row.id, type: tag } } }
                  
                    { tag }
                  
                )) ?? ''
              }
            }
            

            包含 v-for、组件、空标签。若并未全局引入Element Plus,需手动引入相关组件,其它自定义组件同样如此。


            三、表头分组

            此前的对官网的表头分组功能理解有误,官网是通过表格属性 header-height 来设置多级表头,在新的表头上创建分组

            .

            属性:header-height

            描述说明:Header 的高度由height设置。 如果传入数组,它会使 header row 等于数组长度

            类型:Number/Array

            默认值:50

            .

            下面的原demo倒也能实现分组,而且无需通过设置多一级表头来实现

            再说一遍,官网的表头分组demo是真的sao

            下面的两种分组实现均在Git Page 演示中可见

            官方文档在 TableV2 上给出的说法是“表头分组”,效果同 TableV1 不大一样。

            V1是通过 嵌套实现,意义明确、实现简单,创建一个嵌套的 columns 列表就可以。

            V2的实现是通过表格组件提供的 header 插槽

            const CustomizedHeader  = ({ cells, columns, headerIndex }) => {
            	return cells
            }
            
            
              
                
                  
                
              
            
            

            类型:

            type HeaderSlotProps = {
              cells: VNode[]
              columns: Column[]
              headerIndex: number
            }
            

            Demo(多一级表头实现表头分组)

            官网demo通过新增一级表头来实现分组,CustomizedHeader 组件的生成过程非常sao,对理解造成了很大干扰。官方已经把默认生成的表头 VNode 列表提供给我们了,我们自行处理,返回一个VNode列表即可,需要实现表头分级,并保持对齐。

            官网的表头分组是根据序号进行的,实际上不实用,这里通过添加一个分组标识来实现

            columns 中添加 _group 属性,标识所属分组,CustomizedHeader组件中遍历一遍表头VNode,分组相同的连续表头为一个分组

            demo:

            el-table-v2: :header-height="[36, 50]" (两级表头,高度分别为36px, 50px)

              
                
              
            
            
            const columns = [
              { key: 'no', dataKey: 'no', title: 'No.', width: 60 },
              { key: 'code', dataKey: 'code', title: 'code', width: 80, _group: 'Group 1' },
              { key: 'name', dataKey: 'name', title: 'name', width: 80, _group: 'Group 1' },
              { key: 'age', dataKey: 'age', title: 'Age', width: 60, _group: 'Group 2' },
              { key: 'gender', dataKey: 'gender', title: 'gender', width: 80, _group: 'Group 2' },
              { key: 'city', dataKey: 'city', title: 'City', width: 80 },
            ]
            const CustomizedHeader = ({ cells, columns, headerIndex }) => {
              if (headerIndex === 1) return cells
              const groupCells = []
              let currGroupCell = []
              for (let i = 0, len = columns.length; i  prev + curr.props.column.width, 0)
                  groupCells.push(
                    { width: `${width}px` }}
                      {columns[i]._group ?? ''}
                    
                  )
                  currGroupCell = []
                }
              }
              return groupCells
            }
            

            注意,在 headerIndex 为1时直接返回了cells,这是因为 :header-height="[36, 50]" 对应了两个表头(CustomizedHeader组件),第二个表头不作处理,第一个为分组表头。

            Demo(不增加一级表头)

            CustomizedHeader 组件属性中提供了原表头VNode列表,你可以以极大自由度地操控这些VNode来改变表头,上面的demo只是官网提供的一种。

            同样的 columns,表格无需通过header-height增加一级表头

              
                
              
            
            
            const CustomizedHeader = ({ cells, columns, headerIndex }) => {
              const groupCells = []
              let currGroupCell = []
              for (let i = 0, len = columns.length; i  prev + curr.props.column.width, 0)
                  groupCells.push(
                    currGroupCell.length > 1 ? (
                      { width: `${width}px` }}
                        
            {columns[i]._group ?? ''}
            {currGroupCell}
            ) : ( currGroupCell[0] ) ) currGroupCell = [] } } return groupCells }

            上例中,CustomizedHeader组件中对VNode的处理同上一个demo很类似。但实现了一种“合并单元格”的效果。除了宽度必须为原来两个单元格的宽度之和以保证对齐外,该元素的内容及样式均可自行设置。


            四、排序

            介绍

            TableV1 组件排序的实现过程:

            设置 el-table-column 的 sortable 属性为 true 即可。

            多个排序间相互独立

            TableV2 排序的实现在我看来是“自由度很高”的,除了根据单项排序表格外,它还提供了一种叫“受控排序”的东西(可以实现多重排序):

            首先,排序值只有两种,升/降序。清空排序需要手动清空记录排序状态的变量;

            其次,组件提供了排序监听事件(@column-sort),但具体的排序方法需自行定义;

            再次,不同于 TableV1 同时只进行一项排序,TableV2 允许多重排序。它可以记录所有可排序项的排序状态,但如何实现多重排序需要你自己在监听事件中实现。(自由度很高,一方面需要手动实现多重排序方法,另一方面需要通过管理排序状态变量控制表头UI上的三种状态: 升/降/无。为了避免UI上的疑惑,这两方面需要协调一致)

            关键属性、事件、方法说明

            先放上从官网上粘过来的相关的属性、事件、方法说明,方便对照后续示例参考

            • TableV2属性
              属性名描述说明类型默认值
              sort-by排序方式Object{}
              sort-state多个排序Objectundefined
              • TableV2事件
                事件名描述参数
                column-sort列排序时调用Object

                Column属性

                属性名描述类型默认值
                sortable设置列是否可排序Boolean-
                • 相关类型
                  type KeyType = string | number | symbol
                  type ColumnSortParam = { column: Column; key: KeyType; order: SortOrder }
                  enum SortOrder {
                    ASC = 'asc',
                    DESC = 'desc',
                  }
                  type SortBy = { key: KeyType; Order: SortOrder }
                  type SortState = Record
                  

                  使用示例(属性、事件、方法)

                  示例只保留了最基本的部分,方便理解如何使用。第三小节提供了完整 demo codepen 链接,可在线调试

                  单项排序

                  一般想要的就是表格若干项可以排序,但只进行单项排序

                   
                  
                  // 自行编写排序事件处理方法
                  const handleSort = () => {}
                  // 记录排序状态, key: 排序项的key, order: 升/降序
                  const sortState = ref({ key: "no", order: 'asc' });
                  // 监听排序事件
                  const onSort = ({ key, order }) => {
                    handleSort()
                    sortState.value = { key, order };
                  };
                  

                  多重排序

                  举个例子,有个人员表,希望按城市排序,同一城市的按性别排序,同一性别的按年龄排序

                   
                  
                  // 事件处理方法:自行根据 sortState 实现多重排序
                  const handleSort = () => {}
                  // 以键值对形式记录排序状态
                  const sortState = ref({
                    city: 'desc',
                    gender: 'asc',
                    age: 'asc'
                  });
                  // 监听排序事件
                  const onSort = ({ key, order }) => {
                    handleSort()
                    sortState.value[key] = order;
                  };
                  

                  在线演示

                  el-table-v2 单项排序 demo

                  el-table-v2 多重排序 demo


                  五、筛选/过滤器

                  介绍

                  类似于自定义单元格渲染,实现筛选需要通过自定义表头单元格渲染实现

                  官方示例是在可筛选的表头单元格中添加显示为筛选图标的弹出框(el-popover 组件),弹出框内显示可筛选选项,选项列表需要自行计算好。筛选的执行也需要自行监听实现,和排序一样,自由度非常高~

                  自定义表头单元格渲染:

                  const columns = [
                  	{
                  		// key, dataKey, title, ...
                  		headerCellRenderer: (props) => {
                  			return props.column.title
                  		}
                  	},
                    // ...
                  ]
                  

                  Column属性

                  属性名描述类型默认值
                  headerCellRenderer自定义头部渲染器VueComponent/(props: HeaderRenderProps) => VNode-

                  类型

                  type HeaderRenderProps = {
                    column: Column
                    columns: Column[]
                    columnIndex: number
                    headerIndex: number
                  }
                  

                  通过在 headerCellRenderer 方法中返回一个的 VNode 实现自定义表头单元格渲染

                  使用示例

                  筛选/过滤器的高自由度决定了它的具体实现方式因人而异,以下示例仅作参考。同第二节一样,示例使用的是 jsx

                  实现过程

                  首先,需要标识哪些 column 需要添加筛选功能,延续个人在TableV1中的使用习惯,在 columns 数组中添加相关属性,filterable 标识该项是否可筛选, filterMethod 指定筛选方法

                  import { generalArrFilterHandler } from '@/use/el-table-v2-utils'
                  const columnData = ref([
                    { key: "no", dataKey: "no", title: "No.", width: 60 },
                    { key: "code", dataKey: "code", title: "code", width: 80 },
                    { key: "name", dataKey: "name", title: "name", width: 80 },
                    { key: "age", dataKey: "age", title: "Age", width: 60 },
                    { key: "gender", dataKey: "gender", title: "gender", width: 80, filterable: true },
                    { key: "city", dataKey: "city", title: "City", width: 80, filterable: true },
                    { key: "tags", dataKey: "tags", title: "Tags", width: 150, filterable: true, filterMethod: generalArrFilterHandler }
                  ]);
                  

                  为了避免对 TableV2 的潜在影响,表格组件所使用的 columns 数组中过滤掉一些不必要的属性,headerCellRenderer 方法也需要定义在此。

                  示例中,定义了一个弹出框,点击筛选图标显示弹出框,弹出框内是一个多选框组,确定后进行筛选

                  const columns = columnData.value.map(col => {
                  	return {
                  		key: col.dataKey,
                  	    title: col.title,
                  	    dataKey: col.dataKey,
                  	    width: col.width ?? 100,
                  	    headerCellRenderer: (props) => {
                  	      if(!col.filterable) return props.column.title
                  	      return 
                  { props.column.title } ...{ width: 200 }} {{ default: () => (
                  filterableCols[col.dataKey].selected } { filterableCols[col.dataKey].list.map(f => f.value } label={ f.value }{ f.text }) }
                  onFilter }Confirm () = onReset(col.dataKey) }>Reset
                  ), reference: () => ( ) }}
                  } } })

                  别忘了,筛选项可筛选列表也需要自行计算出来。 filterableCols 是用来存储可筛选项相关信息的,示例中,定义了默认的筛选方法

                  import { generalFilterHandler } from '@/use/el-table-v2-utils'
                  const originData = ref([]);
                  const tableData = ref([]);
                  /**
                   * 筛选信息列表
                   * props:
                   * - {Array} list 可筛选值列表
                   * - {Array} selected 已勾选列表
                   * - {Function} [filterMethod] 筛选方法
                   */
                  const filterableCols = reactive(
                    columnData.value
                      .filter(c => c.filterable)
                      .reduce((prev,curr) => {
                        prev[curr.dataKey] = {
                          selected: [],
                          list: [],
                          filterMethod: curr.filterMethod ?? generalFilterHandler
                        }
                        return prev
                      }, {})
                  )
                  // 自动获取各项筛选列表
                  const getFiltersFromResp = () => {
                    for(let dataKey in filterableCols) {
                      let list
                      if(dataKey === 'tags') { // tags 项可筛选列表固定
                        list = ['developer','Ph.D','Bachelor','Master','CEO','HRBP','HR'].map(p1 => ({ text: p1, value: p1 }))
                      } else { // 其它项取所有非重复项
                        list = originData.value.map(p => p[dataKey]).filter(Boolean)
                        // 去重、转对象
                        list = [...new Set(list)].map(p1 => ({ text: p1, value: p1 }))
                      }
                      filterableCols[dataKey].list = list
                      filterableCols[dataKey].selected = []
                    }
                  }
                  // const getTableData = () => { ... }
                  const getData = (total) => {
                    getTableData(total).then((res) => {
                      originData.value = res ?? [];
                      tableData.value = originData.value;
                      getFiltersFromResp()
                    });
                  };
                  

                  筛选方法需自行定义

                  const onFilter = () => {
                    const allFilters = Object.entries(filterableCols).filter(([_,configs]) => {
                      return configs.selected?.length > 0
                    })
                    tableData.value = originData.value.filter(p => {
                      return allFilters.every(([dataKey,configs]) => {
                        return !configs.filterMethod || configs.filterMethod(p[dataKey], configs.selected)
                      })
                    })
                  };
                  const onReset = (dataKey) => {
                    filterableCols[dataKey].selected = []
                    onFilter();
                  };
                  

                  完整代码

                  import { ref, reactive, onMounted } from "vue"
                  import { generalFilterHandler, generalArrFilterHandler } from '@/use/el-table-v2-utils'
                  onMounted(() => {
                    getData()
                  })
                  const originData = ref([]);
                  const tableData = ref([]);
                  const columnData = ref([
                    { key: "no", dataKey: "no", title: "No.", width: 60 },
                    { key: "code", dataKey: "code", title: "code", width: 80 },
                    { key: "name", dataKey: "name", title: "name", width: 80 },
                    { key: "age", dataKey: "age", title: "Age", width: 60 },
                    { key: "gender", dataKey: "gender", title: "gender", width: 80, filterable: true },
                    { key: "city", dataKey: "city", title: "City", width: 80, filterable: true },
                    { key: "tags", dataKey: "tags", title: "Tags", width: 150, filterable: true, filterMethod: generalArrFilterHandler }
                  ]);
                  const columns = columnData.value.map(col => {
                  	return {
                  		key: col.dataKey,
                  	    title: col.title,
                  	    dataKey: col.dataKey,
                  	    width: col.width ?? 100,
                  	    headerCellRenderer: (props) => {
                  	      if(!col.filterable) return props.column.title
                  	      return 
                  { props.column.title } ...{ width: 200 }} {{ default: () => (
                  filterableCols[col.dataKey].selected } { filterableCols[col.dataKey].list.map(f => f.value } label={ f.value }{ f.text }) }
                  onFilter }Confirm () = onReset(col.dataKey) }>Reset
                  ), reference: () => ( { cursor: 'pointer', color: filterableCols[col.dataKey].selected?.length 0 ? '#387FE5' : 'inherit' } } > ) }}
                  } } }) /** * 筛选信息列表 * props: * - {Array} list 可筛选值列表 * - {Array} selected 已勾选列表 * - {Function} [filterMethod] 筛选方法 */ const filterableCols = reactive( columnData.value .filter(c => c.filterable) .reduce((prev,curr) => { prev[curr.dataKey] = { selected: [], list: [], filterMethod: curr.filterMethod ?? generalFilterHandler } return prev }, {}) ) const onFilter = () => { const allFilters = Object.entries(filterableCols).filter(([_,configs]) => { return configs.selected?.length > 0 }) tableData.value = originData.value.filter(p => { return allFilters.every(([dataKey,configs]) => { return !configs.filterMethod || configs.filterMethod(p[dataKey], configs.selected) }) }) }; const onReset = (dataKey) => { filterableCols[dataKey].selected = [] onFilter(); }; const tagList = ['developer','Ph.D','Bachelor','Master','CEO','HRBP','HR'] // 自动获取各项筛选列表 const getFiltersFromResp = () => { for(let dataKey in filterableCols) { let list if(dataKey === 'tags') { // tags 项可筛选列表固定 list = tagList.map(p1 => ({ text: p1, value: p1 })) } else { // 其它项取所有非重复项 list = originData.value.map(p => p[dataKey]).filter(Boolean) // 去重、转对象 list = [...new Set(list)].map(p1 => ({ text: p1, value: p1 })) } filterableCols[dataKey].list = list filterableCols[dataKey].selected = [] } } const getData = (total) => { getTableData(total).then((res) => { originData.value = res ?? [] tableData.value = originData.value getFiltersFromResp() }) } const getTableData = (total) => { if (!total) total = Math.floor(Math.random() * 2000 + 1000) return new Promise((resolve, reject) => { resolve( Array.from({ length: total }).map((_, idx) => { return { no: idx + 1, code: Math.floor(Math.random() * 100000).toString(16), name: Math.floor(Math.random() * 100000).toString(16), age: Math.floor(Math.random() * 30 + 18), gender: Math.random() > 0.5 ? "男" : "女", city: ["北京", "上海", "深圳"][Math.floor(Math.random() * 3)], tags: tagList.sort((a,b) => Math.random() - 0.5) .slice(0, Math.floor(Math.random() * 4)) } }) ) }) }

                  el-table-v2 筛选/过滤器 demo

                  Total: {{ tableData.length }}
                  刷新表格数据

                  el-table-v2-utils.js:

                  /**
                   * element-plus TableV2 筛选方法
                   * @param {string} value 单元格数值
                   * @param {string|Array} filters 已选筛选值或筛选值列表
                   * @returns {boolean}
                   */
                  export function generalFilterHandler(value, filters) {
                    if(filters instanceof Array)
                      return filterHandler(value, filters)
                    return selectFilterHandler(value, filters)
                  }
                  /**
                   * element-plus TableV2 筛选方法
                   * @param {string} value 单元格数值
                   * @param {Array} filters 已选筛选值列表
                   * @returns {boolean}
                   */
                  function filterHandler(value, filters) {
                    return !filters?.length ? true : filters.includes(value)
                  }
                  /**
                   * element-plus TableV2 筛选方法
                   * @param {string} value 单元格数值
                   * @param {string} filter 已选中的筛选值
                   * @returns {boolean}
                   */
                  function selectFilterHandler(value, filter) {
                    return !filter && filter !== 0 || filter === value
                  }
                  /**
                   * element-plus TableV2 筛选方法(单元格数值类型为数组)
                   * @param {string|Array} value 单元格数值
                   * @param {string|Array} filters 已选筛选值或筛选值列表
                   * @returns {boolean}
                   */
                  export function generalArrFilterHandler(value, filters) {
                    if(!(value instanceof Array))
                      return generalFilterHandler(value, filters)
                    if(filters instanceof Array)
                      return arrayFilterHandler(value, filters)
                    return selectArrayFilterHandler(value, filters)
                  }
                  /**
                   * element-plus TableV2 筛选方法(单元格数值类型为数组)
                   * @param {string} value 单元格数值
                   * @param {Array} filters 已选筛选值列表
                   * @returns {boolean}
                   */
                  function arrayFilterHandler(value, filters) {
                    return !filters?.length ? true : filters.some(f => value?.includes(f))
                  }
                  /**
                   * element-plus TableV2 筛选方法(单元格数值类型为数组)
                   * @param {string} value 单元格数值
                   * @param {string} filter 已选中的筛选值
                   * @returns {boolean}
                   */
                  function selectArrayFilterHandler(value, filter) {
                    return !filter && filter !== 0 || value?.includes(filter)
                  }
                  

                  注意: 对单元格数值的筛选也分为很多种

                  • 最常见的,就是判断与选中筛选值是否相等(多选时,是否包含在内)。其它常见的判断有包含、以…开头、以…结尾、等等
                  • 其次,上例中有个特殊的项,tags,其数据类型为数组,需要筛选出存在 tag 包含在选中 tags 列表中的数据(如筛选条件为满足所有选中筛选值,筛选方法又不一样)
                  • 其它更特殊些的筛选都需要自行拟好筛选方法
                  • 需要注意,筛选方法参数与 TableV1 中的不同(示例中的自定义筛选方法是遍历一遍表格数据,而 TableV1 提供的筛选方法接口是对选中筛选值列表中的每个值,都遍历一遍表格数据。实质是一样的,只是方法参数类型不同而已)

                    另外,下一节方案二中,通过给源数据添加 hidden 标识是否通过筛选,无需单独记录筛选数据。改动也很简单,删除 originData,onFilter 中更新 hidden 属性值,表格绑定数据中进行筛选 hidden 不为 true 的。

                    其它附加功能

                    默认筛选值、筛选列表单选

                    如 TableV1 中提供的功能,有时候,我们需要添加默认筛选值;有些筛选项我们希望做成单选的形式。

                    接上例,可更新代码如下:

                    import CustomSelector from '@/components/custom-selector.vue'
                    const columnData = ref([
                      // ...
                      {
                        key: "gender",
                        dataKey: "gender",
                        title: "gender",
                        width: 80,
                        filterable: true,
                        filterSingle: true,
                        filteredValue: '男'
                      },
                      // ...
                    ]);
                    /**
                     * 筛选信息列表
                     * props:
                     * - {Array} list 可筛选值列表
                     * - {Array} selected 已勾选列表(筛选值多选时使用)
                     * - {string} singleSelect 已勾选值(筛选值单选时使用)
                     * - {Function} [filterMethod] 筛选方法
                     * - {Array} [filteredValue] 默认筛选值
                     * - {boolean} [filterSingle] 筛选值单选?
                     */
                    const filterableCols = reactive(
                      columnData.value
                        .filter(c => c.filterable)
                        .reduce((prev,curr) => {
                          prev[curr.dataKey] = {
                            selected: [],
                            list: [],
                            singleSelect: undefined,
                            filterMethod: curr.filterMethod ?? generalFilterHandler,
                            filteredValue: curr.filteredValue,
                            filterSingle: curr.filterSingle ?? false
                          }
                          return prev
                        }, {})
                    )
                    const onFilter = () => {
                      const allFilters = Object.entries(filterableCols).filter(([_,configs]) => {
                        return configs.filterSingle
                          ? ![null,undefined].includes(configs.singleSelect)
                          : configs.selected?.length > 0
                      })
                      tableData.value = originData.value.filter(p => {
                        return allFilters.every(([dataKey,configs]) => {
                          return !configs.filterMethod ||
                            configs.filterMethod(p[dataKey], configs.filterSingle ? configs.singleSelect : configs.selected)
                        })
                      })
                    };
                    // 自动获取各项筛选列表
                    const getFiltersFromResp = () => {
                      for(let dataKey in filterableCols) {
                        // ...
                        // 根据是否多选,获取对应默认排序(值/列表)
                        filterableCols[dataKey].selected = !filterableCols[dataKey].filterSingle
                          ? filterableCols[dataKey].filteredValue instanceof Array
                        		? filterableCols[dataKey].filteredValue
                        		: []
                        	: []
                        filterableCols[dataKey].singleSelect = filterableCols[dataKey].filterSingle
                          ? typeof filterableCols[dataKey].filteredValue !== 'object'
                        		? filterableCols[dataKey].filteredValue
                        		: undefined
                          : undefined
                      }
                    }
                    const columns = columnData.value.map(col => {
                      return {
                        key: col.dataKey,
                        title: col.title,
                        dataKey: col.dataKey,
                        width: col.width ?? 100,
                        headerCellRenderer: (props) => {
                          if(!col.filterable) return props.column.title
                          return 
                    {props.column.title} ...{ width: 200 }} {{ default: () => { return filterableCols[col.dataKey].filterSingle ? filterableCols[col.dataKey].singleSelect } onChange={ onFilter } list={ filterableCols[col.dataKey].list } / :
                    filterableCols[col.dataKey].selected } { filterableCols[col.dataKey].list.map(f => f.value } label={ f.value }{ f.text }) }
                    onFilter }Confirm () = onReset(col.dataKey) }>Reset
                    }, reference: () => ( { cursor: 'pointer', color: (filterableCols[col.dataKey].filterSingle ? ![null,undefined].includes(filterableCols[col.dataKey].singleSelect) : filterableCols[col.dataKey].selected?.length 0) ? '#387FE5' : 'inherit' } } > ) }}
                    } } })

                    CustomSelector组件:单选列表(展开的 el-select )

                    const props = defineProps({
                      modelValue: { default: '' },
                      list: { type: Array, default: [] }
                    })
                    const emit = defineEmits(['change','update:modelValue'])
                    function handleOptionClick(val) {
                      if(props.modelValue === val) return
                      emit('update:modelValue', val)
                      emit('change', val)
                    }
                    
                    
                      
                    {{ option.text }}
                    .wrap { width: 100%; font-size: 14px; color: #666; .item { line-height: 32px; padding: 0 12px; cursor: pointer; &.active {color: #387FE5;font-weight: bold;} &:hover {background-color: #f9f9f9;} } }
                    多选时,不点击确定

                    单选组件设置了监听事件,无需点击确定即可触发筛选事件

                    而多选使用的多选框组,存储选中筛选值的变量是直接绑定到组件上的,示例中通过点击确定按钮手动触发筛选事件,不点击会导致的UI与实际筛选不符的问题

                    想到两种解决方案,一是使用多选框组提供的 change 事件,代价是可能会筛选过于频繁。另一种是创建一个变量存储前一次筛选状态,每次执行筛选前与当前筛选状态进行对比,相同则不执行筛选

                    方案一:更新 headerCellRenderer

                    ↓↓↓↓↓

                    方案二:

                    const prevFilters = ref([])
                    const compareFilters = currFilters => {
                      return prevFilters.value.length === currFilters.length
                        && prevFilters.value.every((f,idx) => {
                          let [
                            currDataKey,
                            { selected: currSelected, singleSelect: currSingleSelect }
                          ] = currFilters[idx]
                          currSelected = currSelected.slice(0).sort()
                          return f.dataKey === currDataKey
                            && f.singleSelect === currSingleSelect
                            && f.selected.slice(0).sort().every((p,idx1) => p === currSelected[idx1])
                        })
                    }
                    const onFilter = () => {
                      // const allFilters = ...
                      if(compareFilters(allFilters)) return
                      prevFilters.value = allFilters.map(([dataKey,configs]) => ({
                        dataKey,
                        selected: configs.selected,
                        singleSelect: configs.singleSelect
                      }))
                      // ...
                    };
                    

                    六、排序、筛选同时使用

                    按前两节示例,排序、筛选当然可以同时实现,但需要处理数据上冲突

                    问题分析

                    两部分的示例中,都使用了 originData 记录表格原始数据:

                    如果不保存源数据,在排序中,不光无法置空排序、回归原顺序,不同排序项的历史排序影响也会持续下去,其实是难以预料的多重排序结果。筛选操作也变成了基于上一次筛选结果的筛选

                    const tableData = ref([])
                    const originData = ref([])
                    // handle sort
                    tableData.value = originData.value.slice(0).sort(sortMethod)
                    // handle filter
                    tableData.value = originData.value.filter(filterMethod)
                    

                    两者都是基于源表格数据进行排序、筛选。两个功能同时存在时,就有冲突了,会彼此干扰此前的操作。

                    解决方案

                    方案一:创建中间变量

                    额外创建一个变量 tempData,用它记录筛选后的数据,tableData记录排序后的值

                    originData -> tempData -> tableData

                    • originData 更新时,tempData, tableData 重置为 originData
                    • 筛选时,基于源数据筛选(originData -> tempData),tableData 重置为 tempData
                    • 排序时,基于 tempData 排序(tempData -> tableData)

                      看起来有点绕,其实就两条依赖关系,添加两个相应的 watch 就可以了

                       
                      
                      const tableData = ref([])  // 表格当前数据(排序、筛选后)
                      const tempData = ref([])   // 中间变量,对源数据的筛选
                      const originData = ref([]) // 表格源数据
                      // 源数据更新 -> 更新筛选项各自的可筛选列表, 执行筛选
                      watch(originData, (newVal) => {
                        getFiltersFromResp()
                        onFilter() // 执行筛选会更新 tempData
                      })
                      // 排序状态更新 | tempData 更新 -> 执行排序
                      watch(
                        [sortState, tempData],
                        ([newState, newData]) => {
                          // handle sort ( originData --sort--> tableData )
                          const { key, order } = newState ?? {}
                          // 数据为空 | 当前无排序,重置 tableData
                          if (!newData?.length || !key || !order) {
                            tableData.value = newData
                            return
                          }
                          // ...
                          tableData.value = newData.slice(0).sort(sortMethod)
                        },
                        { immediate: true }
                      )
                      // handle filter
                      const onFilter = () => {
                      	// ...
                      	tempData.value = originData.value.filter(execFilter)
                      }
                      

                      方案二:源数据中添加筛选标识

                      筛选事件处理方法中,标识数据是否通过筛选(例如:添加 hidden 属性),与方案一相比,代码改动较小:

                      const tableData = ref([])  // 表格当前数据(排序、筛选后)
                      // const tempData = ref([])   // 中间变量,对源数据的筛选
                      const originData = ref([]) // 表格源数据
                      // 源数据更新 -> 更新筛选项各自的可筛选列表, 执行筛选
                      watch(originData, (newVal) => {
                        getFiltersFromResp()
                        onFilter()
                      })
                      // 排序状态更新 | originData 更新(包括 hidden 更新) -> 执行排序
                      watch(
                        [sortState, originData], // [sortState, tempData],
                        ([newState, newData]) => {
                          const { key, order } = newState ?? {}
                          if (!newData?.length || !key || !order) {
                            tableData.value = newData?.filter(p => !p.hidden) ?? [] // newData
                            return
                          }
                          // ...
                          // tableData.value = newData.slice(0).sort(sortMethod)
                          tableData.value = newData.filter(p => !p.hidden).sort(sortMethod)
                        },
                        { immediate: true, deep: true } // { immediate: true }
                      )
                      const onFilter = () => {
                      	// ...
                        // tempData.value = originData.value.filter(execFilter)
                      	originData.value.forEach(val => {
                      		val.hidden = !execFilter(val)
                      	})
                      }
                      

                      无中间变量,originData 添加 hidden 属性

                      相比之下,少一条依赖,代码简单一点。排序和筛选其实还都是基于源数据,只是使用 hidden 属性过滤 TableV2 的绑定数据

                      此方法同样可以应用在上一节单独使用筛选功能时,可以做到只使用源数据。

                      注意:

                      上例中为了监听到源数据 hidden 的变化,进行了深层监听。如果具体业务中有其它逻辑需要频繁改动源数据其它属性,而又不需要更新排序、筛选。可以不使用 watch 监听,避免无意义的执行排序、筛选

                      其他

                      为了突出排序已生效,可以加上样式

                      .el-table-v2__header-cell .el-table-v2__sort-icon.is-sorting {color:#387FE5}


                      总结

                      TableV2 只加载可见区域以及前后预加载(可设置)的“行”,纵向滚动后全部重新加载一遍。只要数据拿到了,表格的加载速度很快,对表格的一些响应式操作反应也很快,如果表格数据量可能在几千行及以上,很有必要换掉 TableV1。

                      TableV2 的问题在于它目前是全部重新加载,哪怕是纵向滑动了一行。

                      遇到的坑

                      当设置了动态高度行(estimated-row-height)时,可能会发生严重的卡顿现象!(与表格数据总数无关)

                      调试发现,目前 TableV2 每滚动一次就重新加载一次表格内及预加载的行数,总单元格过多时,会让体验感在上面的场景下变得极差。

                      以本人实际生产环境中的一个表格为例,表格一行有17个的单元格,一页显示20条数据,预先多加载的行数是2,每一次需要渲染 17*24=408 个单元格。连续纵向滚动会触发多次表格内容加载,轻微卡顿,有待优化,感觉可以提供一个属性作为连续滚动的节流处理开关。另一方面就是全部重新加载这个策略。

                      上面说的是正常流程,不正常的是,当设置了动态高度且超出设定高度的行数不少时,一次滚动就会重复触发四五次,再加上连续滚动。假设一个只触发三次的连续滚动,408 *4*3=4896 个单元格渲染。如果你够年轻,手速够快,轻松破万。再加上,某些自定义渲染的单元格够“大”够“重”,那。。。

                      这个问题应该是个 bug,希望后续版本能修复

VPS购买请点击我

文章版权声明:除非注明,否则均为主机测评原创文章,转载或复制请以超链接形式并注明出处。

目录[+]