如何使用 antd 的 Upload 将图片上传到七牛云?

824 阅读3分钟

线上源码

上传七牛组件-QiniuUpImg

详细源码可见上链接中的 components/QiniuUpImg.tsx 文件

要点

获取七牛上传文件的 token

跟后端同事沟通接口,看怎样获取七牛上传文件的 token

可以每次加载组件都请求一次 token,或者将 token 放在缓存中,过期再重新请求

beforeUpload

上传文件之前的钩子,如果开启了 multiple 多选文件的话,选了几个文件,这个函数就执行几次

在这个钩子中比较关键的是给每个 file 对象添加 fileKey 属性(这个属性叫什么不重要,大家可以随便修改)。fileKey 是上传七牛需要用到的 key (详情可以查看七牛云直传文件API文档),而这个 key 本质上就是文件在七牛云上存储的文件名(资源名),唯一的要求就是不能重复。

目前选择了这样的格式定义 key

crm/20211107/95269c8d-0d3a-4ba1-acb7-4f8389a009e3.png

  • crm 可以根据自己需要修改; (重点:crm 前不要加 / 不然七牛那边会有问题)
  • 20211107 是对应上传当前客户端的日期,方便在七牛上做好文件夹分类
  • 95269c8d-0d3a-4ba1-acb7-4f8389a009e3uuid 生成的一串随机码
  • .png 是源文件的后缀

onChange

核心需求点:

封装的 QiniuUpImg 组件,我希望对外暴露的只是上传完七牛之后得到的线上 URL 地址,而不是复杂的 file 对象

基于这点需求,在 onChange 中做了以下控制:

  1. Upload 变成受控组件
    • 做成受控组件后,对外暴露什么都可以我们自己定义,符合需求
    • PS:一定要在 onChange 中同步修改受控的 fileList ,不然在上传文件时, onChange 函数只会执行一次
  2. 过滤掉 status === 'error' 的情况,这种情况判断 token 是否已经过期
  3. 过滤掉 status === 'uploading' 的情况,如果上传队列中有文件处于这种状态,会一直执行 onChange 函数
  4. (status === 'done' || status === 'removed') && !isFileListHadUploading ,(文件已经上传完成或者被删除) 并且 (上传队列中没有 status === 'uploading' 状态) 时,循环文件队列,找到线上 URL,并对外暴露出去

useEffect

怎样初始化 fileList ? 何时重置 fileList

/**
 * 将 props 中的 value (父组件)传来的值格式化成 Upload 组件可以读懂的数据格式
 *    具体可以参考: https://ant.design/components/upload-cn/#UploadFile
 * @param value
 * @returns
 */
const formatValue = (value: string | [] | void): any[] => {
  if (!value) {
    return [];
  }

  const fileList = lodash.isArray(value) ? lodash.cloneDeep(value) : [value];

  return fileList.map((item) => {
    const uid = uuidv4();
    const exp = /(\.(\w+)$)|(\.(\w+)(?=\?))/g; // 匹配 url 中的后缀,前一个分组匹配的是没有?参数的情况,后一个分组匹配有?参数的情况
    const ext = item?.match?.(exp)?.[0] || '';

    return {
      uid,
      name: `imgs-${uid}${ext}`,
      status: 'done',
      url: item,
      thumbUrl: item,
    };
  });
}

useEffect(() => {
  // 只在 value 跟prevValue 不相等,并且跟 curFileList 也不相等的时候才会重置 curFileList
  if (!lodash.isEqual(prevValue, value) && !isValueAndFileListEqual(value, curFileList)) {
    console.log('upload-prevValue', prevValue);
    console.log('upload-value', value);
    console.log('upload-curFileList', curFileList);

    setCurFileList(formatValue(value));
  }
}, [value]);

源码中这两段比较关键,可以结合源码查看注释

源码

为了避免线上源码网络访问不佳,补充一份(跟线上源码一样)

/*
 * @Author: your name
 * @Date: 2021-11-04 18:48:02
 * @LastEditTime: 2021-11-05 15:41:15
 * @LastEditors: Please set LastEditors
 * @Description: 将图片直传到七牛云图床
 */
import React, { useState, useEffect, useRef } from 'react';
import { v4 as uuidv4 } from 'uuid';
import lodash from 'lodash';

import { PlusOutlined, UploadOutlined } from '@ant-design/icons';
import { Upload, Modal, message, Button } from 'antd';

// import request from '@/utils/request'; 可以换成其它 fetch 库

/**
 * 保存前一个状态的值
 * @param value 任意 state 的值
 * @returns 前一个状态的值
 */
const usePrevious = (value: any) => {
  const ref = useRef();

  useEffect(() => {
    if (!lodash.isEqual(ref.current, value)) {
      ref.current = lodash.cloneDeep(value);
    }
  });

  return ref.current;
};

/**
 * 获得一个根据 uuid 生成的唯一文件名,避免上传到七牛后文件名字有冲突
 * 格式: crm/20211107/95269c8d-0d3a-4ba1-acb7-4f8389a009e3.png
 *    ps:
 *      - crm 可以根据自己需要修改; (重点:crm 前不要加 / 不然七牛那边会有问题)
 *      - 20211107 是对应上传当前客户端的日期,方便在七牛上做好文件夹分类
 * @param file 文件对象
 * @returns
 */
const getUuidFileName = (file = {}) => {
  const nowDate = new Date();
  const year = nowDate.getFullYear();
  let month = nowDate.getMonth() + 1;
  let date = nowDate.getDate();
  let curr = '';
  const suffix = uuidv4();
  const fileName = file.name || '';
  const exp = /\.(\w+)$/g;
  const ext = fileName?.match?.(exp)[0] || '';

  month = month < 10 ? `0${month}` : month;
  date = date < 10 ? `0${date}` : date;
  curr = `${year}${month}${date}`;

  return encodeURI(`crm/${curr}/${suffix}${ext}`);
};

/**
 * 将 props 中的 value (父组件)传来的值格式化成 Upload 组件可以读懂的数据格式
 *    具体可以参考: https://ant.design/components/upload-cn/#UploadFile
 * @param value
 * @returns
 */
const formatValue = (value: string | [] | void): any[] => {
  if (!value) {
    return [];
  }

  const fileList = lodash.isArray(value) ? lodash.cloneDeep(value) : [value];

  return fileList.map((item) => {
    const uid = uuidv4();
    const exp = /(\.(\w+)$)|(\.(\w+)(?=\?))/g; // 匹配 url 中的后缀,前一个分组匹配的是没有?参数的情况,后一个分组匹配有?参数的情况
    const ext = item?.match?.(exp)?.[0] || '';

    return {
      uid,
      name: `imgs-${uid}${ext}`,
      status: 'done',
      url: item,
      thumbUrl: item,
    };
  });
};

/**
 * 判读当前传进来的 value 是否跟组件的 curFileList 相等
 *  value 是 url 字符串,直接跟 curFileList 中各项的 url 对比,看是否相等
 * @param value 父组件传进来的 url 字符串(或 url 字符串数组)
 * @param fileList 组件当前的 curFileList
 * @returns boolean
 */
const isValueAndFileListEqual = (value, fileList) => {
  let result = true;

  // fileList 至少也是一个数组,如果 value 没有值,两者肯定不相等
  if (!value) {
    return false;
  }

  const valueList = lodash.isArray(value) ? value : [value];

  // 判断 fileList 中每一项的 url 或 fileKey 是否都能在 valueList 中找到,让若有一个找不到的,两者都不相等
  for (const item of fileList) {
    const { fileKey, url } = item;
    const fileKeyToUlr = `${QINIU_URL}/${fileKey}`;
    const targetUlr = fileKey ? fileKeyToUlr : url;

    // 一般不会有这种情况
    if (!fileKey && !url) {
      result = false;
      break;
    }

    if (!valueList.includes(targetUlr)) {
      result = false;
      break;
    }
  }

  return result;
};

const verifyFileType = (file, accept) => {
  const { type } = file;
  const ext = type.split('/')?.[1] || '';

  if (accept === 'image/*') {
    return true;
  }

  if (!accept.includes(ext.toLocaleLowerCase())) {
    return false;
  }

  return true;
};

const verifyFileSize = (file, maxsize) => {
  const isOverload = file.size / 1024 / 1024 > maxsize; // true: 超重,不通过验证

  return !isOverload;
};

export interface Props {
  value?: string | [];
  [propName: string]: any;
}

const QiniuUpImg: React.FC<Props> = (props) => {
  const {
    action = QINIU_UPLOAD_API,
    accept = '.jpg, .jpeg, .png .gif',
    limit = 1,
    multiple = true,
    listType = 'picture-card',
    errorMsg = '图片上传失败',
    maxsize = 2, // 默认图片最大为2M

    value,
    onChange,
  } = props;
  const filterProps = lodash.omit(props, [
    'action',
    'accept',
    'limit',
    'multiple',
    'listType',
    'errorMsg',
    'maxsize',
    'value',
    'onChange',
  ]);

  const prevValue = usePrevious(value);

  const [token, setToken] = useState('');
  const [curFileList, setCurFileList] = useState(formatValue(value));
  const [previewImage, setPreviewImage] = useState('');
  const [isPreviewVisible, setIsPreviewVisible] = useState(false);

  const getQiNiuToken = () => {
    // 获取七牛上传文件的 token,具体可以跟后端同学协商接口
    // request('/getQiNiuToken').then((res) => {
    //   setToken(res.data || '');
    // });
  };

  const debGetQiNiuToken = lodash.debounce(getQiNiuToken, 2000, {
    leading: true,
    trailing: false,
  });

  const getExtraData = (file) => {
    return {
      key: file.fileKey,
      token,
    };
  };

  const errorHandler = (error) => {
    const { status } = error || {};

    // 上传七牛的 token 过期,重新获取
    if (status === 401) {
      debGetQiNiuToken();
      return;
    }
  };

  /**
   * 上传文件之前的钩子,如果开启了 multiple 多选文件的话,选了几个文件,这个函数就是执行几次。另外,不要直接 copy file 对象,也不要修改 file 对象的只读属性,可以额外增加自己需要的属性
   *    具体可以参考:https://ant.design/components/upload-cn/#API
   * @param file
   * @param fileList
   * @returns
   */
  const handleBeforeUpload = (file, fileList) => {
    if (!verifyFileType(file, accept)) {
      message.error(`图片只支持 ${accept} 格式`);
      return false;
    }

    if (!verifyFileSize(file, maxsize)) {
      message.error(`图片最大为${maxsize}M`);
      return false;
    }

    const fileKey = getUuidFileName(file);

    file.fileKey = fileKey; // file 对象添加上传七牛需要用到的 key

    return file;
  };

  /**
   * 这个函数很关键,按以下逻辑步骤执行:
   *    1. setCurFileList(fileList); 整个 Upload 的封装采用了受控的方式,通过设置当前的 curFileList,让处于 uploading 状态的文件可以多次触发 onChange 函数
   *    2. 错误检查,目前主要是核对 token 有没有过期,如果有需要,可以增加更多
   *    3. 将上传完成的图片 url 返回给父组件
   *      - 这一步也很关键,设计 QiniuUpImg 的初衷之一是为了避免把多余的内容返回给父组件,毕竟都已经上传到七牛,直接返回一个 url 地址就够用了
   *      - 筛选 fileList ,只将处于 done 状态的 file,读取 url 并返回(ps:注意处理处于 removed 和 uploading 状态的 file)
   * @param param0
   * @returns
   */
  const handleChange = ({ file, fileList }) => {
    const { status, error } = file;
    const doneFileUrl: string[] = [];
    const isFileListHadUploading = lodash.some(fileList, {
      status: 'uploading',
    });

    if (!status) {
      return;
    }

    setCurFileList(fileList);

    if (status === 'error') {
      message.error(errorMsg, 2);

      errorHandler(error);
      return;
    }

    // 只将上传完成的图片 url 返回给父组件; 如果列表中有 status: 'uploading' 的项,还会继续执行 onChange 函数,所以要等所有文件都上传完
    if (
      (status === 'done' || status === 'removed') &&
      !isFileListHadUploading
    ) {
      fileList.forEach((item) => {
        const { url, response, status: itemStatus } = item;
        const { key } = response || {};
        const tempUrl = response ? `${QINIU_URL}/${key}` : url; // QINIU_URL 换成你对应七牛存储空间的访问域名即可

        if (itemStatus === 'done' && tempUrl) {
          doneFileUrl.push(tempUrl);
        }
      });

      onChange?.(doneFileUrl, fileList);
    }
  };

  const handlePreview = (file) => {
    const { url, thumbUrl } = file;

    if (!url && !thumbUrl) {
      return;
    }

    setPreviewImage(url || thumbUrl);
    setIsPreviewVisible(true);
  };

  const handleCancel = () => {
    setIsPreviewVisible(false);
  };

  useEffect(() => {
    // 只在 value 跟prevValue 不相等,并且跟 curFileList 也不相等的时候才会重置 curFileList
    if (
      !lodash.isEqual(prevValue, value) &&
      !isValueAndFileListEqual(value, curFileList)
    ) {
      console.log('upload-prevValue', prevValue);
      console.log('upload-value', value);
      console.log('upload-curFileList', curFileList);

      setCurFileList(formatValue(value));
    }
  }, [value]);

  useEffect(() => {
    getQiNiuToken();
  }, []);

  const renderUploadButton = () => {
    return (
      <>
        {listType === 'picture-card' ? (
          <div>
            <PlusOutlined />
            <div style={{ marginTop: 8 }}>上传</div>
          </div>
        ) : (
          <Button icon={<UploadOutlined />}>上传</Button>
        )}
      </>
    );
  };

  return (
    <>
      <Upload
        action={action}
        accept={accept}
        multiple={multiple}
        listType={listType}
        fileList={curFileList}
        data={getExtraData}
        beforeUpload={handleBeforeUpload}
        onChange={handleChange}
        onPreview={handlePreview}
        {...filterProps}
      >
        {curFileList.length >= limit ? null : renderUploadButton()}
      </Upload>

      <Modal
        visible={isPreviewVisible}
        title="预览"
        footer={null}
        onCancel={handleCancel}
      >
        <img alt="example" style={{ width: '100%' }} src={previewImage} />
      </Modal>
    </>
  );
};

export default QiniuUpImg;