从零实现基于React+TS的Upload组件

1,883 阅读5分钟

有没有人跟我一样,每次面试必会被问到如何实现一个上传组件。在实际工作中,有很多成熟的UI组件库供我们选择,很少会自己去开发。今天咱必须从零到一实现一个。以下这个Upload组件具备基础的上传功能,支持多选文件,支持拖拽上传,支持进度展示。当然肯定没有人家UI组件库功能齐全,主要是为了锻炼组件开发能力。

上传一个文件的生命周期

首先,设计一个upload的组件,我们可以从用户角度出发,在小本本上画一画上传一个文件需要经历哪些阶段。

upload.png

  1. beforeUpload:在上传前检查文件类型,文件大小是否符合某种特定的规则
  2. onProgress:正在上传中,展示上传进度
  3. onChange:上传完毕,不管成功还是失败都会触发
  4. onSuccess:上传成功时触发,弹出提示信息
  5. onRemoved:点击上传好的文件进行删除
  6. onError:上传失败时触发,弹出错误信息

整理好思路之后,就非常清楚自己的组件大概长这样:

<Upload
   action='https://upload'
   beforeUpload={()=>{}}
   onProgress={()=>{}}
   onChange={()=>{}}
   onSuccess={()=>{}}
   onRemoved={()=>{}}
   onError={()=>{}}
>
  <Button>Click to Upload</Button>
  //children 用户可自定义
</Upload>

异步请求方式的选择

1. 原生XHR和$ajax

  1. 原生XHR时浏览器发送异步请求的一个对象,不是很友好,不符合关注点分离原则,比较繁琐,配置和调用混乱。
  2. $ajax是对jquery对XHR的封装。如果单为一个ajax功能而引入jQuery得不偿失。

2. fetch

  1. 只对网络请求报错,对400,500都当作成功的请求
  2. 默认不会带cookie
  3. 不支持abort,不支持超时控制,即使超时,也不会abort,造成请求资源的浪费
  4. 没有办法原生监测请求的进度,xhr可以

3. axios

  1. 既支持浏览器(XMLHttpRequest),也支持nodeJs(http模块)环境
  2. 完全支持标准的Promise API
  3. 可以拦截请求和响应
  4. 可以转换请求和Response Data
  5. 可以取消请求
  6. 可以监测请求进度

上传文件的基本原理

上传文件其实就是使用异步请求完成文件的上传

  1. 选择文件:<input type="file"/>
  2. 文件上传的两种方式:
    • Form Submit方式
    • 使用JavaScript发送异步请求

Form Submit

<form method="post" encType="multipart/form-data" action="https://xxx">
   <input type="file" name="myFile"/>
   <button type="submit">Submit</button>
</form>

encType:

  • application/x-www-form-urlencoded (默认)
  • multipart/form-data (传送二进制文件必须要设置成这个)

当点击提交按钮的时候,就会执行from的默认行为,带着input中的数据,直接发送一份特定的http Request请求到action对应的url中,服务端收到请求,做出对应的处理,再返回结果。

JavaScript模拟form

提交form就是发送http异步请求,使用Javascript可以模拟表单发送一个http请求,相比与form能够有更好的体验。

<input type="file" name="myFile" onChange={handleChange} />

const handleChange=(e:React.changeEvent<HTMLInputELement>)=>{
  const files=e.target.files  // 支持上传多个文件,fileList是一个类数组
  if(files){
      const uploadFile=files[0]
      const formData=new FormData() // js创建form数据
      formData.append(uploadFile.name,uploadFile)
      axios.post('https://xxx',formData,{
        headers:{'Content-Type':'multipart/form-data'}
      }).then(resp=>{
        console.log(resp)
      })
   }
}

实现思路:

  1. 将fileInput隐藏掉,在点击触发上传的元素上,通过JS控制fileInput的dom节点,触发它的click,从而实现点击input效果。
  2. 在input的onChange事件触发时添加文件,给onChange事件绑定uploadFiles函数,用于文件的上传,在uploadFiles中,我们使用axios进行异步请求的发送方式,
  3. 在发送请求之前暴露beforeUpload,此时可以拿到要上传的文件,使用者可以做一些验证,返回Boolean,表示验证成功或失败。此时文件状态为ready。
  4. axios为我们提供了onUploadProgress事件,用于监控文件上传的进度。在UploadProgress事件中暴露onProgress,改变文件状态为uploading。
  5. 在axios的then回调中,触发onSuccess事件和onChange事件,改变文件状态为success。
  6. 在axios的catch回调中,触发onError和onChange事件,改变文件状态为error。
  7. 在文件列表模板中,我们可以根据文件的不同状态展示不同的icon,当状态为uploading,可以展示进度条。

上传组件实战

首选,我们分解一下组件的结构,除了Upload组件外,还需要其他组件的支持。比如icon的展示(Icon组件),进度条的展示(Progress组件),拖拽上传(Dragger组件),upload又可以分为两部分,一个是上传区域(Upload组件),一个是文件列表(UploadList组件)。

图标-Icon

图标使用的是react-fontawesome,它提供了一套常用的图标集合,支持 fontawesome 所有 free-solid-icons,可以在这里查看所有图标 fontawesome.com/icons?d=gal…

import React from 'react';
import classNames from 'classnames';
import { FontAwesomeIcon, FontAwesomeIconProps } from '@fortawesome/react-fontawesome';

export type ThemeProps = 'primary' | 'secondary' | 'success' | 'info' | 'warning' | 'danger' | 'dark' | 'light'

export interface IconProps extends FontAwesomeIconProps{
  /**
   * 支持框架主题,根据主题显示不同的颜色
   */
  theme?: ThemeProps
}
const Icon: React.FC<IconProps> = (props) => {
  const { className, theme, ...rest } = props
  const classes = classNames('rt-icon', className, {
    [`icon-${theme}`]:theme
  })
  return (
    <FontAwesomeIcon className={classes} {...rest}/>
  )
}
export default Icon

进度条-Progress

import React, { FC } from "react";
import { ThemeProps } from "../Icon/icon";
export interface ProgressProps{
  percent: number; // 进度百分比
  strokeHeight?: number; // 进度条高度
  showText?: boolean; // 是否展示文字
  styles?: React.CSSProperties;
  theme?: ThemeProps; 
}
export const Progress: FC<ProgressProps> = (props) => {
  const {percent,strokeHeight,showText,styles,theme}=props
  return (
    <div className="rt-progress-bar" style={styles}>
      <div className="rt-progress-bar-outer" style={{ height: `${strokeHeight}` }}>
        <div
          className={`rt-progress-bar-inner color-${theme}`}
          style={{ width: `${percent}%` }}
          data-testid="progress"
        >
          {showText && <span className="inner-text">{ `${percent}%`}</span>}
        </div>
      </div>
    </div>
  )
    
}
Progress.defaultProps = {
  strokeHeight: 15,
  showText: true,
  theme:'primary',
}
export default Progress

拖拽-Dragger

import React, { FC, useState, DragEvent } from "react";
import classNames from "classnames";
interface DraggerProps{
  onFile: (files: FileList) => void
}

export const Dragger: FC<DraggerProps> = (props) => {
  const { onFile, children } = props
  const [dargOver, setDragOver] = useState(false)
  const classes = classNames('rt-uploader-dragger', {
    'is-dragover':dargOver
  })
  const handleDrag = (e: DragEvent<HTMLElement>, over: boolean) => {
    e.preventDefault()
    setDragOver(true)
  }
  const handleDrop = (e: DragEvent<HTMLElement>) => {
    e.preventDefault()
    setDragOver(false)
    onFile(e.dataTransfer.files)
  }
  return (
    <div
      className={classes}
      onDragOver={e => { handleDrag(e, true) }}
      onDragLeave={e => { handleDrag(e, false) }}
      onDrop={handleDrop}
      
    >
      {children}
    </div>
  )
}
export default Dragger

文件列表-UploadList

import React, { FC } from "react";
import { UploadFile } from "./upload";
import Icon from '../Icon/icon'
import Progress from '../Progress/progress'

interface UploadListProps{
  fileList: UploadFile[];
  onRemove:(_file:UploadFile)=>void
}
export const UploadList: FC<UploadListProps> = (props) => {
  const {fileList,onRemove}=props
  return (
    <ul className="rt-upload-list">
      {fileList.map((item) => {
        return (
          <li className="rt-upload-list-item" key={item.uid}>
            <span className={`file-name file-name-${item.status}`}>
              <Icon icon="file-alt" theme="secondary" />
              {item.name}
            </span>
            <span className="file-status">
              {(item.status === 'uploading'|| item.status === 'ready') && <Icon icon="spinner" spin theme="primary" />}
              {item.status === 'success' && <Icon icon="check-circle" theme="success" />}
              {item.status === 'error' && <Icon icon="times-circle" theme="danger" />}
            </span>
            <span className="file-actions">
              <Icon icon="times" onClick={() => { onRemove(item) }} />
            </span>
            {item.status === 'uploading' &&
              <Progress percent={item.percent || 0} />}
          </li>
        )
      })}
    </ul>
  )
}
export default UploadList

上传-Upload

import React, { FC, useRef, ChangeEvent,useState } from "react";
import axios from "axios";
import UploadList from "./uploadList";
import Dragger from "./dragger";

export type UploadFileStatus = 'ready' | 'uploading' | 'success' | 'error'
export interface UploadFile{
  uid: string;
  size: number;
  name: string;
  status?: UploadFileStatus;
  percent?: number;
  raw?: File;
  response?: any;
  error?:any
}
export interface UploadProps{
  action: string;
  defaultFileList?: UploadFile[];
  beforeUpload?: (file: File) => boolean | Promise<File>;
  onProgress?: (percentage: number, file: UploadFile) => void;
  onSuccess?: (data: any, file: UploadFile) => void;
  onError?: (err: any, file: UploadFile) => void;
  onChange?: (file: UploadFile) => void;
  onRemove?: (file: UploadFile) => void;
  headers?: { [key: string]: any };
  name?: string;
  data?: { [key: string]: any };
  withCredentials?: boolean;
  accept?: string;
  multiple?: boolean;
  drag?: boolean;
}
export const Upload: FC<UploadProps> = (props) => {
  const {
    action,
    onProgress,
    onSuccess,
    onError,
    beforeUpload,
    onChange,
    defaultFileList,
    onRemove,
    name,
    headers,
    data,
    withCredentials,
    accept,
    multiple,
    drag,
    children
  } = props
  const fileInput = useRef<HTMLInputElement>(null)
  const [fileList, setFileList] = useState<UploadFile[]>(defaultFileList||[])
  
  const updateFileList = (updateFile: UploadFile, updateObj: Partial<UploadFile>) => {
     setFileList(prevList => {
      return prevList.map(file => {
        if (file.uid === updateFile.uid) {
          return { ...file, ...updateObj }
        } else {
          return file
        }
      })
    })
  }
  const handleClick = () => {
    if (fileInput.current) {
      fileInput.current.click()
    }
  }
  const handleFileChange = (e: ChangeEvent<HTMLInputElement>) => {
    const files = e.target.files
    if (!files) {
      return
    }
    uploadFiles(files)
    if (fileInput.current) {
      fileInput.current.value = ''
    }
  }
  const handleRemove = (file: UploadFile) => {
    setFileList((prevList) => {
      return prevList.filter(item=>item.uid!==file.uid)
    })
    if (onRemove) {
      onRemove(file)
    }
  }
  const uploadFiles = (files: FileList) => {
    let postFiles = Array.from(files)
    postFiles.forEach(file => {
      if (!beforeUpload) {
        post(file)
      } else {
        const result = beforeUpload(file)
        if (result && result instanceof Promise) {
          result.then(processedFile => {
            post(processedFile)
          })
        } else if (result !== false) {
          post(file)
        }
      }
    })
  }
  const post = (file: File) => {
    let _file: UploadFile = {
      uid: Date.now() + 'upload-file',
      status: 'ready',
      name: file.name,
      size: file.size,
      percent: 0,
      raw:file
    }
    setFileList(prevList => {
      return [_file,...prevList]
    })

    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, {
      headers: {
        ...headers,
        'Content-Type':'multipart/form-data'
      },
      withCredentials,
      onUploadProgress: (e) => {
        //更新state是一个异步过程,在progress中拿到的是之前的结果
        let percentage = Math.round((e.loaded * 100) / e.total)
        if (percentage < 100) {
          //在这里不能直接更新状态
          updateFileList(_file, { percent: percentage, status: 'uploading' })
          if (onProgress) {
            onProgress(percentage,_file)
          }
        }
      }
    }).then(res => {
      console.log(res)
      updateFileList(_file, { status: 'success', response: res.data })
      if (onSuccess) {
        onSuccess(res.data,_file)
      }
      if (onChange) {
        onChange(_file)
      }
    }).catch(err => {
      console.log(err)
      updateFileList(_file, { status: 'error', error: err })
      if (onError) {
        onError(err, _file)
      }
      if (onChange) {
        onChange(_file)
      }
    })
  }

  return (
    <div className="rt-upload-component">
      <div className="rt-upload-input"
        style={{ display: 'inline-block' }}
        onClick={handleClick}
      >
        {drag ? <Dragger onFile={(files) => { uploadFiles(files) }}>{children}</Dragger> : children}
        <input
            className="rt-file-input"
            style={{ display: 'none' }}
            ref={fileInput}
            onChange={handleFileChange}
            type="file"
            accept={accept}
            multiple={multiple}
            placeholder="upload"
         />
      </div>
      <UploadList
        fileList={fileList}
        onRemove={handleRemove}
      />
    </div>
  )
}
Upload.defaultProps = {
  name:'file'
}
export default Upload