Nextjs中使用axios实现一个动态的下载/上传进度条

longlikun

likun

Posted on August 19, 2024

Nextjs中使用axios实现一个动态的下载/上传进度条

在现代的Web开发中,处理文件上传/下载 和表单提交是常见的需求,尤其是在构建富交互的用户界面时。无论是上传图片、文档还是其他类型的文件,都需要确保用户体验的流畅性和数据的安全性。这篇文章将带您深入了解如何在Next.js应用中处理文件上传/下载,展示上传/下载进度,并使用React的热门工具集来简化这一过程。我们将结合 Next.js 强大的全栈功能和 Tailwind CSS 的快速样式设置,来构建一个美观且高效的文件上传界面。通过使用 Axios 进行HTTP请求,您将学会如何将文件数据发送到服务器,并在上传/下载过程中显示实时的进度条,以提高用户反馈的即时性。

无论您是初学者还是有经验的开发者,这篇文章都将为您提供实用的示例和详细的解释,帮助您掌握在现代Web开发中处理文件上传的最佳实践。让我们开始吧!

一.创建一个nextjs程序

首先创建一个nextjs程序,命名为progress-bar

 npx create-next-app@latest progress-bar
Enter fullscreen mode Exit fullscreen mode

在接下来的询问环节中, 选项全部选择默认:

✔ Would you like to use ESLint? … **No** / Yes
✔ Would you like to use Tailwind CSS? … No / **Yes**
✔ Would you like to use `src/` directory? … No / **Yes**
✔ Would you like to use App Router? (recommended) … No / **Yes**
✔ Would you like to customize the default import alias? … **No** / Yes
Enter fullscreen mode Exit fullscreen mode

安装完成进入项目目录,运行

npm run dev
Enter fullscreen mode Exit fullscreen mode

然后打开http://localhost:3000应该能看到nextjs的默认页面。

二.安装必要的包

让我们先从下载进度条开始,要使用上传/下载状态条,可以使用axios的配置选项onDownloadProgress/onUploadProgress,因此安装axios,同时安装@tanstack/react-query,来更好的管理请求状态。你也可以使用你喜欢的yarn pnpm等工具。

npm install axios @tanstack/react-query
Enter fullscreen mode Exit fullscreen mode

三、创建一个前端页面download

首先创建一个页面,app/download/page.tsx,里面简单的添加一些样式。

    <div className="bg-white">
            <header className="absolute inset-x-0 top-0 z-50">
                <nav aria-label="Global" className="flex items-center justify-between p-6 lg:px-8">
                    <div className="flex lg:flex-1">
                        <a href="#" className="-m-1.5 p-1.5">
                            <span className="sr-only">公司名称</span>
                            <Image
                                width={200}
                                height={200}
                                alt="logo"
                                src="https://tailwindui.com/img/logos/mark.svg?color=indigo&shade=600"
                                className="h-8 w-auto"
                            />
                        </a>
                    </div>
                    <div className="flex lg:hidden">
                        <button
                            type="button"
                            onClick={() => (true)}
                            className="-m-2.5 inline-flex items-center justify-center rounded-md p-2.5 text-gray-700"
                        >
                            <span className="sr-only">Open main menu</span>
                        </button>
                    </div>
                    <div className="hidden lg:flex lg:gap-x-12">
                        {navigation.map((item) => (
                            <a key={item.name} href={item.href} className="text-sm font-semibold leading-6 text-gray-900">
                                {item.name}
                            </a>
                        ))}
                    </div>
                    <div className="hidden lg:flex lg:flex-1 lg:justify-end">
                        <a href="#" className="text-sm font-semibold leading-6 text-gray-900">
                            Log in <span aria-hidden="true">&rarr;</span>
                        </a>
                    </div>
                </nav>
            </header>
            <div className="relative isolate px-6 pt-14 lg:px-8">
                <div
                    aria-hidden="true"
                    className="absolute inset-x-0 -top-40 -z-10 transform-gpu overflow-hidden blur-3xl sm:-top-80"
                >
                    <div
                        style={{
                            clipPath:
                                'polygon(74.1% 44.1%, 100% 61.6%, 97.5% 26.9%, 85.5% 0.1%, 80.7% 2%, 72.5% 32.5%, 60.2% 62.4%, 52.4% 68.1%, 47.5% 58.3%, 45.2% 34.5%, 27.5% 76.7%, 0.1% 64.9%, 17.9% 100%, 27.6% 76.8%, 76.1% 97.7%, 74.1% 44.1%)',
                        }}
                        className="relative left-[calc(50%-11rem)] aspect-[1155/678] w-[36.125rem] -translate-x-1/2 rotate-[30deg] bg-gradient-to-tr from-[#ff80b5] to-[#9089fc] opacity-30 sm:left-[calc(50%-30rem)] sm:w-[72.1875rem]"
                    />
                </div>
                <div className="mx-auto max-w-2xl py-32 sm:py-48 lg:py-56">

                    <div className="text-center">
                        <h1 className="text-2xl font-bold tracking-tight text-gray-900 sm:text-5xl">
                            Nextjs 实现下载进度条
                        </h1>
                        <p className="mt-6 text-lg leading-8 text-gray-600">
                        @longlikun 
                        </p>
                        <div className="mt-10 flex items-center justify-center gap-x-6">
                            <button
                                type='submit'

                                className="rounded-md bg-indigo-600 px-3.5 py-2.5 text-lg font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
                            >
                                立即下载
                            </button>
                        </div>             
                    </div>
                </div>
                <div
                    aria-hidden="true"
                    className="absolute inset-x-0 top-[calc(100%-13rem)] -z-10 transform-gpu overflow-hidden blur-3xl sm:top-[calc(100%-30rem)]"
                >
                    <div
                        style={{
                            clipPath:
                                'polygon(74.1% 44.1%, 100% 61.6%, 97.5% 26.9%, 85.5% 0.1%, 80.7% 2%, 72.5% 32.5%, 60.2% 62.4%, 52.4% 68.1%, 47.5% 58.3%, 45.2% 34.5%, 27.5% 76.7%, 0.1% 64.9%, 17.9% 100%, 27.6% 76.8%, 76.1% 97.7%, 74.1% 44.1%)',
                        }}
                        className="relative left-[calc(50%+3rem)] aspect-[1155/678] w-[36.125rem] -translate-x-1/2 bg-gradient-to-tr from-[#ff80b5] to-[#9089fc] opacity-30 sm:left-[calc(50%+36rem)] sm:w-[72.1875rem]"
                    />
                </div>
            </div>
        </div>
Enter fullscreen mode Exit fullscreen mode

Image description

因为在样式中使用了一张远程图片,我们还需要配置一下远程域名。我们在下一步完成。

四、配置nextjs图片的安全域名

为了保护应用程序免受恶意用户的攻击,nextjs需要进行配置才能使用外部图像。因为这里使用了tailwindui.com 的一个远程图片,另外使用的图片格式是svg,所以也一起配置,在next.config.mjs 文件中添加如下内容:

module.exports = {
    images: {
            dangerouslyAllowSVG: true,
            remotePatterns: [
            {
                protocol: 'https',
                hostname: 'tailwindui.com',        
            },
            ]
        }
}
Enter fullscreen mode Exit fullscreen mode

完成后打开http://localhost:3000/download,页面,应该看到如下页面:

Image description

四、添加一个进度条样式

在页面中添加一个进度条,样式可以从我的这篇文章中找一下,我这里就选择一个最基本的样式,添加到download 页面:

...
<div className="mx-5 my-10 h-4 rounded-full bg-gray-200">
    <div className="h-4 rounded-full bg-green-500 w-1/2" ></div>
</div>
...
Enter fullscreen mode Exit fullscreen mode

完成后页面如下:

Image description

四、添加环境变量

我在本地使用golang 构建了一个从阿里云oss下载/上传图片的端口,你可以使用自己的端口或者 httpbin 等类似公共api,在根目录创建一个env文件,添加下面的内容:

// 添加端口域名
NEXT_PUBLIC_API_URL=http://localhost:8080/api 
Enter fullscreen mode Exit fullscreen mode

五、配置react-query

react-query的配置可以参考这篇文章,这里我不详细介绍了。
创建一个文件 app/provider/ReactQueryProvider.tsx,添加下面内容:

'use client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { useState } from 'react'

export const ReactQueryClientProvider = ({ children }: { children: React.ReactNode }) => {
  const [queryClient] = useState(
    () =>
      new QueryClient({
        defaultOptions: {
          queries: {
            // With SSR, we usually want to set some default staleTime
            // above 0 to avoid refetching immediately on the client
            staleTime: 60 * 1000,
          },
        },
      })
  )
  return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
}

Enter fullscreen mode Exit fullscreen mode

然后在layout.tsx文件添加ReactQueryProvider,包裹子组件。

import ReactQueryProvider from "@/app/providder/ReactQueryProvider";

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body className={inter.className}>
        <ReactQueryProvider>
          {children}
        </ReactQueryProvider>
        </body>
    </html>
  );
}
Enter fullscreen mode Exit fullscreen mode

六、使用 usestate管理进度和进度条的开启状态

定义两个变量来分别管理下载进度的百分比和进度条的开启和关闭状态。

const [downloadProgress, setDownloadProgress] = useState<number>(0); //下载进度
const [showDownloadProgress, setShowDownloadProgress] = useState<boolean>(false); //是否显示下载进度条
Enter fullscreen mode Exit fullscreen mode

六、配置请求函数

我的接口使用的是POST,因此需要使用react-query的useMutation,添加请求代码如下:

    const mutation = useMutation({
        mutationFn: async () => {
            const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL;
            const url = `${API_BASE_URL}/download`;
            const response = await axios.post(
                url,
                // 直接指定数据
                {
                    path: "path/", //eg:path/
                    filename: "kongzi", //eg:abc
                    format: "png", //eg:jpg
                },
                {
                    responseType: 'blob',
                    onDownloadProgress: (progressEvent) => {
                        const total = progressEvent.total;               
                        percentage = Math.round((progressEvent.loaded * 100) / total);
                        // 设置下载进度的值,已经转换为百分比
                        setDownloadProgress(percentage);
                    },
                }
            );

            if (response.status === 200) {
                const url = window.URL.createObjectURL(response.data);
                const link = document.createElement('a');
                link.href = url;
                link.download = "kongzi" + "." + "png"; // 拼接文件全名
                link.click();
                window.URL.revokeObjectURL(url);
            }
        },
        onMutate: () => {
        },

        onSuccess: () => {
        },
        onError: () => {
        },

    });
Enter fullscreen mode Exit fullscreen mode

我的端口需要使用path,filename和format 几个参数,为了不涉及过多无关的请求,这里直接硬编码为指定参数。我们只关注onDownloadProgress 这个配置。解释一下:

  • progressEvent: 这是下载进度事件对象,包含有关下载的数据。
  • const total = progressEvent.total: 从事件对象中获取文件的总大小(字节数)。
  • progressEvent.loaded: 表示当前已下载的字节数。
  • percentage = Math.round((progressEvent.loaded * 100) / total): 计算已下载部分的百分比,并使用 Math.round 四舍五入。

我们获取到percentage 这个值就可以了.

七、添加点击函数

添加一个点击函数,然后给button绑定这个函数。

    const handleDownload = () => {
        console.log("handle download")
        setShowDownloadProgress(true)
        // mutation.mutate()
    }
Enter fullscreen mode Exit fullscreen mode

在这个函数中我们暂时先注释掉 mutation.mutate(),不触发实际请求,先测试是否能正确显示下载进度条。
给下载按钮绑定handleDownload函数。

<button
    type='submit'
    onClick={handleDownload}
    className="rounded-md bg-indigo-600 px-3.5 py-2.5 text-lg font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
>
    立即下载
</button>
Enter fullscreen mode Exit fullscreen mode

测试一下,当点击下载按钮后,会显示一个进度50%的静态下载进度条。

Image description

打开 // mutation.mutate() 这段注释, 实际测试一下。
可以看到点击下载后会显示进度条,然后完成下载。

我们还没有完成,这里的进度条还是固定没有变化的,我们还需要实现: 1.进度条动态显示。2.当下载完成后,关闭进度条。让我们继续吧。

七、动态修改div样式

想要实现进度条的动态变化,我这里直接动态修改div的宽度,把downloadProgress的值传递给width,这个值已经被我们处理成百分比格式的了。

{showDownloadProgress && (
<div className="mx-5 my-10 h-4 rounded-full bg-gray-200">
    <div className="h-4 rounded-full bg-green-500 w-1/2"  style={{ width: `${downloadProgress}%` }} ></div>
</div>

)}
Enter fullscreen mode Exit fullscreen mode

然后设置当下载完成后关闭进度条,在onSettled设置进度条的状态为false,这样不管成功还是失败都会关闭进度条。

    onSettled: () => {
        setShowDownloadProgress(false)
    },
Enter fullscreen mode Exit fullscreen mode

完成以后测试一下。可以看到当点击下载按钮后,显示进度条,进度条绿色不断增长直到100%,然后进度条消失。
视频:

八、优化进度条样式

进度条内容显示样式稍微有点简单,优化一下样式,添加一下文字提示

<div className="mx-5 my-10 h-4 rounded-full bg-gray-200 ">
    <div className="h-4 rounded-full bg-green-500 w-1/2" style={{ width: `${downloadProgress}%` }} ></div>
    // 添加下面的样式
    <div className="mt-4 flex items-center justify-between text-sm">
        <div className="text-gray-600">下载进度</div>
        <div className="text-gray-600">{downloadProgress}%</div>
    </div>
</div>
Enter fullscreen mode Exit fullscreen mode

完成如下所示,因为使用的国外的oss,所以响应会慢一些:
视频

Image description

九、提取下载进度条到一个单独的组件

提取进度条到一个单独的文件,方便一会上传复用。新建文件/app/components/ProgressBar.tsx,添加下面内容。

import React from 'react'

interface ProgressProps {
    progress: number
}
const ProgressBar: React.FC<ProgressProps> = ({
    progress
}) => {
    return (
        <div className="mx-5 my-10 h-4 rounded-full bg-gray-200 ">
            <div className="h-4 rounded-full bg-green-500 w-1/2" style={{ width: `${progress}%` }} ></div>
            <div className="mt-4 flex items-center justify-between text-sm">
                <div className="text-gray-600">下载进度</div>
                <div className="text-gray-600">{progress}%</div>
            </div>
        </div>
    )
}

export default ProgressBar
Enter fullscreen mode Exit fullscreen mode

修改upload/page.tsx文件

import ProgressBar from '@/app/components/ProgressBar'

{showDownloadProgress && (        
    <ProgressBar progress={downloadProgress}></ProgressBar>

)}
Enter fullscreen mode Exit fullscreen mode

测试一下,一切正常。

十、处理成功和错误提示

处理成功或者错误最好是给一个提示框,这里使用headlessui 的 Dialog
,首先安装一下headlessui

npm install @headlessui/react@latest
Enter fullscreen mode Exit fullscreen mode

这里我只是简单的文字提示下载成功或错误,实际项目可能会需要根据错误不同给出不同的提示。
创建一个文件/app/components/ShowDialog.tsx

import React from 'react';

import { Button, Dialog, DialogPanel, DialogTitle } from '@headlessui/react';
import { useState } from 'react';

interface ShowDialog {
  isSuccess: boolean; 
}

const ShowDialog: React.FC<ShowDialog> = ({ isSuccess }) => {
  let [isOpen, setIsOpen] = useState(true);

  function close() {
    setIsOpen(false);
  }

  return (
    <Dialog
      open={isOpen}
      as='div'
      className='relative z-10 focus:outline-none'
      onClose={close}
    >
      <div className='fixed inset-0 z-10 w-screen overflow-y-auto'>
        <div className='flex min-h-full items-center justify-center p-4'>
          {isSuccess ? (
            <DialogPanel
              transition
              className='w-full h-52 max-w-lg rounded-xl bg-white/5 p-6 backdrop-blur-2xl duration-300 ease-out data-[closed]:transform-[scale(95%)] data-[closed]:opacity-0'
            >
              <DialogTitle
                as='h3'
                className='text-base/7 font-medium text-black'
              >
                下载成功
              </DialogTitle>
              <p className='mt-2 text-sm/6 text-black/50'> 文件已成功下载。您可以现在查看或打开文件。如果您需要进一步操作,请根据提示进行。感谢您的耐心等待!</p>
              <div className='mt-4 '>
                <Button
                  className='inline-flex text-center items-center gap-2 rounded-md bg-green-500 py-1.5 px-3 text-sm/6 font-semibold text-white shadow-inner shadow-white/10 focus:outline-none data-[hover]:bg-gray-600 data-[focus]:outline-1 data-[focus]:outline-white data-[open]:bg-gray-700'
                  onClick={close}
                >
                 下载成功
                </Button>
              </div>
            </DialogPanel>
          ) : (
            <DialogPanel
              transition
              className='w-full h-52 max-w-lg rounded-xl bg-white/5 p-6 backdrop-blur-2xl duration-300 ease-out data-[closed]:transform-[scale(95%)] data-[closed]:opacity-0'
            >
              <DialogTitle
                as='h3'
                className='text-base/7 font-medium text-black'
              >
                下载失败
              </DialogTitle>
              <p className='mt-2 text-sm/6 text-black/50'>
                文件下载失败,可能是由于网络连接不稳定或服务器问题导致。请检查您的网络连接并稍后重试。如果问题仍然存在,请联系技术支持以获取帮助。
              </p>
              <div className='mt-4'>
                <Button
                  className='inline-flex items-center gap-2 rounded-md bg-red-500 py-1.5 px-3 text-sm/6 font-semibold text-white shadow-inner shadow-white/10 focus:outline-none data-[hover]:bg-gray-600 data-[focus]:outline-1 data-[focus]:outline-white data-[open]:bg-gray-700'
                  onClick={close}
                >
                  下载失败
                </Button>
              </div>
            </DialogPanel>
          )}
        </div>
      </div>
    </Dialog>
  );
};

export default ShowDialog;
Enter fullscreen mode Exit fullscreen mode

定义了一个 isSuccess 来判断请求成功/失败,从而渲染不同样式。
回到/app/download/page.tsx页面中,在下载按钮上面,添加下面代码:

    {mutation.isError && <ShowDialog isSuccess={false}></ShowDialog>}
    {mutation.isSuccess && <ShowDialog isSuccess={true}></ShowDialog>}
Enter fullscreen mode Exit fullscreen mode

来测试一下,修改一个请求参数,改成一个错误的参数,然后点击下载,进度条不动,过一会会弹出一个提示框。

Image description
然后恢复到正确的参数,测试下载,当进度条到100%以后,会弹出这个提示框。

Image description
可以看到使用react query,管理状态是非常简单的。

十五、实现上传进度条

上传进度要麻烦一些,最主要是需要处理数据验证, 重点是是使用 react-hook-form 和zod的验证, 首先安装一下必要工具

npm install zod  react-hook-form
Enter fullscreen mode Exit fullscreen mode

另外需要使用form,所以在plugins中配置tailwindcss 使用forms,首先安装

npm install -D @tailwindcss/forms
Enter fullscreen mode Exit fullscreen mode

然后到tailwind.config.ts 文件中,添加

// tailwind.config.js
module.exports = {
  theme: {
    // ...
  },
  plugins: [
    require('@tailwindcss/forms'),
    // ...
  ],
}
Enter fullscreen mode Exit fullscreen mode

十六、创建一个上传页面

新建一个上传页面/app/upload/page.tsx,添加下面代码

'use client'
import { useForm } from "react-hook-form";

export default function UploadPage() {
    const {
        register,
        handleSubmit,
        formState: { errors },
    } = useForm({

    });
    return (
        <div className="mx-auto max-w-screen-xl px-4 py-16 sm:px-6 lg:px-8">
            <div className="mx-auto max-w-lg">
                <h1 className="text-center text-2xl font-bold text-indigo-600 sm:text-3xl">上传文件进度条</h1>

                <p className="mx-auto mt-4 max-w-md text-center text-gray-500">
                    Lorem ipsum dolor sit amet, consectetur adipisicing elit. Obcaecati sunt dolores deleniti
                    inventore quaerat mollitia?
                </p>

                <form onSubmit={handleSubmit((data) => console.log(data))} className="mb-0 mt-6 space-y-4 rounded-lg p-4 shadow-lg sm:p-6 lg:p-8">
                    <p className="text-center text-lg font-medium">上传文件</p>

                    <div className="col-span-full">
                        <label htmlFor="filename" className="sr-only">filename</label>
                        <div className="relative">
                            <input
                                type="text"
                                className="w-full rounded-lg border-gray-200 p-4 pe-12 text-sm shadow-sm"
                                placeholder="请填写文件名"
                            />

                        </div>
                    </div>

                    <div className="col-span-full">
                        <label
                            htmlFor="desc"
                            className="block text-sm font-medium leading-6 text-gray-900"
                        >
                            文件简介
                        </label>
                        <div className="mt-2">
                            <textarea
                                id="desc"
                                placeholder="请填写文件简介"
                                rows={3}
                                className="block w-full rounded-md border-0 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:py-1.5 sm:text-sm sm:leading-6"
                                defaultValue={""}
                            />


                        </div>
                    </div>
                    <div className="col-span-full">
                        <label
                            htmlFor="file"
                            className="block text-sm font-medium leading-6 text-gray-900"
                        >
                            选择文件
                        </label>
                        <div className="mt-2 flex justify-center rounded-lg border border-dashed border-gray-900/25 px-6 py-4">
                            <div className="text-center">

                                <div className="mt-4 flex text-sm leading-6 text-gray-600">
                                    <label
                                        htmlFor="file"
                                        className="relative cursor-pointer rounded-md bg-white font-semibold text-indigo-600 focus-within:outline-none focus-within:ring-2 focus-within:ring-indigo-600 focus-within:ring-offset-2 hover:text-indigo-500"
                                    >
                                        <span>选择文件</span>

                                        <input

                                            type="file"
                                            id="file"
                                            className="sr-only"
                                        />
                                    </label>
                                </div>
                            </div>
                        </div>
                    </div>

                    <button
                        type="submit"
                        className="block w-full rounded-lg bg-indigo-600 px-5 py-3 text-sm font-medium text-white"
                    >
                        上传
                    </button>

                </form>
            </div>
        </div>
    )
}
Enter fullscreen mode Exit fullscreen mode

不要忘记在顶部添加 'use client',完成后页面类似这样

Image description

十八、选择文件

在选择文件中,我希望可以显示文件名称,即当选择文件后 ,上传按钮变成删除按钮,同时显示选中的文件名,因此定义两个变量。

const [fileUpload, setFileUpload] = useState<FileList | null>(null); //设置获取文件
const [fileName, setFileName] = useState<string>(""); //选择后显示文件的名称
Enter fullscreen mode Exit fullscreen mode

创建处理文件的函数,一个处理文件,一个删除文件

    //获取文件
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const files = e.target.files;
    console.log('files', files);//打印调试
    if (files && files.length > 0) {
        setFileUpload(files[0]); // 更新状态
        setFileName(files?.[0].name)
    }
};
    // 重置文件选择
const handleRemoveFile = () => {
    setFileName("");
    setFileUpload(null); // 重置文件输入字段
};
Enter fullscreen mode Exit fullscreen mode

完成后的效果如下

Image description

十七、使用useform

使用useform注册字段,并添加验证选项。
文件名称是必须的,最大的字符是10字符。

    <div className="sm:col-span-4">
        <label
            htmlFor="title"
            className="block text-sm font-medium leading-6 text-gray-900"
        >
            文件名称
        </label>
        <input
            id="title"
            {...register("title", { required: true, maxLength: 10 })}
        />

        {/* useform 错误信息 */}
        {errors.title?.type === "required" && (
            <p className="text-red-500 ">⚠ 标题不能为空</p>
        )}
        {errors.title?.type === "maxLength" && (
            <p className="text-red-500 ">⚠ 标题长度不能超过10个字符</p>
        )}
    </div>
Enter fullscreen mode Exit fullscreen mode

文件详情是必须的,最大的字符是200字符。

    <div className="mt-2">
    <textarea
        id="desc"
        {...register("desc", { required: true, maxLength: 100 })}
        placeholder="请填写文件简介"
        rows={3}
        className="block w-full rounded-md border-0 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:py-1.5 sm:text-sm sm:leading-6"
        defaultValue={""}
            />
            {/* useform 错误信息 */}
    {errors.desc?.type === "required" && (
        <p className="text-red-500 ">⚠ 详细信息不能为空</p>
    )}
    {errors.desc?.type === "maxLength" && (
        <p className="text-red-500 ">⚠ 详细信息不能超过200个字符</p>
    )}

        </div>
Enter fullscreen mode Exit fullscreen mode

文件暂时设置必须。其他验证等一会我们使用zod验证。

    <div className="mt-4 flex text-sm leading-6 text-gray-600">
    <label
        htmlFor="file"
        className="relative cursor-pointer rounded-md bg-white font-semibold text-indigo-600 focus-within:outline-none focus-within:ring-2 focus-within:ring-indigo-600 focus-within:ring-offset-2 hover:text-indigo-500"
    >
        <span>选择文件</span>
        <input
            {...register("file", {
                required: true,
            })}
            type="file"
            id="file"
            className="sr-only"
        />
        {errors.desc?.type === "required" && (
            <p className="text-red-500 ">⚠ 详细信息不能为空</p>
        )}
    </label>
</div>
Enter fullscreen mode Exit fullscreen mode

测试一下

Image description
长度验证

Image description

十八、使用zod进行文件验证

目前我们只是使用usfeform进行简单验证,如果要验证文件大小类型等复杂验证,还无法实现,因此使用zod来验证
先安装一下。

npm install  zod @hookform/resolvers
Enter fullscreen mode Exit fullscreen mode

验证规则单独放一个文件中,创建一个文件 /app/lib/validata.ts,添加下面内容

import { z } from 'zod';
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
// 根据需要增减文件类型
const ACCEPTED_IMAGE_TYPES = [
  'image/jpeg',
  'image/jpg',
  'image/png',
  'image/webp',
];

// 验证文件上传
export const validationSchema = z.object({
title: z
    .string()
    .min(4, "名称不能小于4个字符")
    .max(20, "名称最多为20个字符"),
desc: z
    .string()
    .min(4, "名称不能小于4个字符")
    .max(200, "名称最多为200个字符"),
fileUpload: z
    .custom<FileList>((v) => v instanceof FileList)
    .transform((val) => {
      console.log("val:", val); // 调试信息:打印输入值
      if (val instanceof File) return val;
      if (val instanceof FileList) return val[0];
      return null; })
    // 验证文件大小
    .refine(
      (file) => file instanceof File && file.size <= MAX_FILE_SIZE,
      {message: `文件大小不能超过 ${MAX_FILE_SIZE / (1024 * 1024)} MB`, }
    )
    // 验证文件类型
    .refine(
      (file) => file instanceof File && ACCEPTED_IMAGE_TYPES.includes(
          file.type
        ),
      { message: '类型不支持,请选择 (jpeg, jpg, png, webp)类型', }
    )
});
Enter fullscreen mode Exit fullscreen mode

这里主要看一下file字段,首先检查传入的值不是 FileList,因为我的端口接受的是单个File, 所以通过 transform 方法对 FileList 进行处理,所以将其转换为单个 File 实例, 必须为图片文件, 然后限定最大可上传文件为5M。你可以根据实际情况添加更多验证规则。
回到上传页面, 添加下面代码:

import { validationSchema } from "../lib/validate";
import { zodResolver } from "@hookform/resolvers/zod";
    const {
        register,
        handleSubmit,
        formState: { errors },
    } = useForm({
// 添加下面这段代码
      defaultValues: {
            title: "",
            desc: "",
            file: null,
        },

      resolver: zodResolver(validationSchema),

    });

Enter fullscreen mode Exit fullscreen mode

修改原来的错误提示代码,

    {errors.title && (
        <p className="text-red-500 ">{errors.title.message}</p>
    )}                               
Enter fullscreen mode Exit fullscreen mode

详情错误提示

   {errors.desc && (
       <p className="text-red-500 ">{errors.desc.message}</p>
   )}                               
Enter fullscreen mode Exit fullscreen mode

文件错误提示

   {errors.file && (
       <p className="text-red-500 ">{errors.fileUpload.message}</p>
   )}                               
Enter fullscreen mode Exit fullscreen mode

测试一下

Image description

提交请求

因为使用的use-hook-form,所以按照要求定义个onSubmit函数,依旧使用react query 的useMutation 来处理。

    //获取请求地址
    const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL;
    const url = `${API_BASE_URL}/upload`;

    // 使用 useMutation 处理文件上传
    const mutation = useMutation({
        mutationFn: async (data: UploadFormInputs) => {
            const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL;
            const url = `${API_BASE_URL}/upload`;
            console.log("handle data",data)

            if (data.fileUpload instanceof File) {
                const formData = new FormData();
                formData.append('title', data.title);
                formData.append('desc', data.desc);
                formData.append('file', data.fileUpload);

                const response = await axios.post(url, formData, {
                    onUploadProgress: (progressEvent: any) => {
                        const progress = Math.round(
                            (progressEvent.loaded / progressEvent.total) * 100
                        );
                        setUploadProgress(progress);
                    },
                });

                return response.data;
            }
        },
        onSuccess: (data) => {
            console.log('File uploaded successfully', data);

        },
        onError: (error) => {
            console.error('Failed to upload file', error);
        },
        onSettled:()=>{
            setShowUploadProgress(false)
            reset()
            setFileUpload(null)
        }
    });
    //提交表单
    const onSubmit = async (data: UploadFormInputs) => {
        mutation.mutate(data)
    };
Enter fullscreen mode Exit fullscreen mode

这里的内容和下载的内容类似,在上传完成后设置隐藏进度条,并重置表单。这里没有使用use hook form 的 set value,所以我们还需要手动恢复文件选择setFileUpload(null)。

修改ShowDialog

目前下载成功我们弹出的提示依旧是下载成功/失败,但是我们这里是上传页面,因此修改ShowDialog组件,使其兼容上传。
修改ShowDialog.tsx 文件

interface ShowDialog {
  isSuccess: boolean;
  action: string//添加这个

}
Enter fullscreen mode Exit fullscreen mode

同时把ShowDialog.tsx 文件里面的下载全部修改为{action},修改下载文件页面中的代码传递action的值。

{mutation.isError && <ShowDialog isSuccess={false} action='下载'></ShowDialog>}
{mutation.isSuccess && <ShowDialog isSuccess={true} action='下载'></ShowDialog>}
Enter fullscreen mode Exit fullscreen mode

上传页面也同样修改。传递值"上传"

{mutation.isSuccess && <ShowDialog isSuccess={true} action='上传'></ShowDialog>}
{mutation.isError && <ShowDialog isSuccess={false} action='上传'></ShowDialog>}
Enter fullscreen mode Exit fullscreen mode

Image description

Image description

你可以修改文字使其更符合状态提示。这样我们也完成了文件上传的进度条显示。

总结

在本文中,我们深入探讨了如何在 Next.js 应用中处理文件上传,并结合 Tailwind CSS、React Hook Form、Zod、Axios 和 React Query 等工具,创建了一个功能完善、用户体验良好的上传组件。从表单管理到数据验证,从文件处理到上传进度展示,我们覆盖了文件上传过程中的关键环节。

在开发过程中,使用这些工具不仅提升了我们的开发效率,还保证了代码的可维护性和扩展性。这种组合方式展示了如何在复杂的项目中,将各个库和框架的优势最大化,创造出高质量的用户体验。

希望通过这篇文章,您对文件上传的处理流程有了更深入的理解,并能够在自己的项目中应用这些技术。如果您有任何问题或想法,欢迎讨论和分享!

我的博客原文地址:https://blog.eimoon.com/p/nextjs-file-progress-bar-react-query/

💖 💪 🙅 🚩
longlikun
likun

Posted on August 19, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related