在Next.js中使用UploadThing

659 阅读5分钟

什么是UploadThing?

在Next.js中使用UploadThing前先介绍一下UploadThing。借用官方的一句话:UploadThing 是将文件上传添加到全栈 TypeScript 应用程序的最简单方法,UploadThing 是一个用于构建文件上传功能的平台,它是我们平时开发中进行文件托管的一种方案,可以更好的为我们管理文件,现在的工作原理主要通过缓存和回调来包装 S3来实现文件管理

在所有权上,你可以将文件发布到UploadThing上管理,也可以将文件上传到你的服务器上的 /api/uploadthing 端点,它提供了一个易于使用的 API,不过你必须在自己的服务器上托管一个 UploadThing 端点,才能使用 UploadThing 的功能。

为什么需要托管端点

  • 所有权和控制权:  托管端点意味着你拥有对文件上传的完全控制权,你可以决定如何处理上传的文件,例如存储位置、权限等。

  • 带宽成本:  你无需将上传的文件通过 UploadThing 的服务器,可以避免额外的带宽成本。

正文开始

Create a new app store

当我们登录网页后,就可以创建应用文件管理仓库了

5b4091138cecd56c8bf22063db5ee7d7.png

创建完成后就可以看到一个 dashboard 面板

19aea5e4112fecf56fd438eb68e41247.png

在练手的时候可以创建一个免费版的,容量只有2G但是学习基本够用了

设置秘钥

访问API Keys这个目录,将 API 密钥复制并添加项目.env文件作为环境变量 33362328f4ec118a04788ab226aa0972.png

6e7fb9a2fa083c8787c650a888ae4039.png

安装

bun add uploadthing @uploadthing/react
or
pnpm add uploadthing @uploadthing/react
or 
npm install uploadthing @uploadthing/react

设置文件路由器

上传到 uploadthing 的所有文件都与 FileRoute 关联

  • 设置支持上传的文件类型 ["image", "video", ...]
  • Max file size 最大文件大小
  • middleware 用于验证和标记请求
  • onUploadComplete 上传完成时的回调
  • app/api/uploadthing/core.ts
import { createUploadthing, type FileRouter } from 'uploadthing/next'
import { auth } from '@clerk/nextjs/server'

const f = createUploadthing()

const authenticateUser = () => {
  const user = auth()
  // If you throw, the user will not be able to upload
  if (!user) throw new Error('Unauthorized')
  // Whatever is returned here is accessible in onUploadComplete as `metadata`
  return user
}

// FileRouter for your app, can contain multiple FileRoutes
export const ourFileRouter = {
  // Define as many FileRoutes as you like, each with a unique routeSlug
  subaccountLogo: f({ image: { maxFileSize: '4MB', maxFileCount: 1 } })
    .middleware(authenticateUser)
    .onUploadComplete(() => { }),
  avatar: f({ image: { maxFileSize: '4MB', maxFileCount: 1 } })
    .middleware(authenticateUser)
    .onUploadComplete(() => { }),
  agencyLogo: f({ image: { maxFileSize: '4MB', maxFileCount: 1 } })
    .middleware(authenticateUser)
    .onUploadComplete(() => { }),
  media: f({ image: { maxFileSize: '4MB', maxFileCount: 1 } })
    .middleware(authenticateUser)
    .onUploadComplete(() => { }),
} satisfies FileRouter

export type OurFileRouter = typeof ourFileRouter

  • app/api/uploadthing/route.ts
import { createRouteHandler } from "uploadthing/next";

import { ourFileRouter } from "./core";

// Export routes for Next App Router
export const { GET, POST } = createRouteHandler({
    router: ourFileRouter,

    // Apply an (optional) custom config:
    // config: { ... },
});

创建 UploadThing 组件

  • generateUploadButton 函数用于生成用于与 UploadThing 交互的 UploadButton 组件。
  • generateUploadDropzone 函数用于生成用于与 UploadThing 交互的 UploadDropzone 组件。

生成组件允许将完全类型安全的组件绑定到文件路由器的类型

  • lib/uploadthing.ts
import {
    generateUploadButton,
    generateUploadDropzone,
} from "@uploadthing/react";

import type { OurFileRouter } from "@/app/api/uploadthing/core";

export const UploadButton = generateUploadButton<OurFileRouter>();
export const UploadDropzone = generateUploadDropzone<OurFileRouter>();

UploadThing SSR 插件[可选]

UploadThing 需要从您的服务器获取信息以获取权限信息。通常这意味着加载状态。我们构建了一个可选插件来防止这种情况

69c47079041e32f6f04374656e47e298.png 要添加 SSR 水合并避免该加载状态,只需在子级之前的根布局主体中渲染 <NextSSRPlugin /> 水合助手即可。

  • app/layout.tsx
import { NextSSRPlugin } from "@uploadthing/react/next-ssr-plugin";
import { extractRouterConfig } from "uploadthing/server";
 
import { ourFileRouter } from "@/app/api/uploadthing/core";
 
export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>
        <NextSSRPlugin
          /**
           * The `extractRouterConfig` will extract **only** the route configs
           * from the router to prevent additional information from being
           * leaked to the client. The data passed to the client is the same
           * as if you were to fetch `/api/uploadthing` directly.
           */
          routerConfig={extractRouterConfig(ourFileRouter)}
        />
        {children}
      </body>
    </html>
  );
}

项目演示

场景介绍:form 表单中需要上传文件

  • app/agency/page.tsx
"use client"
import { Agency } from "@prisma/client";
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import { z } from "zod"

import { AlertDialog } from "@/components/ui/alert-dialog";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import FileUpload from "@/components/global/file-upload";


const formSchema = z.object({
    agencyLogo: z.string().min(1)
})


interface AgencyDetailsProps {
    data?: Partial<Agency>
}

const AgencyDetails = ({ data }: AgencyDetailsProps) => {

    const form = useForm<z.infer<typeof formSchema>>({
        mode: 'onChange',
        resolver: zodResolver(formSchema),
        defaultValues: {
            agencyLogo: data?.agencyLogo || "",
        }
    })


    function onSubmit(values: z.infer<typeof formSchema>) {
        // Do something with the form values.
        // ✅ This will be type-safe and validated.
        console.log(values)
    }

    const isLoading = form.formState.isSubmitting;

    return (
        <AlertDialog>
            <Card className="w-full">
                <CardHeader>
                    <CardTitle>Agency Information</CardTitle>
                    <CardDescription>
                        Lets create an agency for you business. You can edit agency settings
                        later from the agency settings tab.
                    </CardDescription>
                </CardHeader>
                <CardContent>
                    <Form {...form}>
                        <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
                            <FormField
                                disabled={isLoading}
                                control={form.control}
                                name="agencyLogo"
                                render={({ field }) => (
                                    <FormItem>
                                        <FormLabel>Agency Logo</FormLabel>
                                        <FormControl>
                                            <FileUpload
                                                apiEndpoint="agencyLogo"
                                                onChange={field.onChange}
                                                value={field.value}
                                            />
                                        </FormControl>
                                        <FormMessage />
                                    </FormItem>
                                )}
                            />

                        </form>
                    </Form>
                </CardContent>
            </Card>
        </AlertDialog>
    );
};

export default AgencyDetails;

  • components/global/file-upload.tsx
"use client"
import { FileIcon, X } from 'lucide-react'
import Image from 'next/image'
import { Button } from '../ui/button'
import { UploadDropzone } from '@/lib/uploadthing'


interface FileUploadProps = {
    apiEndpoint: 'agencyLogo' | 'avatar' | 'subaccountLogo'
    onChange: (url?: string) => void
    value?: string
}

const FileUpload = ({ apiEndpoint, onChange, value }: FileUploadProps) => {
    const type = value?.split('.').pop()
    if (value) {
        return (
            <div className="flex flex-col justify-center items-center">
                {type !== 'pdf' ? (
                    <div className="relative w-40 h-40">
                        <Image
                            src={value}
                            alt="uploaded image"
                            className="object-contain"
                            fill
                        />
                    </div>
                ) : (
                    <div className="relative flex items-center p-2 mt-2 rounded-md bg-background/10">
                        <FileIcon />
                        <a
                            href={value}
                            target="_blank"
                            rel="noopener_noreferrer"
                            className="ml-2 text-sm text-indigo-500 dark:text-indigo-400 hover:underline"
                        >
                            View PDF
                        </a>
                    </div>
                )}
                <Button
                    onClick={() => onChange('')}
                    variant="ghost"
                    type="button"
                >
                    <X className="h-4 w-4" />
                    Remove Logo
                </Button>
            </div>
        )
    }
    return (
        <div className="w-full bg-muted/30">
            <UploadDropzone
                endpoint={apiEndpoint}
                onClientUploadComplete={(res) => {
                    onChange(res?.[0].url)
                }}
                onUploadError={(error: Error) => {
                    console.log('Upload Error', error)
                }}
            />
        </div>
    )
}

export default FileUpload

效果展示

ezgif-7-492fafb2c2.gif

其他

当我们点击上传是组件内部会自动调用 Uploadthing API

 POST /api/uploadthing?slug=agencyLogo

对应的是我们app/api/uploadthing/route.ts中的agencyLogo路由

  agencyLogo: f({ image: { maxFileSize: '4MB', maxFileCount: 1 } })
    .middleware(authenticateUser)
    .onUploadComplete(() => { }),

上传成功后会返回静态资源链接🔗 默认格式为:"utfs.io/f/*****.png"

参考项目地址(GitHub):github.com/jshuaishuai…

@aws-sdk/client-s3 上传实例参考

import { useState } from "react";
import {
  ListObjectsCommandOutput,
  S3Client,
  PutObjectCommand,
} from "@aws-sdk/client-s3";
import "./App.css";

function App() {
  const [filePath, setFilePath] = useState('');

  const bucketName = 'file-ms';

  const s3Client = new S3Client({
    region: "us-west-1",
    credentials: {
      accessKeyId: process.env.S3_ACCESS_KEY as string,
      secretAccessKey: process.env.S3_SECRET_ACCESS_KEY as string,
    }
  })

  const handleFileUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
    const file = event.target.files?.[0];
    if (!file) {
      return;
    }
    const newFilename = encodeURIComponent(`files/${file.name}`);
    try {
      const command = new PutObjectCommand({
        Body: file,
        ACL: 'public-read',
        Bucket: bucketName,
        Key: newFilename, // 设置文件在 S3 上的路径
        ContentType: file.type,
      });
      await s3Client?.send(command);
      const url = `https://${bucketName}.s3.amazonaws.com/${newFilename}`;
      setFilePath(url);
      // 更新文件列表
    } catch (error) {
      console.error('Error uploading file', error);
    }
  };

  return (
    <div className="App">
      <input type="file" onChange={handleFileUpload} />
      <p>{filePath}</p>
    </div>
  );
}

export default App;

end

感谢观看!!