本章讨论使用Ant Desing的Upload组件实现上传文件的的各种实践细节。

12.1 准备工作

12.1.1 设置路由和菜单

在config/routes.ts中增加如下的路由

  1. {
  2. path: '/upload',
  3. name: '上传文件',
  4. icon: 'upload',
  5. routes: [
  6. {
  7. path: '/upload/upload01',
  8. name: '常规文件上传',
  9. component: './Upload/upload01.tsx',
  10. },
  11. {
  12. path: '/upload/upload02',
  13. name: '上传头像',
  14. component: './Upload/upload02.tsx',
  15. },
  16. ],
  17. },

保存以后在左侧可以看到新建的菜单

12.1.2 创建两个空文件

在目录src/pages/Upload下创建两个空的文件upload01.tsx和upload02.tsx。

12.1.3 Mock中定义和数据

设计上传文件的Mock服务时,我们可以再次深刻体验Ant Design的文档实在是粗糙。Upload组件的文档中提到建议去参考一个Github上的范例,但那个并不适合Ant Desing Pro的脚手架环境。其实这个脚手架已经实现了处理前端上传文件的能力,但没有体现在任何的文档中。本节我们解释一下如何利用这种能力。

mock目录下下创建一个文件fileServ.ts

  1. import { Request, Response } from 'express';
  2. interface fileInfo {
  3. fieldname: string,
  4. originalname: string,
  5. encoding: string,
  6. mimetype: string
  7. size: number,
  8. buffer: any,
  9. }
  10. interface RequestEx extends Request {
  11. files: fileInfo[]
  12. }
  13. interface attachement {
  14. uid: string,
  15. oid: number | undefined,
  16. name: string,
  17. url?: string,
  18. size?: number,
  19. }
  20. let attachments: attachement[] = [
  21. {
  22. uid: '1',
  23. oid: 8,
  24. name: 'file001.png',
  25. url: 'http://www.51gh.com.cn',
  26. size: 123,
  27. },
  28. {
  29. uid: '2',
  30. oid: 8,
  31. name: 'file002.png',
  32. url: 'http://www.51gh.com.cn',
  33. size: 456,
  34. },
  35. {
  36. uid: '3',
  37. oid: 9,
  38. name: 'file999.png',
  39. url: 'http://www.51gh.com.cn',
  40. size: 123456,
  41. },
  42. ]
  43. let maxUid = 3

12.1.4 Mock中处理并保存文件信息

  1. function uploadHandler(req: RequestEx, res: Response) {
  2. const { oid } = req.body
  3. const files = req.files.map((value) => {
  4. const { buffer, ...rest } = value
  5. return { ...rest }
  6. })
  7. const result = {
  8. success: (Math.floor(Math.random() * 10) % 3 != 0) ,
  9. data: { files },
  10. }
  11. if(result.success) {
  12. for( let value of files) {
  13. maxUid ++
  14. attachments.push({
  15. oid,
  16. uid: maxUid+'',
  17. name: value.originalname,
  18. url: 'http://www.51gh.com.cn',
  19. size: value.size,
  20. })
  21. }
  22. }
  23. console.log(result)
  24. return res.json(result);
  25. }
  26. export default {
  27. 'POST /api/upload': uploadHandler,
  28. }

仔细阅读代码,你会发现上文所说的“能力”指的是前端传递过来的文件数据的信息已经被封装到request参数的files属性中,可以直接起取出来使用。

12.2 上传文件的基本功能

添加pro-card组件

  1. $ tyarn add @ant-design/pro-card
  2. // 或
  3. $ cnpm install --save @ant-design/pro-card

创建一个文件src/pages/Upload/upload01.tsx

  1. import React from 'react';
  2. import { PageContainer } from '@ant-design/pro-layout';
  3. import ProCard from '@ant-design/pro-card';
  4. import { Upload, UploadProps, message, Button } from 'antd';
  5. import { UploadOutlined } from '@ant-design/icons';
  6. const uploadPage: React.FC = () => {
  7. const uploadProps: UploadProps<any> = {
  8. accept: 'image/*,.pdf',
  9. action: '/api/upload',
  10. name: 'myFile',
  11. data: {
  12. oid: 8,
  13. },
  14. onChange(info: any) {
  15. const { file } = info
  16. if(file.status != 'uploading')
  17. console.log(info);
  18. if (file.status === 'done') {
  19. if(file.response.success) {
  20. message.success('文件上传成功');
  21. }
  22. else {
  23. message.error('文件接收失败');
  24. file.status = 'error'
  25. }
  26. } else if (file.status === 'error' ) {
  27. message.error('文件上传失败');
  28. }
  29. },
  30. };
  31. return (
  32. <PageContainer header={{breadcrumb: {},}}>
  33. <ProCard style={{width: '60%'}}>
  34. <Upload {...uploadProps}>
  35. <Button icon={<UploadOutlined />}>点击上传文件</Button>
  36. </Upload>
  37. </ProCard>
  38. </PageContainer>
  39. )
  40. }
  41. export default uploadPage

从本节起,我们单独用TypeScript对象定义组建的属性,然后用对象扩展符定义在组件上。这样定义属性的代码更加直观,组件部分也更加的整洁清晰。

现在我们可以点击按钮体验上传文件组件的基本功能image.png
为体验组件提供的上传进度功能,建议选择一个比较大(几十兆或更大)的文件来试一下。

12.3 查询已上传文件列表

Upload组件有一个缺省文件列表defaultFileListi属性,再组件初次构建的时候从这个属性中读取已经上传的文件信息。接下来我们从服务器读取数据后动态设置这个属性,顺便展示一下如何自己编码实现异步读数据的完整功能。

12.3.1 Mock服务函数

mock/fileServ.ts中添加数据和响应函数

  1. function getAttachements(req: RequestEx, res: Response) {
  2. let data:attachement[] = []
  3. const { oid } = req.query;
  4. const fieldList = attachments.filter((data) => data.oid == oid )
  5. console.log(oid, fieldList)
  6. if(fieldList != undefined)
  7. data = Array.of( ...fieldList )
  8. return res.json({
  9. success: true,
  10. data,
  11. })
  12. }

设置与请求地址的对应

  1. export default {
  2. 'GET /api/getAttachements': getAttachements,

12.3.2 编写异步读数据代码

Upload/upload01.tsx中引用useStateuseEffect以及UmiJS的requet

  1. import { useState, useEffect } from 'react';
  2. import { request } from 'umi';

定义3个React State Hook

  1. const [objectId, setObjectId] = useState(8)
  2. const [isLoading, setIsLoading] = useState(true)
  3. const [defaultFileList,setDefaultFileList] = useState([])

使用React Effect Hook控制异步读数据

  1. useEffect( ()=> {
  2. const fetchData = async (oid: number) => {
  3. const result = await request('/api/getAttachements', {
  4. method: 'GET',
  5. params: {
  6. oid: objectId,
  7. }
  8. })
  9. const fileList = result.data.map( (value:any) => ({...value, status:'done'}) )
  10. setDefaultFileList(fileList)
  11. setIsLoading(false)
  12. }
  13. setIsLoading(true)
  14. fetchData(8)
  15. },[objectId])

这里其实也可以用Umi Hook中的useRequest来实现,但并没有明显的好处,而且反倒不够灵活,代码也不简洁。此外,类似fileList这种变量一定要用State Hook来处理,否则就要自己用React的State来处理,不能直接赋值。

12.3.3 修改组件代码

  1. <ProCard style={{width: '60%'}} loading={isLoading}>
  2. <Upload {...uploadProps} defaultFileList={defaultFileList} >
  3. <Button icon={<UploadOutlined />}>点击上传文件</Button>
  4. </Upload>
  5. </ProCard>

刷新页面,经过短暂的loading状态,我们可以看到如下的页面
image.png

12.3.4 模拟objectId变化

我们定义Effect Hook时,指定了要依赖objectId,当他发生变时会自动重新执行查询,这在实际的业务场景中是可能出现的,在本节我们用Switch组件来模拟这个变化。

首先声明引用

  1. import { Switch } from 'antd'

然后定义属性

  1. const switchProps: any = {
  2. checkedChildren: 'oid=8',
  3. unCheckedChildren: 'oid=9',
  4. checked: objectId == 8,
  5. onChange: (checked: boolean) => {
  6. if(checked)
  7. setObjectId(8)
  8. else
  9. setObjectId(9)
  10. },
  11. }

在一个新的ProCard中定义Switch

  1. <ProCard style={{width: '60%'}}>
  2. <Switch { ...switchProps } />
  3. </ProCard>

现在界面变成了如下的样子
image.png
切换Switch,可以看到Upload的列表随之变化
image.png
最后别忘了把uploadProps中写死的属性也改成受变量控制

  1. data: {
  2. - oid: 8,
  3. + objectId
  4. },

在本节的设计中,我们很明显的看到了给Effect Hook设置依赖的好处,而且Effect Hook的能力也突出的展出了出来。

对React Hook技术尚未掌握的,需要尽快重新学习有关的内容

image.png

12.3.5 捕获网络请求的异常

在8.3.2中,我们没有做捕获异常的设计,这是不应该的。在处理网络请求时很有可能遇到各种异常情况,所以需要对useEffect中的retchData函数做如下的改造

  1. try {
  2. const result = await request('/api/getAttachements', {
  3. method: 'GET',
  4. params: {
  5. oid,
  6. }
  7. })
  8. const fileList = result.data.map( (value:any) => ({...value, status:'done'}) )
  9. setDefaultFileList(fileList)
  10. setIsLoading(false)
  11. } catch (e) {
  12. setDefaultFileList([])
  13. setIsLoading(false)
  14. }

也就是说,当发生异常时,我们在这里放弃设置默认文件的行为。当然,在实际的项目中应该根据业务的具体要求决定处理的方法。

12.4 删除已上传的文件

在现在已经完成的页面中,点击文件列表的删除图标,可以直接将该文件从列表中删除。在实际业务场景中,更重要的是删除服务器的文件和数据库中的记录,删除成功职工再处理文件列表。Upload组件为我们提供了onRemove响应函数来完成这个逻辑

12.4.1 Mock服务函数

mock/fileServ.ts

  1. function deleteAttachement(req: RequestEx, res: Response) {
  2. const { oid, uid } = req.query;
  3. attachments = attachments.filter( (file) => !(file.oid == oid && file.uid == uid))
  4. console.log(attachments)
  5. return res.json({
  6. success: true,
  7. });
  8. }
  9. export default {
  10. 'GET /api/deleteAttachement': deleteAttachement,

12.4.2 onRemove响应函数

首先是引用

  1. import { DeleteOutlined, ExclamationCircleOutlined } from '@ant-design/icons';
  2. import { Modal } from 'antd'

然后定义一个State Hook标识是否成功

  1. const [isSuccessful, setIsSuccessful] = useState(false)

下面是响应函数的定义

  1. onRemove(file: any): boolean | Promise<boolean> {
  2. async function removeFromServer(oid:any, uid:any) {
  3. try {
  4. const result = await request('/api/deleteAttachement', {
  5. method: 'GET',
  6. params: {
  7. oid,
  8. uid,
  9. }
  10. })
  11. if(result.success)
  12. setIsSuccessful(true)
  13. } catch(e) {
  14. return
  15. }
  16. }
  17. if(file.status == 'done') {
  18. setIsSuccessful(false)
  19. return new Promise((resolve, reject) => {
  20. Modal.confirm({
  21. title: '删除提示',
  22. icon: <ExclamationCircleOutlined />,
  23. content: `你确实要删除 ${file.name} ?`,
  24. okText: '是的',
  25. okType: 'danger',
  26. cancelText: '不了',
  27. onOk: async () => {
  28. removeFromServer(file.oid, file.uid)
  29. resolve(true)
  30. },
  31. onCancel: () => reject(),
  32. });
  33. })
  34. return isSuccessful
  35. } else {
  36. return true
  37. }
  38. },

上面的代码有有个地方需要注意:

  • 我们首先判断欲删除文件的状态,如果是非正常的状态,那没它应该不在服务器上,直接就删除好了
  • 对于状态正常的文件,我们要询问用户是否真的删除,需要用Promise来起一个等待的作用。如果不这样做,因为对话框按钮是异步响应的,对话框弹出的同时onMove函数就已经返回了,无法得到用户的选择结果。

    12.5 控制许可数量和选择方式

    到现在为止,我们没有对上传文件的数量进行限制,但是只能一个各的选文件。本节我们来看一下如何限制上传的数量以及如何同时上传多个文件(多选或上传整个目录)。

定义一个配合工作的State Hook

  1. const [controlType, setControlType] = useState(2)

为完成本节的目标,我们需要用到Upload组件的三个属性其定义如下

  1. //可以上传文件的最大数量,当为1时,新上传的文件信息会替换现有的
  2. //不设置代表不限制
  3. maxCount: number,
  4. //是否允许选择上传整个目录,默认不允许
  5. directory: boolean
  6. //是否允许选择多个文件同时上传,默认不允许
  7. multiple: controlType > 1,

为了看到这些属性的控制效果,我们根据一组单选按钮来决定许可的方式

  1. import { Radio } from 'antd';
  2. const radioProps:any = {
  3. options: [
  4. { value:1, label: '限1个'},
  5. { value:2, label: '允许多个'},
  6. { value:3, label: '支持目录'},
  7. ],
  8. value: controlType,
  9. optionType: 'button',
  10. buttonStyle: 'solid',
  11. onChange: (e: any) => {
  12. setControlType(e.target.value)
  13. },
  14. }

把这组单选按钮放到Switch组件的上面

  1. <ProCard style={{width: '60%'}}>
  2. <Radio.Group {...radioProps}/>
  3. <br />
  4. <br />
  5. <Switch { ...switchProps } />
  6. </ProCard>

给Upload的属性定义(uploadProps)增加三项内容

  1. maxCount: controlType === 1? 1: 999,
  2. directory: controlType === 3,
  3. multiple: controlType > 1,

现在我们可以看到如下的见面,点击不同的按钮后上传文件,可以体会到设置的变化。
image.png

注意:当设置支持选择目录上传的时候,不同浏览器的表现是不一样的,有的浏览器既可以直接选择文件又可以选择目录,但有的浏览器却只能选择目录,无法再选择具体的文件。

12.6 上传前校验文件的大小

限制许可上传的文件的大小,这是很常见的业务需求。在Upload组件上实现这个非常简单,具体做法是增加一个buforeUpload属性,它是一个上传前自动调用的函数,在函数里面判断文件的大小,返回true代表可以上传、返回false代表不可以上传但可以放到组件的文件列表里面,如果返回Upload.LIST_IGNORE则什么都不会做。

  1. beforeUpload: (file: any, fileList: any[]) => {
  2. const sizeLimit = 10 * 1024 * 1024
  3. if(file.size > sizeLimit) {
  4. Modal.warning({
  5. title: '文件太大,',
  6. content: '文件大小不能超过10M',
  7. });
  8. return Upload.LIST_IGNORE
  9. } else {
  10. return true
  11. }
  12. },

12.7 管理用户头像

本节我们用Upload组件实现管理用户头像的功能。

12.7.1 准备两个图片

在public目录下创建新的目录Images,然后放两个图片文件,分别为d1.jpg和d2.jpg。本教程用了如下的两个图片:
d1.jpgd2.jpg

12.7.2 Mock中的数据和函数

mock/fileServ.ts中增加如下的内容

  1. let AvatarList: any[] = [
  2. {
  3. userId: 8,
  4. url: '/images/d1.jpg',
  5. },
  6. {
  7. userId: 9,
  8. url: '/images/d2.jpg',
  9. },
  10. ]
  11. function getAvatar(req: RequestEx, res: Response) {
  12. const { userId } = req.query;
  13. const avatar = AvatarList.find((data) => data.userId == userId )
  14. if(avatar != undefined)
  15. return res.json({
  16. success: true,
  17. data: avatar,
  18. })
  19. else
  20. return res.json({
  21. success: false,
  22. })
  23. }
  24. function uploadAvatar(req: RequestEx, res: Response) {
  25. const { userId } = req.body
  26. const files = req.files.map((value) => {
  27. const { buffer, ...rest } = value
  28. return { ...rest }
  29. })
  30. const result = {
  31. success: true ,
  32. data: { files },
  33. }
  34. return res.json(result);
  35. }
  36. export default {
  37. 'GET /api/user/getAvatar' : getAvatar,
  38. 'POST /api/user/uploadAvatar' : uploadAvatar,

12.7.2 完成管理头像的基本功能

新建一个文件Upload/style.less

  1. .avatar-box {
  2. border-radius: 50%;
  3. overflow: hidden;
  4. width: 100%;
  5. height: 100%;
  6. display: flex;
  7. align-items: center;
  8. justify-content: center;
  9. }
  10. .avatar-box img {
  11. width: 100%;
  12. height: auto;
  13. }

Upload/upload02.tsx中置入如下的代码

  1. import React, { useState, useEffect } from 'react';
  2. import { PageContainer } from '@ant-design/pro-layout';
  3. import ProCard from '@ant-design/pro-card';
  4. import { request } from 'umi';
  5. import { Button, Upload, UploadProps } from 'antd';
  6. import { LoadingOutlined, PlusOutlined } from '@ant-design/icons';
  7. import './style.less'
  8. const uploadPage: React.FC = () => {
  9. const [isLoading, setIsLoading] = useState(false)
  10. const [userId, setUserId] = useState(8)
  11. const [avatarUrl, setAvatarUrl] = useState<string>()
  12. const uploadButton = (
  13. <div>
  14. {isLoading ? <LoadingOutlined /> : <PlusOutlined />}
  15. <div style={{ marginTop: 8 }}>上传头像</div>
  16. </div>
  17. );
  18. const avatarImage = (
  19. <div className="avatar-box">
  20. <img src={avatarUrl} alt="头像" />
  21. </div>
  22. )
  23. useEffect(() => {
  24. const getAvatar = async (id: number) => {
  25. setIsLoading(true)
  26. try {
  27. const result = await request('/api/user/getAvatar', {
  28. method: 'GET',
  29. params: {
  30. userId: id
  31. }
  32. })
  33. const image = await fetch (result.data.url, {
  34. method: 'GET',
  35. headers: {
  36. "Content-Type": "image/png;image/jpeg"
  37. },
  38. })
  39. const blob = await image.blob()
  40. setAvatarUrl(URL.createObjectURL(blob))
  41. setIsLoading(false)
  42. } catch (e) {
  43. //没有头像的时候,后端设置success为false,这里自动做异常处理
  44. setIsLoading(false)
  45. }
  46. }
  47. getAvatar(userId)
  48. },[userId])
  49. const uploadProps: UploadProps<any> = {
  50. accept: '.jpg,.jpeg,.png',
  51. action: '/api/user/uploadAvatar',
  52. name: 'avatar',
  53. listType: 'picture-card', //DJ
  54. showUploadList: false,
  55. onChange: info => {
  56. if (info.file.status === 'uploading') {
  57. setIsLoading(true);
  58. return;
  59. }
  60. if (info.file.status === 'done') {
  61. setIsLoading(false)
  62. setAvatarUrl(URL.createObjectURL(info.file.originFileObj))
  63. }
  64. }
  65. }
  66. return (
  67. <PageContainer header={{breadcrumb: {},}}>
  68. <ProCard style={{width: '60%'}} >
  69. <Upload {...uploadProps} >
  70. { avatarUrl? avatarImage : uploadButton }
  71. </Upload>
  72. <Button onClick={ () => setAvatarUrl(undefined) }>清除头像</Button>
  73. </ProCard>
  74. </PageContainer>
  75. )
  76. }
  77. export default uploadPage

下面是完成后的界面
image.png
点击当前的头像可以另选一个图片上传后作为头像,点击清除按钮则可以彻底清除当前的头像。清除头像的时候,上面的代码只是清除了当前页面中记录的链接,没有访问服务器。正式工作的软件系统应该先向服务器发出删除头像的请求,服务器操作成功之后再清除本地链接。

此外,尽管只需要执行一次,我们仍旧用Effect Hook来处理从服务器请求当前头像的工作。这么做的重要理由是放置页面发生不应有的刷新。若想看到不用Effect Hook的效果,可以尝试直接定义Avatar函数并调用它,然后打开浏览器的开发调试,查看网络状态,你会发现页面刷新和网络请求陷入了一个死循环(想想原因是什么?)

12.7.3 上传前裁切图片

我们在上面的示例中有意选择了一个扁长的图片作为头像,这时的视觉效果非常不理想。通常用户会希望有个手段可以从他选择的图片中取一部分做头像,现在我们就用现成的ImgCrop组件来实现这个功能。

首先安装新的程序包

  1. $ tyarn add antd-img-crop
  2. $ cnpm install --save antd-img-crop

必要的引用

  1. import ImgCrop from 'antd-img-crop';
  2. import 'antd/es/modal/style';
  3. import 'antd/es/slider/style';

把ImgCrop组件包围在Upload的外面

  1. <ImgCrop rotate shape={"round"}>
  2. <Upload {...uploadProps} >
  3. { avatarUrl? avatarImage : uploadButton }
  4. </Upload>
  5. </ImgCrop>

现在我们选择要上传的图片以后,可以对他进行移动、缩放和旋转了
image.png

12.8 照片墙

用Upload组件是实现照片墙其实非常简单,主要是设置listType属性为picture-card、showUploadList属性为true(默认值)即可。
具体请的参见官方范例

12.9 拖动上传

Upload组件本身就是支持拖动上传的。你可以分别拖一个图片文件到upload01那页的上传按钮和upload02那页的头像区域,可以看到和在选择文件的对话框中选中这个图片的效果是一样的。

在实际的项目开发中,我们可能会给接受拖动来的的文件更大的区域并设置更明显的文字说明,具体的请参见官方范例

12.10 重要说明

  • 官方范例的地址是 https://ant.design/components/upload-cn/
  • 不要是试图自己控制fileList属性,尽管官方范例中有相关的例子,但估计用的是较早版本的组件,当前最新的组件有严重的bug,下面是这个bug的官方说明

    onChange 事件仅会作用于在列表中的文件,因而 fileList 不存在对应文件时后续事件会被忽略。

  • 再次提醒,如果你对React Hook还不能完全掌握,需要尽快重新学习有关的内容

版权说明:本文由北京朗思云网科技股份有限公司原创,向互联网开放全部内容但保留所有权力。