基于 Antd 封装业务 Upload 组件

Featured Image

前言

我们的后台系统都是基于 Antd Design 开发的。最近做的新系统里有比较多的场景需要使用到附件上传的功能,我们针对 Antd 的 <Upload /> 组件在项目里进行了业务的封装。过程中也碰到些问题,遂总结于本文中。

基本使用

我们主要是用到了它多文件上传和功能。

import React from 'react';
import { Upload } from 'antd';

return () => {
  const [fileList, setFileList] = useState([
    {
      uid: '1',
      name: '1.txt',
      status: 'done',
      url: 'https://www.baidu.com',
    },
  ]);

  const handleChange = info => {
    let fileList = info.fileList.slice();
    
    fileList = fileList.map(file => {
      if (file.response) {
        // Component will show file.url as link
        file.url = file.response.url;
      }
      return file;
    });

    setFileList(fileList);
  };

  return (
    <Upload
      action="https://www.mocky.io/v2/5cc8019d300000980a055e76"
      fileList={fileList}
      onChange={handleChange}
    />
  );
};

这是官方文档中提供的示例,我们可以通过 action 属性定义上传的地址,通过 onChange 获取上传后的文件地址以及 fileList 设置上传文件。其中 onChange 以及 fileList 参数类型如下。

import { RcFile as OriRcFile } from 'rc-upload/es/interface';

export interface UploadFile<T = any> {
  uid: string;
  size?: number;
  name: string;
  fileName?: string;
  lastModified?: number;
  lastModifiedDate?: Date;
  url?: string;
  status?: UploadFileStatus;
  percent?: number;
  thumbUrl?: string;
  originFileObj?: RcFile;
  response?: T;
  error?: any;
  linkProps?: any;
  type?: string;
  xhr?: T;
  preview?: string;
}

export interface RcFile extends OriRcFile {
  readonly lastModifiedDate: Date;
}

export type UploadFileStatus = 'error' | 'success' | 'done' | 'uploading' | 'removed';

export interface UploadChangeParam<T extends object = UploadFile> {
  // https://github.com/ant-design/ant-design/issues/14420
  file: T;
  fileList: UploadFile[];
  event?: { percent: number };
}

UploadFile 是主要的类型,最外层是组件包装的一些数据,包括 status, percent 等用于记录下载状态字段。其中还有 response 字段,当下载完成 status === 'done' 的时候,该字段会存储服务端返回的相应数据。

需求描述

中后台场景会有大量的表单场景,其中我们的大部分附件提交都是在表单之中。当然我们的表单也是使用的 Antd 组件。它提供了类似于原生 <form /> 的一套模式,你不需要关心表单的交互,当使用 <Form.Item name="" /> 包裹之后,就会自动认为你是表单元素,在 onFinish 事件中可以达到所有提交后的表单数据。而通过initialValues 属性又可以对整个表单设置初值。简单的通过这两个属性就可以实现表单的大多数需求。

impoprt React from 'react';
import {Form, Input, Upload} from 'antd';

export default function() {
  const initialValues = {
    remark: 'hello', 
    attachment: {
      attachmentNo: 1234,
      fileKey: 2345
    }
  };
  
  return (
    <Form onFinish={onFinish} initialValues={initialValues}>
      <Form.Item name="remark" label="说明">
        <Input.Textarea />
      </Form>
      <Form.Item name="attachment" label="附件">
        <Upload />
      </Form>
      <Button>提交</Button>
    </Form>
  );
}

这套表单的方式让上层交互变的非常纯粹,所以我期望我们封装的组件也最好能适配这套逻辑。而这里的矛盾点在于,我们需要的是 UploadFile['response']['data'] 中的数据,但是当我们要给它赋值的时候,它接收的是 UploadFile[] 的数据格式。所以除了封装业务的配置之外,还需要将数据格式转换的逻辑封装进去。

思考实现

最开始我想的设想类似于下面这个 Demo,只需要定义 uploadFile2valuevalue2UploadFile 两个方法,用于处理数据的双向转换即可。

import { Upload } from 'antd';

const Upload = React.memo(({onChange}) => (
  <Upload 
    fileList={value2UploadFile} 
    onChange={e => onChange(uploadFile2value(e.fileList))} 
  />
));

但是我没想到的是,文件上传是一个异步的过程,最终 onChange 接收到的 fileList 数据是一组多状态数据的集合,具体的状态列表如下。

export type UploadFileStatus = 'error' | 'success' | 'done' | 'uploading' | 'removed';

根据预期效果,显然我想要的是 status=done 后的数据。而如果我在 uploadFile2value 中对数据做过滤仅将 status=done 的数据返回给出去的话,在之后的渲染中 initialValues 传过来的初始数据中则不包含其它状态的数据了,会导致传入的 fileList 数据异常。这样我们就陷入了一种死循环,刚上传文件文件状态是 uploading 然后被 onChange 过滤为空数据传出,之后空数据作为初始数据再次被传入上传中的文件状态丢失组件回复到初始状态……

最终实现

后台维护组件库的小伙伴提醒了我,既然组件本身需要所有的数据,而外部只需要上传完成的数据,那我们可以考虑将所有的数据在组件内部自行维护,仅将外部组件需要的数据传递出去。当外部数据传入进来的时候,将其与内部数据做合并即可。

import React, { useEffect, useState } from 'react';
import { Upload } from 'antd';

const value2UploadFile = record => ({ 
  uid: record.id, 
  name: record.name, 
  status: 'done', 
  response: { code: 0, msg: '', data: record }
});

function useUpload(files, onChange) {
  const [filePool,setFilePool] = useState([]);

  useEffect(() => {
    if(!Array.isArray(files) || files.length === 0) {
      return;
    }

    setFilePool(filePool => {
      const fileIds = filePool.filter(({status}) => status === 'done').map(file => file.response?.data?.id);
      const appendFiles = files.filter(({id}) => !fileIds.includes(id)).map(value2UploadFile);
      return [...filePool, ...appendFiles];
    });
  }, [files]);

  const handleUploadChange = ({fileList}) => {
    fileList.filter(({status, response}) => 
      status === 'done' && response.code !== 0
    ).forEach(file => {
      file.status = 'error';
    });
    setFilePool(fileList);
    
    const doneFiles = fileList.filter(({status}) => status === 'done').map(file => file.response.data);
    onChange(doneFiles);
  }
  
  return [filePool, handleUploadChange];
} 

export default function({value, onChange, ...props}) {
  const [filePool, onFileChange] = useUpload(value, onChange);
  
  return (
    <Upload
      listType="picture"
      btnType="default"
      btnText="上传文件"
      {...props}
      
      fileList={filePool}
      onChange={handleUploadChange}
      withCredentials
      action="/api/file/upload"
    />
  );
}

可以看到我们内部增加了 filePool 的状态用来存储数据,每次内部都会全量的存储待上传的文件列表,但是最终调用外部的 onChange 方法回传出去的时候则只会传出 status=done 的数据。而针对赋值的场景,我们鉴定了 files 的变化,根据最终返回数据的 id 获取到 fileIds 内部已存在的文件,然后再使用这个和传入的数据进行 diff 比较,查看是否有新增的数据。如果存在新增的数据则将其转换成组件需要的数据格式后更新文件列表。

通过以上操作,我们就将上传组件的逻辑封装在了内部组件中。甚至我们还能在内部增加当接口返回非 0 的 code 上传失败的时候我们会将组件数据状态修改为 error 而不是 done。最终外部组件不需要关心上传接口本身内部的逻辑,只需要关系上传之后得到的数据即可,达到了业务上传组件解耦的目的。

Avatar
怡红公子 擅长前端和 Node.js 服务端方向。热爱开源时常在 Github 上活跃,也是博客爱好者,喜欢将所学内容总结成文章分享给他人。

2 评论

2021+00-24T04:50:21.926Z 回复

看到80%的WordPress博客,都是讲前端开发的

2021+00-24T05:08:04.029Z 回复

@银行理财 , 可是我的也不是 WP 呀……