跳到主要内容

43-实战篇 React Notes 文件上传

前言

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

为此我们实现这样一个需求:点击添加按钮,上传本地的 .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