JackyLove 的技术人生

人生艰难,唯有一技傍身才能慢慢走向通途。个人博客,记录生活,分享技术,记录成长。专注全栈技术,next技术。

第40章—实战篇ReactNotes文件上传

首次发表于 2024-03-22, 更新于 2024-03-22

前言

本篇我们来实现文件上传功能。

为此我们实现这样一个需求:点击添加按钮,上传本地的 .md文件,读取文件内容,新建一条笔记。效果如下:

ReactNotes-上传文件 3.gif

温故而知新

我们先回忆下文件上传功能,通常是用 <input type="file">,示例代码如下:

<form method="post" enctype="multipart/form-data">
  <div>
    <label for="file">选择要上传的文件</label>
    <input type="file" id="file" name="file" multiple accept="image/*,.pdf" />
  </div>
  <div>
    <button>提交</button>
  </div>
</form>

其中 <input type="file" > 如果有附加属性 multiple,表示允许用户选择多个文件。如果有附加属性 accept表示支持的文件类型,这个例子中表示的是支持图片格式和 pdf 文件。

其中 <form> 添加了属性 enctype 用于指明提交表单的内容类型,可选的值有 3 个:

  1. application/x-www-form-urlencoded:所有字符在发送前都会被编码。空格会转换为“+”符号,特殊字符会转换为 ASCII 十六进制值,适用于普通的表单数据
  2. multipart/form-data:不对字符编码。如果表单中有上传文件,使用这个
  3. text/plain:发送数据时完全不进行任何编码。用的很少

因为 <input type="file"> 默认的样式无法改变,通常会使用 label 标签关联,隐藏 input 标签:

<form method="post" enctype="multipart/form-data">
  <div>
    <label for="file">Import .md File</label>
    <input type="file" id="file" name="file" multiple style={{ position : "absolute", clip: "rect(0 0 0 0)" }} />
  </div>
  <div>
    <button>提交</button>
  </div>
</form>

第一种方式:API 接口

现在让我们开始写吧!简单起见,我们的代码使用 day5-2分支的代码,也就是没有实现国际化之前的项目。

第一种实现方式是使用 API 接口,在客户端提交文件的时候,调用后端的接口进行处理。

实现提交文件功能,你需要监听 <input type="file">onChange 事件或者是 <button>onClick 事件,又或者是 <form>onSubmit 事件,无论哪种,反正你需要写成客户端组件。

上传文件的入口,我们就写在笔记列表的下方:

image.png

新建 components/SidebarImport.js,代码如下:

'use client'

import React, { Suspense } from 'react'

export default function SidebarImport() {
  return (
    <form method="post" enctype="multipart/form-data">
      <div style={{ textAlign: "center" }}>
        <label for="file" style={{ cursor: 'pointer' }}>Import .md File</label>
        <input type="file" id="file" name="file" multiple style={{ position : "absolute", clip: "rect(0 0 0 0)" }} />
      </div>
    </form>
  )
}

components/Sidebar.js中导入该组件:

// ...
import SidebarImport from '@/components/SidebarImport';

export default function Sidebar() {
	// ...
  return (
    <>
      <section className="col sidebar">
      	// ...
        <nav>
          <Suspense fallback={<NoteListSkeleton />}>
            <SidebarNoteList />
          </Suspense>
        </nav>
        <SidebarImport />
      </section>
    </>
  )
}

此时点击 Import .md File 已经能够正常调起文件选择框:

image.png

现在让我们来完善效果吧!

修改 components/SidebarImport.js,代码如下:

'use client'

import React, { Suspense } from 'react'
import { useRouter } from 'next/navigation'

export default function SidebarImport() {
  const router = useRouter()

  const onChange = async (e) => {
    const fileInput = e.target;

    if (!fileInput.files || fileInput.files.length === 0) {
      console.warn("files list is empty");
      return;
    }

    const file = fileInput.files[0];

    const formData = new FormData();
    formData.append("file", file);

    try {
      const response = await fetch("/api/upload", {
        method: "POST",
        body: formData,
      });

      if (!response.ok) {
        console.error("something went wrong");
        return;
      }

      const data = await response.json();
      router.push(`/note/${data.uid}`)

    } catch (error) {
      console.error("something went wrong");
    }

    // 重置 file input
    e.target.type = "text";
    e.target.type = "file";
  };


  return (
    <div style={{ textAlign: "center" }}>
      <label htmlFor="file" style={{ cursor: 'pointer' }}>Import .md File</label>
      <input type="file" id="file" name="file" style={{ position : "absolute", clip: "rect(0 0 0 0)" }} onChange={ onChange } accept=".md" />
    </div>
  )
}

在这段代码中,我们并没有用到 <form> 标签,而是直接直接监听了 <input type="file">onChange 事件。当触发 onChange 事件的时候,我们构建了一个 FormData 对象,将 File 对象添加进去。然后调用 /api/upload接口,将 formData 作为请求体传入。当数据成功返回时,跳转到生成的笔记地址。

这里的跳转我们用的是 useRouterredirect 只能用在服务端组件、路由处理程序、Server Actions。客户端手动跳转使用 useRouter

新建 app/api/upload/route.js,代码如下:

import { stat, mkdir, writeFile } from 'fs/promises'
import { join } from "path";
import { NextResponse } from 'next/server'
import { revalidatePath } from 'next/cache';
import mime from "mime";
import dayjs from 'dayjs';
import { addNote } from '@/lib/redis';

export async function POST(request) {

  // 获取 formData
  const formData = await request.formData()
  const file = formData.get('file')

  // 空值判断
  if (!file) {
    return NextResponse.json(
      { error: "File is required." },
      { status: 400 }
    );
  }

  // 写入文件
  const buffer = Buffer.from(await file.arrayBuffer());
  const relativeUploadDir = `/uploads/${dayjs().format("YY-MM-DD")}`;
  const uploadDir = join(process.cwd(), "public", relativeUploadDir);

  try {
    await stat(uploadDir);
  } catch (e) {
    if (e.code === "ENOENT") {
      await mkdir(uploadDir, { recursive: true });
    } else {
      console.error(e)
      return NextResponse.json(
        { error: "Something went wrong." },
        { status: 500 }
      );
    }
  }

  try {
    // 写入文件
    const uniqueSuffix = `${Math.random().toString(36).slice(-6)}`;
    const filename = file.name.replace(/\.[^/.]+$/, "")
    const uniqueFilename = `${filename}-${uniqueSuffix}.${mime.getExtension(file.type)}`;
    await writeFile(`${uploadDir}/${uniqueFilename}`, buffer);

    // 调用接口,写入数据库
    const res = await addNote(JSON.stringify({
      title: filename,
      content: buffer.toString('utf-8')
    }))

    // 清除缓存
    revalidatePath('/', 'layout')

    return NextResponse.json({ fileUrl: `${relativeUploadDir}/${uniqueFilename}`, uid: res });
  } catch (e) {
    console.error(e)
    return NextResponse.json(
      { error: "Something went wrong." },
      { status: 500 }
    );
  }
}

在这段代码中,我们使用了 mime 这个库,用于获取 MIME 类型信息。所谓 MIME (Multipurpose Internet Mail Extensions) 是描述消息内容类型的标准,用来表示文档、文件或字节流的性质和格式,也就是我们常见的 text/plainimage/jpeg等。在这里我们是用它获取文件扩展名。别忘了安装这个库:

npm i mime

我们通过 await request.formData()获取了提交的 formData。获取其中的 File 对象后,我们主要做了两件事情,一是将文件写入到 public 目录下,二是根据文件信息创建了笔记。最终接口返回文件地址和笔记 id。

为了方便写入,我们将其转为 Buffer 形式:Buffer.from(await file.arrayBuffer()),并通过 writeFile 写入文件。为了防止文件重复,我们根据日期创建文件夹,并生成了随机字符添加到文件名中。

然后我们通过 buffer.toString('utf-8') 获取了文件内容,调用之前 redis.js 导出的 addNote 方法添加笔记内容,然后清除数据缓存,返回了文件地址和笔记 ID。

现在应该可以正常运行了:

ReactNotes-上传文件 1.gif

虽然文件上传成功了,public 目录下也可以查看到这个文件,笔记也创建了,但是观察左侧的笔记列表,你会发现虽然页面跳转到对应的笔记,但是左侧的笔记列表并没有更新!

这是因为虽然我们在接口中使用了 revalidatePath,但是它并不能影响客户端本身的路由缓存。GitHub 上也有讨论。还记得怎么清除路由缓存吗?

image.png

这里并不是在 Server Action 中,所以只能使用第二种方式,所以我们在 router.push后再加一句:

router.push(`/note/${data.uid}`)
router.refresh()

当然也可以配合 useTransition使用:

'use client'

import { useTransition } from 'react'

export default function SidebarImport() {
  const router = useRouter()
  const [isPending, startTransition] = useTransition();

  const onChange = async (e) => {
      // ...
      startTransition(() => router.push(`/note/${data.uid}`));
      startTransition(() => router.refresh());
    	// ...
  };

  return (
    	// ...
  )
}

现在就左侧的列表就可以正常更新了:

ReactNotes-上传文件 2.gif

查看 /api/upload 接口的返回:

image.png

因为我们将文件放在了 public下,所以直接访问 http://localhost:3000/uploads/24-01-03/occaecati-4s2adp.md即可查看文件内容:

image.png

在这个例子中,我们并没有用到这个 URL,如果在实际的开发中,你可以用这个 URL 展示缩略图等。

第二种方式:Server Actions

接下来我们用 Server Actions 重新实现这个需求,关于文件上传,官方也提供了示例代码 server-actions-upload 可供参考。

一般使用 Server Actions 会用在 <form> 标签的 action 属性上,但这次我们是监听 <input type="file">onChange 事件,所以我们就直接在 onChange 事件中调用 Server Actions,对应要使用客户端组件。

components/Sidebar.js中导入 <SidebarImport> 组件:

// ...
import SidebarImport from '@/components/SidebarImport';

export default function Sidebar() {
	// ...
  return (
    <>
      <section className="col sidebar">
      	// ...
        <nav>
          <Suspense fallback={<NoteListSkeleton />}>
            <SidebarNoteList />
          </Suspense>
        </nav>
        <SidebarImport />
      </section>
    </>
  )
}

新建 components/SidebarImport.js,代码如下:

'use client'

import React from 'react'
import { useRouter } from 'next/navigation'
import { importNote } from '@/actions'

export default function SidebarImport() {
  const router = useRouter()

  const onChange = async (e) => {
    const fileInput = e.target;

    if (!fileInput.files || fileInput.files.length === 0) {
      console.warn("files list is empty");
      return;
    }

    const file = fileInput.files[0];

    const formData = new FormData();
    formData.append("file", file);

    try {
      const data = await importNote(formData);
      router.push(`/note/${data.uid}`)

    } catch (error) {
      console.error("something went wrong");
    }

    // 重置 file input
    e.target.type = "text";
    e.target.type = "file";
  };


  return (
    <div style={{ textAlign: "center" }}>
      <label htmlFor="file" style={{ cursor: 'pointer' }}>Import .md File</label>
      <input type="file" id="file" name="file" style={{ position : "absolute", clip: "rect(0 0 0 0)" }} onChange={ onChange } accept=".md" />
    </div>
  )
}

为方便导入,更新 jsconfig.json

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/actions": ["app/actions.js"],
      "@/*": ["/*"]
    }
  }
}

app/actions.js添加 importNote方法:

'use server'

// ...
import { stat, mkdir, writeFile } from 'fs/promises'
import { join } from "path";
import mime from "mime";
import dayjs from 'dayjs';

// ...
export async function importNote(formData) {
  const file = formData.get('file')

  // 空值判断
  if (!file) {
    return { error: "File is required." };
  }

  // 写入文件
  const buffer = Buffer.from(await file.arrayBuffer());
  const relativeUploadDir = `/uploads/${dayjs().format("YY-MM-DD")}`;
  const uploadDir = join(process.cwd(), "public", relativeUploadDir);

  try {
    await stat(uploadDir);
  } catch (e) {
    if (e.code === "ENOENT") {
      await mkdir(uploadDir, { recursive: true });
    } else {
      console.error(e)
      return { error: "Something went wrong." }
    }
  }

  try {
    // 写入文件
    const uniqueSuffix = `${Math.random().toString(36).slice(-6)}`;
    const filename = file.name.replace(/\.[^/.]+$/, "")
    const uniqueFilename = `${filename}-${uniqueSuffix}.${mime.getExtension(file.type)}`;
    await writeFile(`${uploadDir}/${uniqueFilename}`, buffer);

    // 调用接口,写入数据库
    const res = await addNote(JSON.stringify({
      title: filename,
      content: buffer.toString('utf-8')
    }))

    // 清除缓存
    revalidatePath('/', 'layout')

    return { fileUrl: `${relativeUploadDir}/${uniqueFilename}`, uid: res }
  } catch (e) {
    console.error(e)
    return { error: "Something went wrong." }
  }
}

此时页面跟第一种方式一样正常运行:

ReactNotes-上传文件 3.gif

因为在 Server Actions 中调用 revalidatePath 会清除路由缓存,所以我们也不需要再调用 router.refresh()

在这个例子中,我们是在 onChange 事件中调用的 Server Action,使用这种方式对应会丢失渐进式增强,也就是说如果禁用 JS,就无法正常提交了。

如果有提交按钮,写法上会略有改变,我们试着写一下:

修改 components/SidebarImport.js

'use client'

import { useRef } from 'react'
import { useFormStatus } from 'react-dom'
import { useRouter } from 'next/navigation'
import { importNote } from '@/actions'

function Submit() {
  const { pending } = useFormStatus()
  return <button disabled={pending}>{pending ? 'Submitting' : 'Submit'}</button>
}

export default function SidebarImport() {
  const router = useRouter()
  const formRef = useRef(null)

  async function upload(formData) {

    const file = formData.get('file');
    if (!file) {
      console.warn("files list is empty");
      return;
    }

    try {
      const data = await importNote(formData);
      router.push(`/note/${data.uid}`)

    } catch (error) {
      console.error("something went wrong");
    }

    // 重置 file input
    formRef.current?.reset()
  };


  return (
    <form style={{ textAlign: "center" }} action={upload} ref={formRef}>
      <label htmlFor="file" style={{ cursor: 'pointer' }}>Import .md File</label>
      <input type="file" id="file" name="file" accept=".md" />
      <div><Submit /></div>
    </form>
  )
}

actions.js中的代码不用改,效果如下:

ReactNotes-上传文件 4.gif

总结

那么今天的内容就结束了,本篇主要是围绕上传文件功能,帮助大家熟悉如何处理表单中的文件数据以及如何写接口(route.js)和 Server Actions(actions.js)。在实际的开发中,上传文件往往会更复杂,比如缩略图、文件队列、进度条、大文件上传等,但也脱离不了这两种最基本的开发方式。

本篇的代码我已经上传到代码仓库的 Day 7 分支:

  • 第一种方式 在 day7 分支
  • 第二种方式 在 day7-1 分支
  • 第二种方式带提交按钮 在 day7-2 分支

直接使用的时候不要忘记在本地开启 Redis。

参考链接

  1. <input type=“file”> - HTML(超文本标记语言) | MDN
  2. HTMLFormElement: enctype property - Web APIs | MDN
  3. https://developer.mozilla.org/zh-CN/docs/Web/API/File
  4. Building a File Uploader from scratch with Next.js app directory
  5. How to upload a file in Next.js 13+ App Directory with No libraries
  6. Next.js V13: revalidate not triggering after router.push
  7. https://github.com/vercel/next.js/discussions/54075
© Copyright 2025 JackyLove 的技术人生. Powered with by CreativeDesignsGuru