前端项目中升序降序排序组件的实现过程

这次要介绍的主题也是来自同一个产品需求,那就是如何实现一个升序和降序的排序组件。看过前2篇前端黑科技系列文章的朋友,细心一点可以发现今天要讲的主题已经有过出现,就是在Table表格模式时的表头上的排序功能,只不过之前文章没有展开细说,今天就来详细说说如何去实现这样一个升序和降序的排序组件。

背景

一如既往,还是来说说该需求出现的背景。还得再提前两篇文章,所以不清楚的朋友一定要去看看前两篇文章,我只能说很经典,看完会了你真的就是一技傍身,不会那你遇到类似需求需要你实现时将会让你一步不前。虽说需求有时候很扯淡,但是你不得不说它真的很有挑战,特别是完成之后的成就感,那是相当哇瑟的^_^。

稍微扯远了,回过头来说背景。由于项目中的Table表格是使用div拼装而成的,所以没有现成的排序功能,只能自己去手写一个排序组件。这对于我们来说应该不是问题,只是有两种情况需要考虑,那就是需不需要将其提取出来封装成公用组件在其他页面使用的问题,这也会在接下来的文章中涉及。老规矩,直接上完成之后的效果图,如下:

前端项目中升序降序排序组件的实现过程

好了,效果也看了,需求背景也了解清楚了,那下面就要开始实际操作了→

实现过程

实现过程这里就分为2大块来叙说,先来给你们演示在项目中直接写组件代码,而不将其提取出来的实现过程。之后再来介绍如何将其提取,如何对其封装为表单组件,再如何在其他页面引用的封装的组件进行使用的实现过程。这篇也是经典,请抓紧扶手,马上发车了。

注:技术栈依然是React!!!

一、直接在页面中编写排序组件

直接在页面中写排序组件代码,其实不是很提倡,像这样一个排序功能应该封装为公用组件引用使用。其实我们大多数程序员都是想偷点懒,提取组件封装需要考虑诸多问题,还不如直接在页面中实现来得快一点。先在规定的时间节点完成相应的功能需求,保证开发速率不受影响的前提下,先把排期的需求搞上线之后有空余时间才再来考虑封装问题,这几乎是大多数程序员的心理博弈之后的结果。

所以下面我们就先在项目中直接写,后面再来介绍封装组件的问题→

1.1 布局代码

先来看布局代码,其实就是两个上下箭头的svg组成的,icon使用的是antd中提供的<CaretUpOutlined /><CaretDownOutlined />。具体代码如下:

/**
 * 排序组件
 * @param orderField // 排序字段
 */
const renderSorter = (orderField: string) => {
    return (
        <div className='sort-wrap' onClick={() => onSortHandle(orderField)}>
            <CaretUpOutlined
                className={[
                    'sort-icon',
                    isSorterStyle(orderField, 1)
                ].join(' ')}
            />
            <CaretDownOutlined
                style={{ marginTop: '-4px' }}
                className={[
                    'sort-icon',
                    isSorterStyle(orderField, 2)
                ].join(' ')}
            />
        </div>
    )
}

布局的代码并没有直接在排版代码中编写,而是使用函数进行返回的方式。根据背景需求展示的效果图来看该组件会被多次使用在多列上,如果每一列都去写上面的代码会显得冗余繁复,所以就将其使用函数返回的方式进行返回输出,这样可避免很多相同的代码出现问题。

renderSorter()方法中需要传入一个排序字段orderField,用于对表格对应的orderField列进行排序操作。

1.2 样式代码

这个组件的样式就非常简单,就只需要让它垂直排列就行;鼠标样式改为手型,让用户知道它是可操作的就行;至于两个icon之间的间距,可以使用style={{ marginTop: '-4px' }}让两者之间不分得太开就可以了。具体代码,如下:

.sort-wrap {
    display: flex;
    flex-direction: column;
    margin-right: 4px;
    cursor: pointer;
}
.sort-icon {
  color: #bfbfbf;
}
.active {
  color: #1890FF;
}

1.3 逻辑代码

接下来就是该组件最重要的部分了,即主要逻辑代码的实现。需要你耐心的看完,跟上思路才能更好理解我的实现思路→

首先,需要定义2state,分别为sorterArraydataSourceBaksorterArray用于存储需要排序的所有列的信息;dataSourceBak用于存储数据的备份数据,在数据请求回来之后需要把数据进行备份,后面有大作用。如下:

// 排序集合
const [sorterArray, setSorterArray] = useState<any[]>([
    {orderField: 'name', orderMode: 0, isSort: false},
    {orderField: 'sortNum', orderMode: 0, isSort: false},
    {orderField: 'overlay', orderMode: 0, isSort: false},
    {orderField: 'sizeNum', orderMode: 0, isSort: false},
    {orderField: 'refNum', orderMode: 0, isSort: false},
    {orderField: 'lastUpdate', orderMode: 0, isSort: false},
    {orderField: 'realPath', orderMode: 0, isSort: false}
]);
// 源数据备份
const dataSourceBak = useRef<any[]>([]);

每个对象中都包含orderFieldorderModeisSort这三个属性字段。orderField表示需要排序的字段;orderMode表示排序的模式:0为默认,1为升序,2为降序;isSort表示是否在进行排序操作。

接着就来看排序方法的逻辑是如何编写的,如下:

/**
 * 排序操作
 * @param orderField 字段名
 */
const onSortHandle = (orderField: string) => {
    let obj: any = sorterArray.find((v: any) => v.orderField === orderField) || {};
    let newSorterArray: any[] = [...sorterArray];
    let newDataSource: any[] = JSON.parse(JSON.stringify(dataSource)) || [];
    newSorterArray.forEach((v: any) => {
        if (v.orderField === orderField) {
            v.isSort = true;
            if (v.orderMode != 2) {
                obj.orderMode += 1;
            } else {
                obj.orderMode = 0;
            }
        } else {
            v.orderMode = 0;
            v.isSort = false;
        }
    });
    setSorterArray(newSorterArray);
    // 如果取消排序则还原表格数据为未排序之前的数据,否则排除前两行数据进行升序或者降序排序
    if (obj.orderMode === 0) {
        setDataSource(dataSourceBak.current);
        return;
    }
    let tempList: any[] = newDataSource.slice(0, 1);
    let aray: any[] = newDataSource.slice(1).sort(orderField === 'name' || orderField === 'realPath' ? compareEn(orderField, obj.orderMode) : compare(orderField, obj.orderMode))
    setDataSource([...tempList, ...aray]);
}

可以看到,onSortHandle()方法需要将要排序的列字段传入进行操作,通过传入的orderFieldsorterArray数组对比判断当前操作排序的是哪一列数据;然后对orderMode进行计数,根据012对应的排序方式来设置进行相应的排序和组件的显隐就行。

这里需要注意2点,如下:

  1. 如果排序方式为0时,即表示不排序,则需要将源数据重新赋值给表格,源数据就是一开始没有经过排序的数据;
  2. 我的需求是不需要对第一行数据进行排序,所以在排序时需要将其剔除,把剩下的数据排序之后和它进行组装,之后才重新赋值给表格,让其重新渲染才能看到排序后的效果。

再接着,我们来看compare比较函数的实现。如下:

/**
 * 中英文排序比较函数
 * @param orderField 字段名
 * @param orderMode 1升序, 2降序
 */
const compareEn = (orderField: string, orderMode: number) => {
    return function (a: any, b: any) {
        // 升序
        if (orderMode === 1) {
            return a[orderField].localeCompare(b[orderField]);
        }
        // 降序
        if (orderMode === 2) {
            return b[orderField].localeCompare(a[orderField]);
        }
        return 0;
    }
}


/**
 * 数值排序比较函数
 * @param orderField 字段名
 * @param orderMode 1升序, 2降序
 */
const compare = (orderField: string, orderMode: number) => {
    return function (a: any, b: any) {
        let value1: any = parseFloat(a[orderField]);
        let value2: any = parseFloat(b[orderField]);
        if (orderField === 'lastUpdate') {
            value1 = new Date(a[orderField]).getTime();
            value2 = new Date(b[orderField]).getTime();
        }
        // 升序
        if (orderMode === 1) {
            return value1 - value2;
        }
        // 降序
        if (orderMode === 2) {
            return value2 - value1;
        }
        return 0;
    }
}

compare比较函数有两种情况,一种是中英文排序的情况,另一种是数值类型排序的情况。中英文使用的是localeCompare方法,而数值就是两数相减,需要注意的是时间相关的是转成了时间戳进行比较的。

排序逻辑完了,就要来处理排序组件的上下箭头高亮的样式判断了。如下:

/**
 * 判断排序样式
 * @param orderField 字段名
 * @param orderMode 0默认,1升序, 2降序
 */
const isSorterStyle = (orderField: string, orderMode: number): string => {
    let sorterClassName: string = '';
    sorterArray.forEach((v: any) => {
        if (v.isSort && v.orderField === orderField && v.orderMode === orderMode) {
            sorterClassName = 'active';
        }
    });
    return sorterClassName;
}

到这里,页面中编写排序组件的代码就完了。下面就再来看看页面中是如何使用的,如下:

<div className="thead flexic">
    <div className="cell"></div>
    <div className="cell">
        <div className="flex1 tc">参照名</div>
        {renderSorter('name')}
    </div>
    <div className="cell">
        <div className="flex1 tc">状态</div>
        {renderSorter('sortNum')}
    </div>
    <div className="cell">
        <div className="flex1 tc">类型</div>
        {renderSorter('overlay')}
    </div>
    <div className="cell">
        <div className="flex1 tc">实例数</div>
        {renderSorter('refNum')}
    </div>
    <div className="cell">
        <div className="flex1 tc">大小</div>
        {renderSorter('sizeNum')}
    </div>
    <div className="cell">
        <div className="flex1 tc">日期</div>
        {renderSorter('lastUpdate')}
    </div>
    <div className="cell">
        <div className="flex1 tc">路径</div>
        {renderSorter('realPath')}
    </div>
</div>

把每一列的排序字段传入renderSorter方法,然后点击组件就能对数据进行排序了。最后再来看看实现的效果,如下:

前端项目中升序降序排序组件的实现过程

好了,页面中直接编写排序组件代码的实现过程就介绍完了。总的来说,代码逻辑不是多复杂,只要理清楚逻辑实现起来就是分分钟的事了。再有就是代码中的某些逻辑代码是可以进行优化的,比如说下面这段代码,如下:

前端项目中升序降序排序组件的实现过程

采用计数的方式不是说不行,同样也能实现需求。但是就是觉得有点low,所以后面在组件封装部分会对其进行优化,下面就请继续往下看→

二、将排序组件封装为表单组件

实际工作中,只要需求给的时间足够充分都会试着去把公用的部分提取出来编写为公共组件。一是为了简化代码,二是为了提升工作效率,同事间也能进行复用。所以如果时间过剩的情况下,尽量多去封装组件,这样可以利人利己的👍。

下面就来看看这个排序组件是如何封装的,let’s go!

2.1 组件封装

该组件一开始我想的是把父级的表格数据传入,在排序组件中进行逻辑处理之后将排序好的数据传递出去的想法,但是后面仔细一想,如果这样那每一列都会将源数据都进行传入,那就会有多份数据的问题。不是那么友好,也可能隐藏未知的错误情况。所以后面就采用下面的实现思路:排序组件只需要处理排序逻辑,即告诉父组件当前是升序,降序还是不排序,处理数据的逻辑就交由父组件根据排序方式去解决。这样就相对友好,复用性也相对更优。

那下面就来展示部分核心代码,完整代码将在后续全部贴出。

首先还是先来定义该组件需要的参数字段和一些常量,如下:

interface SortButtonProps {
    className?: string; // 样式类名
    children?: React.ReactNode; // 可自定义内容
    value?: any; // 表单组件需要
    onChange?: (value: DirectionName) => void; // 表单组件需要
}

// 排序模式枚举
export enum DirectionName {
    'asc' = 'asc',  // 升序
    'desc' = 'desc', // 降序
}

再来就是布局代码,如下:

return (
    <>
        <div className={['custom-sort-button-wrap', className].join(' ')}>
            <div title={getTitle()} onClick={sortClickHandle}>
                <CaretUpFilled
                    className={generateClassName(['sort-icon', value === DirectionName.asc ? 'active' : ''])}
                />
                <CaretDownFilled
                    className={generateClassName(['sort-icon', value === DirectionName.desc ? 'active' : ''])}
                />
            </div>
        </div>
        {children}
    </>
)

上述代码就是排序组件的布局代码,跟上面页面中直接写的没啥区别,没有什么可赘述的。唯一不同的就是增加了title显示,提升了交互体验。那就来看看代码逻辑,如下:

const getTitle = (): string => {
    if (value === DirectionName.asc) {
        return '升序';
    }
    if (value === DirectionName.desc) {
        return '降序';
    }
}

再来就是组件的点击事件的逻辑代码啦,这里就要去优化上面结束时提到的代码。不再采用计数的方式去确定是升序、降序还是不排序,而是使用短路的方式及时的去返回排序方式,如下:

/**
 * 点击排序
 */
const sortClickHandle = () => {
    if (!value) {
        onChange(DirectionName.asc);
        return;
    }
    if (value === DirectionName.asc) {
        onChange(DirectionName.desc);
        return;
    }
    if (value === DirectionName.desc) {
        onChange(null);
        return;
    }
}

好了,该排序组件的核心代码就是如此,相对简单,没有多少可以需要注意的坑点,那就继续往下→

2.2 完整代码

下面就要来贴出该组件的完整代码了,具体的代码如下:

import React, {useState} from "react";
import {CaretUpFilled, CaretDownFilled} from '@ant-design/icons';
import { generateClassName } from "../../util/common";
import './sortButton.less';

interface SortButtonProps {
    className?: string;
    children?: React.ReactNode;
    value?: any;
    onChange?: (value: DirectionName) => void;
}

// 排序方式枚举
export enum DirectionName {
    'asc' = 'asc',  // 升序
    'desc' = 'desc', // 降序
}


/**
 * 排序按钮
 * @constructor
 */
const SortButton = (props: SortButtonProps) => {
    const { className = '', value, children = null, onChange = (value: DirectionName) => {} } = props;


    /**
     * 点击排序
     */
    const sortClickHandle = () => {
        if (!value) {
            onChange(DirectionName.asc);
            return;
        }
        if (value === DirectionName.asc) {
            onChange(DirectionName.desc);
            return;
        }
        if (value === DirectionName.desc) {
            onChange(null);
            return;
        }
    }

    
     /**
     * 获取title
     */
    const getTitle = (): string => {
        if (value === DirectionName.asc) {
            return '升序';
        }
        if (value === DirectionName.desc) {
            return '降序';
        }
    }


    return (
        <>
            <div className={generateClassName(['custom-sort-button-wrap', className])}>
                <div title={getTitle()} onClick={sortClickHandle}>
                    <CaretUpFilled
                        className={generateClassName(['sort-icon', value === DirectionName.asc ? 'active' : ''])}
                    />
                    <CaretDownFilled
                        className={generateClassName(['sort-icon', value === DirectionName.desc ? 'active' : ''])}
                    />
                </div>
            </div>
            {children}
        </>
    )
}


export default SortButton;

这个组件是需要样式代码的,所以样式代码也给你们贴出来好了。如下:

.custom-sort-button-wrap {
    display: flex;
    flex-direction: column;
    justify-content: center;
    >div {
        display: flex;
        flex-direction: column;
        justify-content: center;
        padding: 0 4px;
        &:hover {
            cursor: pointer;
        }
    }
    .sort-icon {
        font-size: 12px;
        color: #bfbfbf;
        &.active {
            color: #1890FF;
        }
    }
}

代码封装的过程没有啥特别的,就是如何让它成为表单组件。根据antd的规则,自定义或第三方的表单控件,也可以与Form组件一起使用。只要组件遵循以下的约定:

  • 提供受控属性 value 或其它与valuePropName的值同名的属性。
  • 提供 onChange 事件或trigger的值同名的事件。

只要满足上述两个约定,封装的组件就可以说是表单组件,就可以和Form组件一起使用。这样做的好处就是表单组件没有异步的问题,可以在值更新之后就去操作。所以在我们项目中能把它搞成表单组件的都把它搞成表单组件,避免不必要的麻烦。

所以按照上面的规则,我们就为该排序组件就增加了valueonChange属性,使之名正言顺的成为表单组件。但其实如果你不想把它当成表单组件使用也是可以的,就定义state管理value即可。

2.3 引入使用

组件封装好,下面就来引入项目进行使用。方法如下:

import SortButton from "./component/SortButton";

引入成功之后,就在页面中进行使用,如下:

<Form.Item name="wordCountSort" noStyle>
    <SortButton
        className="mla"
        onChange={(value: DirectionName) => {
            const { resetFields } = searchForm;
            resetFields(['sort']);  // 重置时间排序
            query();
        }}
    />
</Form.Item>
<Form.Item name="sort" noStyle>
    <SortButton
        className="mla"
        onChange={(value: DirectionName) => {
            const { resetFields } = searchForm;
            resetFields(['wordCountSort']); // 重置字数排序
            query();
        }}
    />
</Form.Item>

可以看到使用起来还是一如既往的好用,操作也很方便,只是这里的排序是走后端控制的,即通过接口查询排序之后实现的。但是也可以不走后端逻辑,直接在onChange里面定义排序方法也是能实现排序功能的。那就来看看最后实现的效果,如下:

前端项目中升序降序排序组件的实现过程

可以看到,走接口和前端自己排序是有体验上的明显感知的,走接口返回就需要等待接口返回之后重新渲染之后才看到效果。但是这个问题呢也有好处就是前端不去管逻辑,只关心界面渲染相关的东西就好,其他数据逻辑全由后端控制。这就仁者见仁智者见智了,不去深入探讨了。反正最后我们是把排序组件给完成了,也让数据进行了排序,至于其他后面再去细聊。

到这里,排序组件的编写和封装就介绍完毕了。文中提供了两种实现方式,孰优孰劣自行选择,根据自己平时编码的喜好自行决定选择适合自己的就可以。如果你会排序组件的编写欢迎在评论区相互讨论看看有没有更好的实现方式;如果你不会那你就参考我的实现思路,耐心地看完此篇文章,之后自己再去实践也是可以的;如果你都不想,那就直接复制粘贴到项目中使用也是没问题的。