React, Typescript构建Upload组件

在平常的业务开发中, 我们都会借助一些开源的组件库加速开发速度, 开源市场中比较知名的像Ant Design React/Vue, Element-ui都已经为我们提供了非常成熟完善的组件, 但我们开发时不仅要知道组件如何使用同时要明白组件背后的原理为自己封装组件提供技术积累.

这篇文章将用React和Typescript来构建一个和Ant Design React/Vue Upload组件大似相同的Upload组件来帮助你理解如何构建组件!

首先我们从最基本的上传组件开始.创建Upload组件.

基本版

<input type="file" />

我们最基本的Upload组件只需要一个input将其type设置为file即可. 这非常简单同时非常丑陋而且提供的信息不够, 像Ant Design Upload组件有上传状态, 样式好看, 操作事件完整还提供拖拽功能, 那么我们如何在基本版上构建一个这样的组件呢, 接下来我们一步一步完善.

使用Typescript声明基本props

首先我们为上传文件不同阶段提供不同的状态, 比如正在上传中uploading, 成功success, 报错error等等

type UploadFileStatus = 'ready' | 'uploading' | 'success' | 'error'

同时上传文件需要自定义自己的属性, 像文件id, 文件名, 文件大小等这些属性将在后续使用

interface UploadFile {
  uid: string;
  size: string;
  name: string;
}

接着我们声明Upload组件必须的属性

interface UploadProps {
  action: string;
  defaultFileList?: UploadFile[];
  beforeUpload?: (file: UploadFile) => boolean | Promise<File>;
  onSuccess?: (data: any, file: UploadFile) => void;
  onError?: (data: any, file: UploadFile) => void;
  onChange?: (file: UploadFile) => void;
  name?: string;
  data: {[key:string]: any};
  multiple?: boolean;
  accept?: string
}

action为上传地址, defaultFileList表示上传可能是单个也可能是多个, beforeUpload上传之前的处理函数, onSuccess, onError成功失败处理函数, onChange上传文件改变的时候触发的函数等等

升级Upload

这里我们不直接使用input而是借助一个button按钮来触发上传操作

import React, {useRef, HTMLInputElement} from 'react'

const Upload: React.FC<UploadProps> = (props) => {
  const {
    action,
    defaultFileList,
    beforeUpload,
    onSuccess,
    onError,
    onChange,
    name,
    data,
    accept,
    multiple
  } = props

  // 获取input实例
  const fileInputRef = useRef<HTMLInputElement>(null)
  // 点击button时其实就是触发了input
  const handleClick = () => {
   if (inputFileRef.current) {
     inputFileRef.current.click()
   }
  }

  const handleChangeFile = (e: React.ChangeEvent<HTMLInputHTML>) => {
    const files = e.target.files
    if (!files) return
    uploadFiles(files)
    // 清空上传文件
    if (fileInputRef.current) {
      fileInput.current.value = ''
    }
  }

  const uploadFiles = (files: FileList) => {
    let postFiles = Array.from(files)
    postFiles.forEach(file => {
      if (!beforeUpload) {
        postFileUpload(file)
      } else {
        const result = beforeUpload(file)
        if (result && result instanceof Promise) {
          result.then(...)
        } else if (result !== false) {
          postFileUpload(file)
        }
      }
    })
  }

  const postFileUpload = () => {}

  <input
    type="file"
    style={{display: 'none'}}
    onChange={handleChangeFile}
    ref={fileInputRef}
    accept={accept}
    multiple={multiple}
  />
  <button onClick={handleClick}>Upload File</button>
}

当我们点击button时触发handleClick选择上传文件, 上传时触发uploadFiles. 在uploadFiles中对于异步上传我们需要单独处理. 最后就是ajax上传了.

到此我们的Upload组件level已经上了档次了但还是不够, 如果用户想看见上传进度, 并且想看见上传的文件而且还能做移除操作就更完美了. 接下来我们继续完善!

再上一个等圾

为了完成上面要求我们需要增加一些属性来作为支持.

interface UploadFile {
  ...
  raw: File;
  percent?: number;
  response?: any;
  error?: any
}

interface UploadProps {
  ...
  onRemove?: (file: UploadFile) => void;
  onProgress?: (percentage: number, file: UploadFile) => void;
}

const Upload: React.FC<UploadProps> = (props) => {
  const {
    ...
    onRemove,
    onProgress,
    children
  } = props

  // 展示的文件列表
  const [fileList, setFileList] = useState<UploadFile>(defaultFileList || [])

  const uploadFile = (files: FileList) => {
    let postFiles = Array.from(files)
    postFiles.forEach(file => {
      if (!beforeUpload) {
        postFileUpload(file)
      } else {
        const result = beforeUpload(file)
        if (result && result instanceof Promise) {
          // 从beforeUplaod返回的数据中获取上传文件的详细信息
          result.then(processFile => {
            postFileUpload(procesFile)
          })
        } else if (result !== false) {
          postFileUpload(file)
        }
      }
    })
  }

  const postFileUpload = (file: UploadFile) => {
    let _file: UploadFile = {
      uid: Date.now() + 'upload_file',
      name: file.name,
      status: 'ready',
      size: file.size,
      percent: 0,
      raw: file
    }

    const formData = new FormData()
    formData.append(name || 'file', file)
    if (data) {
      Object.keys(data).forEach(key => {
        formData.append(key, data[key])
      })
    }

    axios.post(action, formData, {
      onUploadProgress: (e) => {
        // 上传进度百分比
        let percentage = Math.round((e.loaded * 100) / e.total) || 0
        if (percentage < 100) {
          ...
          if (onProgress) {
            onProgress(percenage, file)
          }
        }
      }
    }).then(res => {
      ...
      if (onSuccess) {
        onSuccess(res.data, file)
      }
      if (onChange) {
        onChange(file)
      }
    }).catch(err => {
      ...
      if (onError) {
        onError(err, file)
      }
      if (onChange) {
        onChange(file)
      }
    })
  }
}

上面代码中我们从beforeUpload中获取到上传文件的详细信息作为上传时提交的参数, 比如name, size, 最后提交后onUploadProgress回调中会返回上传文件的进度, 我们根据上传进度来时时更新文件列表. 接下来完成文件列表的更新!

...
cosnt postFileUpload = (file: UploadFile) => {
  ...

  const setFileList = (prevFileList) => {
    return [_file, ...prevFileList]
  }

  axios.post(action, formData, {
    onUploadProgress: e => {
      ...
      if (percentage < 100) {
        uploadFileList(_file, {
          percent: percentage,
          status: 'uploading'
        })
        if (onProgress) {
          onProgress(percentage, file)
        }
      }
    }
  }).then(res => {
    uploadFileList(_file, {
      response: res.data,
      status: 'success'
    })
    if (onSuccess) {
      onSuccess(res.data, file)
    }
    ...
  }).catch(err => {
    uploadFileList(_file, {
      error: err,
      status: 'error'
    })
    ...
  })
}
const uploadFileList = (updateFile: UploadFile, updateObj: Partial<UploadFile>) => {
  setFileList(prevFileList => {
    return prevFileList.map(file => {
      if (file.uid === updateFile.uid) {
        return {...file, ...updateObj}
      } else {
        return file
      }
    })
  })
}

const handleRemove = (file: UploadFile) => {
  setFileList(prevFileList => {
    return prevFileList.filter(item => {
      return item.uid !== file.uid 
    })
  })
  if (onRemove) onRemove(file)
}

...
return (
  ...
  <UploadList fileList={fileList} onRemove={handleRemove}>
)

到此我们的上传进度和移除文件基本完成. 接下来为了减少用户使用时手动增加配置, 我们增加一些配置, 比如axios提交时是否设置headers, 是否需要配置withCredentials允许跨站点请求

interface UploadProps {
  headers?: {[key: string]: any};
  withCredentials?: boolean;
}

const Upload: React.FC<UploadProps> = (props) => {
  const {..., headers, withCredentials} = props
}

cosnt postFileUpload = (file: UploadFile) => {
  axois.post(_, _, {
    headers: {
      ...headers,
      'Content-Type': 'multipart/form-data'
    },
    withCredentials,
    onUploadProgress: (e) => {...}
  })
}

目前我们的Uplaod组件以及完成百分之八十了, 本着一切为用户服务的思想, 我们在此基础上提供一个新的功能—拖拽, 让用户使用更加方便.

拖拽上传

为了实现拖拽功能我们需要新增拖拽属性drag

interface UploadProps {
  ...
  drag?: boolean;
}

interface DraggerProps {
  onFile: (file: FileList) => void;
}

const Upload: React.FC<UploadProps> = (props) => {
  const {..., drag} = props

  return (
    ...
    {
      drag ? <Dragger onFile={files => {uploadFiles(file)}}>{children}</Dragger>
    }
  )
}

我们将拖拽功能单独提取出来写在Dragger组件中

const Dragger: React.FC<DraggerProps> = (props) => {
  const {onFile, children} = props

  const [dragOver, setDragOver] = useState(false)
  const dragClass = classNames('r-upload-drag', {
    'is-dragover': dragOver
  })

  const handleDrap = (e: DragEvent<HTMLElement>) => {
    e.preventDefault()
    setDragOver(false)
    onFile(e.dataTransfer.files)
  }

  const handleDrag = (e: DragEvent<HTMLElement>, over: boolean) => {
    e.preventDefault()
    setDragOver(over)
  }

  return (
    <div
      className={dragClass}
      onDragOver={e => {handleDrag(e, true)}}
      onDragLeave={e => {handleDrag(e, false)}}
      onDrap={handleDrap}
    >{children}</div>
  )
}

最后我们对Upload组件做个善后工作, 我们去掉button还是用input来做上传

const Upload: React.FC<UploadProps> = (props) => {
  ...
  return (
    <div className="r-upload-component">
      <div
        className="r-upload-input"
        style={{display: "inline-block"}}
        onClick={handleClick}
      >
        {
          drag ? <Dragger onFile={files => {uploadFiles(file)}}>{children}</Dragger>
        }
        <input
          type="file"
          className="r-file-input"
          style={{display: 'none'}}
          onChange={handleChangeFile}
          ref={fileInputRef}
          accept={accept}
          multiple={multiple}
        />
      </div>
      <UploadList fileList={fileList} onRemove={handleRemove} />
    </div>
  )
}

UploadList组件就是上传展示给用户的文件列表, 我们会根据上传不同的状态进行展示

const UploadList: React.FC<UploadListProps> = (props) => {
  const {fileList, onRemove} = props

  return (
    <ul className="r-upload-list>
      {
        fileList.map(item => {
          return (
            <li className="r-upload-list-item" key={item.uid}>
              <span>
                <Icon icon="ion-alt" />
                {item.name}
              </span>
              <span className="file-status>
                {
                  item.status === 'uploading' &&
                  <Icon icon="spinner" spin theme="primary" />
                }
                {
                  item.status === 'success' &&
                  <Icon icon="check-circle" spin theme="success" />
                }
                {
                  item.status === 'error' &&
                  <Icon icon="tims-circle" spin theme="danger" />
                }
              </span>
              <span className="file-action">
                <Icon icon="times" onClick={() => onRemove(item)} />
              </span>
              {
                item.status === 'uploading' && <Progress percent={item.percent || 0} />
              }
            </li>
          )
        })
      }
    </ul>
  )
}

ok, 一个具备点击, 拖拽上传的Uplaod组件就此完成了, 回顾开始我们想要完成一个类似与Ant Design React/Vue组件库Upload组件大致相同的上传组件, 我们根据上传组件所需的功能将其进行细化拆分最后用代码一步步实现, 虽然组件我们编写完成了, 但一个完整的组件不单单是实现其功能就算完成, 下一步我们将为其编写单元测试来测试我们的上传组件是否真是可用.