有没有人跟我一样,每次面试必会被问到如何实现一个上传组件。在实际工作中,有很多成熟的UI组件库供我们选择,很少会自己去开发。今天咱必须从零到一实现一个。以下这个Upload组件具备基础的上传功能,支持多选文件,支持拖拽上传,支持进度展示。当然肯定没有人家UI组件库功能齐全,主要是为了锻炼组件开发能力。
上传一个文件的生命周期
首先,设计一个upload的组件,我们可以从用户角度出发,在小本本上画一画上传一个文件需要经历哪些阶段。
- beforeUpload:在上传前检查文件类型,文件大小是否符合某种特定的规则
- onProgress:正在上传中,展示上传进度
- onChange:上传完毕,不管成功还是失败都会触发
- onSuccess:上传成功时触发,弹出提示信息
- onRemoved:点击上传好的文件进行删除
- onError:上传失败时触发,弹出错误信息
整理好思路之后,就非常清楚自己的组件大概长这样:
<Upload
action='https://upload'
beforeUpload={()=>{}}
onProgress={()=>{}}
onChange={()=>{}}
onSuccess={()=>{}}
onRemoved={()=>{}}
onError={()=>{}}
>
<Button>Click to Upload</Button>
//children 用户可自定义
</Upload>
异步请求方式的选择
1. 原生XHR和$ajax
- 原生XHR时浏览器发送异步请求的一个对象,不是很友好,不符合关注点分离原则,比较繁琐,配置和调用混乱。
- $ajax是对jquery对XHR的封装。如果单为一个ajax功能而引入jQuery得不偿失。
2. fetch
- 只对网络请求报错,对400,500都当作成功的请求
- 默认不会带cookie
- 不支持abort,不支持超时控制,即使超时,也不会abort,造成请求资源的浪费
- 没有办法原生监测请求的进度,xhr可以
3. axios
- 既支持浏览器(XMLHttpRequest),也支持nodeJs(http模块)环境
- 完全支持标准的Promise API
- 可以拦截请求和响应
- 可以转换请求和Response Data
- 可以取消请求
- 可以监测请求进度
上传文件的基本原理
上传文件其实就是使用异步请求完成文件的上传
- 选择文件:
<input type="file"/>
- 文件上传的两种方式:
- 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)
})
}
}
实现思路:
- 将fileInput隐藏掉,在点击触发上传的元素上,通过JS控制fileInput的dom节点,触发它的click,从而实现点击input效果。
- 在input的onChange事件触发时添加文件,给onChange事件绑定uploadFiles函数,用于文件的上传,在uploadFiles中,我们使用axios进行异步请求的发送方式,
- 在发送请求之前暴露beforeUpload,此时可以拿到要上传的文件,使用者可以做一些验证,返回Boolean,表示验证成功或失败。此时文件状态为ready。
- axios为我们提供了onUploadProgress事件,用于监控文件上传的进度。在UploadProgress事件中暴露onProgress,改变文件状态为uploading。
- 在axios的then回调中,触发onSuccess事件和onChange事件,改变文件状态为success。
- 在axios的catch回调中,触发onError和onChange事件,改变文件状态为error。
- 在文件列表模板中,我们可以根据文件的不同状态展示不同的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