本篇我们来实现文件上传功能。
为此我们实现这样一个需求:点击添加按钮,上传本地的 .md
文件,读取文件内容,新建一条笔记。效果如下:
我们先回忆下文件上传功能,通常是用 <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 个:
application/x-www-form-urlencoded
:所有字符在发送前都会被编码。空格会转换为“+”符号,特殊字符会转换为 ASCII 十六进制值,适用于普通的表单数据multipart/form-data
:不对字符编码。如果表单中有上传文件,使用这个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>
现在让我们开始写吧!简单起见,我们的代码使用 day5-2
分支的代码,也就是没有实现国际化之前的项目。
第一种实现方式是使用 API 接口,在客户端提交文件的时候,调用后端的接口进行处理。
实现提交文件功能,你需要监听 <input type="file">
的 onChange
事件或者是 <button>
的 onClick
事件,又或者是 <form>
的 onSubmit
事件,无论哪种,反正你需要写成客户端组件。
上传文件的入口,我们就写在笔记列表的下方:
新建 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
已经能够正常调起文件选择框:
现在让我们来完善效果吧!
修改 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 作为请求体传入。当数据成功返回时,跳转到生成的笔记地址。
这里的跳转我们用的是 useRouter。 redirect 只能用在服务端组件、路由处理程序、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/plain
、image/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。
现在应该可以正常运行了:
虽然文件上传成功了,public
目录下也可以查看到这个文件,笔记也创建了,但是观察左侧的笔记列表,你会发现虽然页面跳转到对应的笔记,但是左侧的笔记列表并没有更新!
这是因为虽然我们在接口中使用了 revalidatePath
,但是它并不能影响客户端本身的路由缓存。GitHub 上也有讨论。还记得怎么清除路由缓存吗?
这里并不是在 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 (
// ...
)
}
现在就左侧的列表就可以正常更新了:
查看 /api/upload
接口的返回:
因为我们将文件放在了 public
下,所以直接访问 http://localhost:3000/uploads/24-01-03/occaecati-4s2adp.md
即可查看文件内容:
在这个例子中,我们并没有用到这个 URL,如果在实际的开发中,你可以用这个 URL 展示缩略图等。
接下来我们用 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." }
}
}
此时页面跟第一种方式一样正常运行:
因为在 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
中的代码不用改,效果如下:
那么今天的内容就结束了,本篇主要是围绕上传文件功能,帮助大家熟悉如何处理表单中的文件数据以及如何写接口(route.js)和 Server Actions(actions.js)。在实际的开发中,上传文件往往会更复杂,比如缩略图、文件队列、进度条、大文件上传等,但也脱离不了这两种最基本的开发方式。
本篇的代码我已经上传到代码仓库的 Day 7 分支:
直接使用的时候不要忘记在本地开启 Redis。