React项目工程化业务封装实践 -- 组件封装

675 阅读6分钟

本篇文章继续讲一下React工程化项目中,业务组件的常用封装。

省份城市过滤器

image.png

我们先定义地区的结构列表:

export const BelongCityCodeList = [
  {
    code: "11",
    name: "北京市",
    children: [
      {
        code: "1101",
        name: "北京市",
      },
    ],
  },
  ...
]

外层定义省份,children内定义该省份的所有城市。接着写自己的过滤组件:

...
import { Cascader } from "antd";
import { BelongCityCodeList } from "../../utils/cityList";
function BelongCityFilter(props) {
  const { value } = props;
  return (
    <Cascader
      value={value}
      fieldNames={{ label: "name", value: "code", children: "children" }}
      options={BelongCityCodeList}
      placeholder="请选择归属地"
    />
  );
}
export default BelongCityFilter;

使用antd的级联选择组件,结构与上述列表一致。

自定义搜索

当然也可以自定义可搜索的列表。通过API获取可选择的列表,然后传给自定义组件来进行过滤筛选。

image.png

const [customerOptions, setCustomerOptions] = useState(customerList);

customerList从后台获取,传给本组件的state。然后定义一个下拉选择:

import { Select } from "antd";

const { Option } = Select;
<Select
    showSearch
    mode={mode} // 默认单选
    style={{ width: width || '100%' }}
    placeholder="请选择客户"
    value={value}
    onChange={onChange}
    onClick={e => e && e.preventDefault}
    dropdownMatchSelectWidth={500}
    onSearch={getFilterOption}
    showArrow={false}
    {...resProps}
>
    {customerOptions.map((item, index) => (
        <Option key={index} value={item.Id}>
            {`${item.Name}-(${item.Id})`}
        </Option>
    ))}
</Select>

然后对于搜索,可自定义筛选规则:

const getFilterOption = (value) => {
    const filterList = customerList.filter(item => {
        return item.Name?.toLowerCase().indexOf(value.toLowerCase()) >= 0
    })
    setCustomerOptions(filterList)
}

日期级联组合选择框

image.png

先定义格式化的日期按钮:

const dateList = ["当日", "本周", "上月"];

然后定义按钮组:

import { DatePicker, Radio } from 'antd';

<Radio.Group
    options={[...dateList.map(item => (
        { label: item, value: item }
    )), { label: "自定义", value: "自定义" }]}
    onChange={onRadioChange}
    defaultValue={rangeBtn}
    value={rangeBtn}
    optionType="button"
    buttonStyle="solid"
    size={size}
/>

定义默认选中:

const defaultRangeBtn = "当日";
const [rangeBtn, setRangeBtn] = useState(defaultRangeBtn); 

定义选中按钮的事件:

const onRadioChange = e => {
    setRangeBtn(e.target.value);
    if (e.target.value !== "自定义") {
        const dateRange = getDateRange(e.target.value);
        setDates(dateRange);
        onChange(dateRange, e.target.value);
    }
};

通过 getDateRange 获取时间区间,以此输出最后的时间 dates:

import moment from 'moment';

export const getDateRange = (dateKey) => {
    return dateRange[dateKey]
}

export const dateRange = {
    当日: [moment().startOf("day"), moment().endOf("day")],
    上周: [
        moment()
            .subtract(1, "week")
            .startOf("week")
            .startOf("day"),
        moment()
            .subtract(1, "week")
            .endOf("week")
            .endOf("day")
    ],
    上月: [
        moment()
            .subtract(1, "month")
            .startOf("month")
            .startOf("day"),
        moment()
            .subtract(1, "month")
            .endOf("month")
            .endOf("day")
    ],
    本月: [
        moment()
            .startOf("month")
            .startOf("day"),
        moment().endOf("day")
    ],
    ...
}

最后放置自定义时的日期选择框:

<RangePicker
    value={dates}
    onChange={onDatesChange}
    size={size}
    disabled={rangeBtn !== "自定义"}
    format="YYYY/MM/DD HH:mm:ss"
    onOpenChange={(open) => {
        // 打开选择面板时清空已选择
        if (open) {
            setDates(undefined)
        }
    }}
    {...rangePickerOptions}
/>

其中选中事件如下:

const onDatesChange = (values) => {
    setDates(values);
    onChange(rangeDates, rangeBtn);
}

onChange为该组件统一对外暴露的事件。按钮组和日期框的级联可以用 Space 组件进行组合。

上传与下载文件

image.png

先定义上传按钮:

import { Upload, Button, message } from 'antd';

// 上传按钮,可自定义按钮样式Content
export function UploadButton(props) {
    const { Content, buttonProps, RelatedModuleId, RelatedId, backFetch, ...resProps } = props;
    const uploadProps = getUploadProps(RelatedModuleId, RelatedId, backFetch);
    return <Upload {...uploadProps} {...resProps}>
        {
            Content ?
                <Content /> :
                <Button icon={<UploadOutlined />} {...buttonProps}>选择文件</Button>
        }
    </Upload>
}

其中属性获取 getUploadProps:

// 上传组件基本参数,需自定义可通过组件props设置
const getUploadProps = (RelatedModuleId, RelatedId, backFetch) => ({
    name: 'file',
    multiple: true, // 上传多个文件
    // action: '/apis/upload', // 上传文件API地址
    customRequest: (file) => fetchUpload(file, RelatedModuleId, RelatedId, backFetch), // 自定义上传方法,覆盖默认的上传方式
    beforeUpload: () => false, // 不上传,用于表单中,最后在form的确定按钮之后自行调API上传
    onChange(info) {
        const { status } = info.file;
        // 自定义的上传后处理
        if (status !== 'uploading') {
            //console.log(info.file, info.fileList);
        }
        if (status === 'done') {
            message.success(`${info.file.name} file uploaded successfully.`);
        } else if (status === 'error') {
            message.error(`${info.file.name} file upload failed.`);
        }
    },
});

fetchUpload 为后端API。

再来看看下载链接定义,相对简单一些:

<a
  href={ImportTemplateFieldId.template_file}
  download
>
  下载导入模版
</a>,

关键点是获取URL。我们还是使用资源配置的方式,在资源管理列表,用户可以上传模板文件,上传后会生成一个唯一的文件UUID,在界面加载时会向后端获取,拼接后存在ImportTemplateFieldId中,这里便可以直接拿到。

折线图

image.png

信息平台最常用的图表是折线图和柱状图,这里以折线图为例进行记录。

引入g2Plot:

import { Line } from '@antv/g2plot';

新建一个 Line 画图对象:

 const container = useRef(null);
 
 const LineChart = () => {
    if (container.current) {
        container.current.innerHTML = "";   // 强制重渲染
    }
    const linePlot = new Line(container.current, {
        data,
        xField,
        yField,
        xAxis,
        yAxis,
        seriesField,
        tooltip,
        smooth: true,
        ...resProps
    })
    linePlot.render();
}

属性字段可通过props获取,这里给一个示例:

<Trend
    data={dateStatistics}
    xField="SendDate"
    yField="Value"
    seriesField="Serie"
    yAxis={{
        label: {
            // 千分位数字
            formatter: (v) => `${getSthousandsFormatNum(v)} 条`,
        },
    }}
    tooltip={{
        formatter: (datum) => {
            return { name: datum.Serie, value: `${getSthousandsFormatNum(datum.Value)} 条` };
        },
    }}
/> 

数据的结构可以是这样的:

[
    {
        SendDate:2020-01-01,
        Value: 258,
        Serie: '发送失败'
    },
    {
        SendDate:2020-01-01,
        Value: 1,
        Serie: '发送成功'
    },
    {
        SendDate:2020-01-01,
        Value: 259,
        Serie: '发送总量'
    }
    ...
]

最后返回一个渲染Line的容器即可:

return <div ref={container} style={{ minHeight: '260px', minWidth: "500px", ...style }} />

数据表格

image.png

这里对antd的Table进行二次封装,为了使用更简便。

先对单独的Table进行包裹一下:

import React from "react";
import { Table } from 'antd';
import { getPaginationOption } from '../../utils/getPaginationOption';

/* 带标准分页模式的表格 */
function XSTable(props) {

    const { dataSource, columns, loading, position, onPaginationChange, pagination, ...rest } = props;
    const PaginationOption = getPaginationOption({ ...position, onChange: onPaginationChange });

    const tableStyle = {
        bordered: true,
        size: "small",
    }

    const tableProps = {
        columns,
        dataSource,
        loading,
        pagination: pagination === undefined ? PaginationOption : pagination
    }
    return (
        <Table
            {...tableStyle}
            {...rest}
            {...tableProps}
        />
    )
}

export default XSTable

其中的分页样式提了出来,单独配置:

// 符合antd表格规范的通用表格分页样式
export function getPaginationOption(props) {

    const { total, pageSize, current, onChange } = props;

    const onPaginationChange = (cur, size) => {
        const pagination = {
            total,
            pageSize: size,
            current: cur
        }
        onChange(pagination)
    }

    return {
        size: "small",
        showSizeChanger: true,
        showQuickJumper: true,
        showTotal: total => `共 ${total} 条`,
        pageSize,
        current,
        total,
        onChange: onPaginationChange,
    }
}

上述封装后,节省了每个Table都要定义一套分页器、样式的问题,但是对于表格搜索过滤和前后端分页的问题没有解决,所以还要继续改造。

定义一个更高阶的组件:

function XTable(props) {
    const {
        dataSource, loading, columns, totalCount, hiddenColumns, // table参数
        filterFields, defaultFilters, filterRowCount, defaultExpand, // filter参数
        handleDownload, downloadLoading, columnsSetting, leftActions, rightActions, // 按钮
        pagination, // 分页,不设置的话默认API分页,设置了就是按设置的走,如果想要前端分页,设置pagination={{ showTotal: total => `共 ${total} 条` }}
        onChange, ...rest } = props;
        
    return (
        <div>
            // 这里放搜索表单
            
            <XSTable
                {...rest}
                dataSource={dataSource}
                loading={loading}
                columns={tableColumns}
                position={position}
                pagination={pagination}
                onPaginationChange={onPaginationChange}
            />
        </div>
    )

}

说明一下设定:我们定义一堆 filter 参数用于搜索过滤,同时对于分页操作,我们想要的效果是走默认的分页是后端分页,想要前端分页可以自己传自定义的pagination,在该pagination中不调用API即可。

先定义一下分页变更事件;

const [position, setPosition] = useState({
    total: 0, pageSize: 10, current: 1
});
    
const onPaginationChange = (value) => {
    setPosition(value);
    getList(filters, value);
}

其中获取API的方法为getList:

const getList = (filters, position) => {
    const params = {
        Limit: position.pageSize,
        Offset: position.current - 1,
        ...filters,
    }
    onChange && onChange(params)
}

onChange 为外部传入,在onChange中调用API即可,API会刷新datasource变化,进而影响组件内部的展示,position来记录分页状态。如果想要前端分页,可以这样传入:

pagination={{ showTotal: total => `共 ${total} 条` }}

这样就没有了默认的那个onPaginationChange事件了,antd自动走前端分页。

接下来说一下搜索。

在放置搜索表单的地方定义一个搜索组件:

<TableFilter
    onSearch={onSearch}
    fields={filterFields}
    leftActions={leftActions}
    rightActions={tableAllRightActions}
    defaultValue={defaultFilters}
    rowCount={filterRowCount}
    defaultExpand={defaultExpand}
/>

在该组件里返回一个antd的Form:

import { Form, Button, Col, Row } from 'antd';

const [form] = Form.useForm();

return (
    <Form
        form={form}
        name="advanced_search"
        style={{ marginBottom: 20 }}
        onFinish={onFinish}
    >
        {fields && fields.length > 0 && (
            <Row gutter={24}>{getFields()}</Row>
        )}
        <Button
            { ...okButtonProps }
            type="primary"
            size="small"
            htmlType="submit"
        >
            {okText || '搜索'}
        </Button>
        <Button
            style={{ margin: '0 8px' }}
            size="small"
            onClick={() => {
                form.resetFields();
                form.setFieldsValue(defaultValue);
            }}
        >
            重置
        </Button>
        {fields && fields.length > count ? (
            <Button
                type="link"
                size="small"
                style={{ padding: '0 4px' }}
                onClick={() => {
                    setExpand(!expand);
                }}
            >
                {expand ? <UpOutlined /> : <DownOutlined />}{' '}
                更多
            </Button>
        ) : null}
    </Form>
)

可以看到, fields 属性存放表单数据,给一个示例:

const fields = [
    {
        label: "名称",
        key: "Name",
        type: "Input",
    },
    {
        label: "年龄",
        key: "Age",
        type: "NumberInput",
    },
]

针对属性的 type,你当然还可以封装一个专门用来展示表单的组件,是Input就显示普通输入框,是NumberInput就显示数字框等。

另外还有重置按钮调用的是form的自带API。更多可以展开多行搜索,还可以收起来,fields区域需要动态展示:

const defaultCount = 4;
const count = rowCount ? rowCount : defaultCount;

const getFields = () => {
    // 根据展开状态来决定显示几行
    const showFields = expand ? fields : fields.slice(0, count);
    const children = [];
    showFields.forEach(item => {
        if (!item.hidden) {
            children.push(
                <Col span={item.span ? item.span : 6} key={item.key}>
                    <Form.Item
                        name={item.key}
                        label={item.label}
                        required={item.required}
                    >
                        // 显示表单组件的组件 
                        {FormItemComponent(item)}
                    </Form.Item>
                </Col>
            );
        }
    });
    return children;
};

最后点击搜索按钮后,会触发表单的submit事件,自动调用onFinish:

const onFinish = values => {
    onSearch(values);
};

在XTable中:

const [filters, setFilters] = useState(defaultFilters);

const onSearch = (value) => {
    setFilters(value);
    const resetPosition = {
        current: 1,
        pageSize: position.pageSize,
        total: totalCount
    };
    setPosition(resetPosition);

    getList(value, resetPosition);
}

搜索后默认回到第一页,重置分页器,并且带上参数请求API。

表格还可以定义 leftActions、rightActions等扩展属性,用于表格的操作。同时还可以向上边图示那样,写一个Popover来动态展示当前可显示的列,并且将选择结果存在localStorage中,用于保存用户使用状态。


常见的通用组件就这么多了,有新的会随后补充~~