next.js-学习4
更新下home菜单的路由,app/ui/dashboard/nav-links.tsx中link的 { name: ‘Home’, href: ‘/dashboard’, icon: HomeIcon },改为 { name: ‘Home’, href: ‘/dashboard/overview’, icon: HomeIcon },
11.添加搜索和分页
添加搜索框
-
/dashboard/invoices/page.tsx中添加
import Pagination from '@/app/ui/invoices/pagination'; import Search from '@/app/ui/search'; import Table from '@/app/ui/invoices/table'; import { CreateInvoice } from '@/app/ui/invoices/buttons'; import { lusitana } from '@/app/ui/fonts'; import { InvoicesTableSkeleton } from '@/app/ui/skeletons'; import { Suspense } from 'react'; export default async function Page() { return ( <div className="w-full"> <div className="flex w-full items-center justify-between"> <h1 className={`${lusitana.className} text-2xl`}>Invoices</h1> </div> <div className="mt-4 flex items-center justify-between gap-2 md:mt-8"> <Search placeholder="Search invoices..." /> <CreateInvoice /> </div> {/* <Suspense key={query + currentPage} fallback={<InvoicesTableSkeleton />}> <Table query={query} currentPage={currentPage} /> </Suspense> */} <div className="mt-5 flex w-full justify-center"> {/* <Pagination totalPages={totalPages} /> */} </div> </div> ); }
-
添加个搜索/app/ui/search.tsx中
//export default function Search({ placeholder }: { placeholder: string }) {中添加 function handleSearch(term: string) { console.log(term); } //input中 placeholder={placeholder}下边添加修改监听事件 onChange={(e) => { handleSearch(e.target.value); }}
跳转到http://localhost:3000/dashboard/invoices路由搜索就会看到搜索的输入内容打印。
继续修改/app/ui/search.tsx,导入组件,修改Search方法,useSearchParams可以封装url参数
import { useSearchParams } from 'next/navigation'; export default function Search() { const searchParams = useSearchParams(); function handleSearch(term: string) { const params = new URLSearchParams(searchParams); if (term) { params.set('query', term); } else { params.delete('query'); } } // ... }
继续增加路由path,这样输入搜索在导航栏会显示
import { useSearchParams, usePathname, useRouter } from 'next/navigation';//更新导入的组件 //searchParams下加入 const pathname = usePathname(); const { replace } = useRouter(); //在handleSearch函数最后一行写入,pathname是请求的路径dashboard/invoices,params.toString()是请求的参数就说问号后边的那一串 replace(`${pathname}?${params.toString()}`);
为了确保输入字段与URL同步,并在共享时填充,您可以通过从searchParams中读取defaultValue来传递输入:
defaultValue={searchParams.get('query')?.toString()}//加到onChange事件下边
/app/dashboard/invoices/page.tsx中,Page函数增加参数
props: { searchParams?: Promise<{ query?: string; page?: string; }>; }
函数第一行,获取参数
const searchParams = await props.searchParams; const query = searchParams?.query || ''; const currentPage = Number(searchParams?.page) || 1;
搜索表格数据
-
打开注释,给/app/ui/invoices/table.tsx传参,返回表格数据
<Suspense key={query + currentPage} fallback={<InvoicesTableSkeleton />}> <Table query={query} currentPage={currentPage} /> </Suspense>
可以看到页面搜索出来数据了
防抖
/app/ui/search.tsx中使用防抖,handleSearch函数上边添加useDebouncedCallback回调0.3秒不输入才去搜索
下载React 防抖
pnpm i use-debounce
// 直接在组件内定义 useDebouncedCallback
const debouncedSearch = useDebouncedCallback((term: string) => {
console.log(`Searching... ${term}`);
const params = new URLSearchParams(searchParams.toString()); // 确保是字符串格式
if (term) {
params.set('query', term);
} else {
params.delete('query');
}
replace(`${pathname}?${params.toString()}`);
}, 300);
// 事件处理函数调用 debouncedSearch
function handleSearch(term: string) {
debouncedSearch(term);
}
分页
-
/app/dashboard/invoices/page.tsx中加入
import { fetchInvoicesPages } from '@/app/lib/data'; <Pagination totalPages={totalPages} />//打开注释
-
/app/ui/invoices/pagination.tsx加入
import { usePathname, useSearchParams } from 'next/navigation'; //Pagination函数中加入,解开这个页面的注释 const pathname = usePathname(); const searchParams = useSearchParams(); const currentPage = Number(searchParams.get('page')) || 1; //创建搜索url const createPageURL = (pageNumber: number | string) => { const params = new URLSearchParams(searchParams); params.set('page', pageNumber.toString()); return `${pathname}?${params.toString()}`; };
-
如果想让用户输入搜索的时候页签是第一个可以/app/ui/search.tsx加入
const params = new URLSearchParams(searchParams);//参数下边加入 params.set('page', '1');
12.增删改票据
首先学会使用from和action
例子:
// Server Component
export default function Page() {
// Action
async function create(formData: FormData) {
'use server';
// Logic to mutate data...
}
// Invoke the action using the "action" attribute
return <form action={create}>...</form>;
}
1. 创建票据
-
/dashboard/invoices/create/page.tsx
import Form from '@/app/ui/invoices/create-form'; import Breadcrumbs from '@/app/ui/invoices/breadcrumbs'; import { fetchCustomers } from '@/app/lib/data'; export default async function Page() { const customers = await fetchCustomers(); //breadcrumbs一个面包屑导航组件,帮助用户了解当前页面在网站中的位置。比如从“发票”页面到“创建发票”页面的导航 //fetchCustomers() 异步获取客户数据,确保在渲染页面之前获取到相关的客户信息 return ( <main> <Breadcrumbs breadcrumbs={[ { label: 'Invoices', href: '/dashboard/invoices' }, { label: 'Create Invoice', href: '/dashboard/invoices/create', active: true, }, ]} /> <Form customers={customers} /> </main> ); }
创建Server Actions,新建/app/lib/actions.ts
'use server';
export async function createInvoice(formData: FormData) {}
导入action,/app/ui/invoices/create-form.tsx
import { createInvoice } from '@/app/lib/actions';
//from改为
<form action={createInvoice}>
在/app/lib/actions.ts中
//编辑异步函数,如果字段多用Object.fromEntries()
export async function createInvoice(formData: FormData) {
const rawFormData = {
customerId: formData.get('customerId'),
amount: formData.get('amount'),
status: formData.get('status'),
};
// Test it out:
console.log(rawFormData);
}
在/app/lib/definitions.ts中有Invoice的定义
/app/lib/actions.ts中,使用验证库验证入参
'use server';
import { z } from 'zod';
const FormSchema = z.object({
id: z.string(),
customerId: z.string(),
amount: z.coerce.number(),
status: z.enum(['pending', 'paid']),
date: z.string(),
});
const CreateInvoice = FormSchema.omit({ id: true, date: true });
export async function createInvoice(formData: FormData) {
// ...
}
在/app/lib/definitions.ts中,开始验证
// ...
export async function createInvoice(formData: FormData) {
const { customerId, amount, status } = CreateInvoice.parse({
customerId: formData.get('customerId'),
amount: formData.get('amount'),
status: formData.get('status'),
});
}
//console.log(rawFormData);
在/app/lib/definitions.ts中,用分消除浮点错误
//createInvoice函数最后一行加入
const amountInCents = amount * 100;
在/app/lib/definitions.ts中,创建日期
//createInvoice函数最后一行加入
const date = new Date().toISOString().split('T')[0];
在/app/lib/actions.ts中,插入数据库
import postgres from 'postgres';
const sql = postgres(process.env.POSTGRES_URL!, { ssl: 'require' });
// ...
//createInvoice函数最后一行加入
await sql`
INSERT INTO invoices (customer_id, amount, status, date)
VALUES (${customerId}, ${amountInCents}, ${status}, ${date})
`;
在/app/lib/actions.ts中,由于要更新发票路由中显示的数据,因此需要清除此缓存并触发对服务器的新请求
import { revalidatePath } from 'next/cache';
//createInvoice函数最后一行加入
revalidatePath('/dashboard/invoices');
在/app/lib/actions.ts中,重定向回invoices页
import { redirect } from 'next/navigation';
//createInvoice函数最后一行加入
redirect('/dashboard/invoices');
1.1测试添加
2. 更新票据
/app/ui/invoices/table.tsx中更新按钮会将id传入更新页面,InvoicesTable函数返回的td中的
/app/ui/invoices/buttons.tsx中,UpdateInvoice函数,Link修改
href={`/dashboard/invoices/${id}/edit`}
创建一个新的动态路由,/app/dashboard/invoices/[id]/edit/page.tsx
import Form from '@/app/ui/invoices/edit-form';
import Breadcrumbs from '@/app/ui/invoices/breadcrumbs';
import { fetchCustomers } from '@/app/lib/data';
export default async function Page() {
return (
<main>
<Breadcrumbs
breadcrumbs={[
{ label: 'Invoices', href: '/dashboard/invoices' },
{
label: 'Edit Invoice',
href: `/dashboard/invoices/${id}/edit`,
active: true,
},
]}
/>
<Form invoice={invoice} customers={customers} />
</main>
);
}
/app/dashboard/invoices/[id]/edit/page.tsx中,给page加上入参
export default async function Page(props: { params: Promise<{ id: string }> }) {
const params = await props.params;
const id = params.id;
// ...
}
/dashboard/invoices/[id]/edit/page.tsx中,根据id获取数据
import { fetchInvoiceById, fetchCustomers } from '@/app/lib/data';//引入
//Page函数中获取数据
export default async function Page(props: { params: Promise<{ id: string }> }) {
// ...
const [invoice, customers] = await Promise.all([
fetchInvoiceById(id),
fetchCustomers(),
]);
// ...
访问: http://localhost:3000/dashboard/invoices,点击编辑就会展示数据,url改变为http://localhost:3000/dashboard/invoice/uuid/edit。
在/app/ui/invoices/edit-form.tsx中
//这样传值是错误的
//<form action={updateInvoice(id)}>
// ...
import { updateInvoice } from '@/app/lib/actions';
export default function EditInvoiceForm({
invoice,
customers,
}: {
invoice: InvoiceForm;
customers: CustomerField[];
}) {
const updateInvoiceWithId = updateInvoice.bind(null, invoice.id);
return <form action={updateInvoiceWithId}>{/* ... */}</form>;
}
在/app/lib/actions.ts创建个UpdateInvoice操作
// Use Zod to update the expected types
const UpdateInvoice = FormSchema.omit({ id: true, date: true });
// ...
export async function updateInvoice(id: string, formData: FormData) {
const { customerId, amount, status } = UpdateInvoice.parse({
customerId: formData.get('customerId'),
amount: formData.get('amount'),
status: formData.get('status'),
});
const amountInCents = amount * 100;
await sql`
UPDATE invoices
SET customer_id = ${customerId}, amount = ${amountInCents}, status = ${status}
WHERE id = ${id}
`;
revalidatePath('/dashboard/invoices');
redirect('/dashboard/invoices');
}
2.1测试更新
3. 删除票据
在/app/ui/invoices/buttons.tsx中,传入id
import { deleteInvoice } from '@/app/lib/actions';
// ...
export function DeleteInvoice({ id }: { id: string }) {
const deleteInvoiceWithId = deleteInvoice.bind(null, id);
return (
<form action={deleteInvoiceWithId}>
<button type="submit" className="rounded-md border p-2 hover:bg-gray-100">
<span className="sr-only">Delete</span>
<TrashIcon className="w-5" />
</button>
</form>
);
}
/app/lib/actions.ts创建一个deleteInvoice操作,因为没跳转页面不需要redirect
export async function deleteInvoice(id: string) {
await sql`DELETE FROM invoices WHERE id = ${id}`;
revalidatePath('/dashboard/invoices');
}
3.1测试删除
13.错误处理
1.添加try/catch
/app/lib/actions.ts中,sql一般需要加上
//createInvoice操作
try {
await sql`
INSERT INTO invoices (customer_id, amount, status, date)
VALUES (${customerId}, ${amountInCents}, ${status}, ${date})
`;
} catch (error) {
// We'll log the error to the console for now
console.error(error);
}
//updateInvoice操作
try {
await sql`
UPDATE invoices
SET customer_id = ${customerId}, amount = ${amountInCents}, status = ${status}
WHERE id = ${id}
`;
} catch (error) {
// We'll log the error to the console for now
console.error(error);
}
注意如何在try/catch块之外调用redirect。这是因为重定向通过抛出错误来工作,而错误将被catch块捕获。为了避免这种情况,可以在try/catch之后调用redirect。只有在try成功时才能访问重定向。举个例子(这个不需要复制到代码中,只是加深大家理解,这里项目中是会遇到的):
export async function createInvoice(formData: FormData) {
let redirectUrl = ''; // 用于存储重定向的 URL
try {
// 解析并验证表单数据
const parsedData = CreateInvoice.parse(Object.fromEntries(formData));
// 如果解析成功,进行发票创建的逻辑
console.log('Parsed Data:', parsedData);
// 模拟保存发票或其他处理逻辑
// 假设这里是保存发票到数据库的代码
// 如果一切顺利,设置成功重定向的 URL
redirectUrl = '/success'; // 设定成功后的重定向页面
} catch (error) {
if (error instanceof z.ZodError) {
// 处理验证错误
console.error('验证失败:', error.errors);
redirectUrl = '/error'; // 如果验证失败,重定向到错误页面
} else {
// 处理其他未预料的错误
console.error('发生了一个意外错误:', error);
redirectUrl = '/error'; // 遇到其他错误时重定向到错误页面
}
}
// 在 try/catch 之后进行重定向
if (redirectUrl) {
redirect(redirectUrl); // 执行重定向
}
}
/app/lib/actions.ts中deleteInvoice操作使用手动抛出异常的方式
export async function deleteInvoice(id: string) {
throw new Error('Failed to Delete Invoice');
// Unreachable code block
//await sql`DELETE FROM invoices WHERE id = ${id}`;
//revalidatePath('/dashboard/invoices');
}
1.1 测试删除失败
2.处理错误
使用error.tsx处理所有错误,创建个/dashboard/invoices/error.tsx,reset
按钮:
- 点击后会执行
reset()
,尝试重新渲染组件(在 Next.js 中,它会重新加载页面)。
'use client';
import { useEffect } from 'react';
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
// Optionally log the error to an error reporting service
console.error(error);
}, [error]);
return (
<main className="flex h-full flex-col items-center justify-center">
<h2 className="text-center">Something went wrong!</h2>
<button
className="mt-4 rounded-md bg-blue-500 px-4 py-2 text-sm text-white transition-colors hover:bg-blue-400"
onClick={
// Attempt to recover by trying to re-render the invoices route
() => reset()
}
>
Try again
</button>
</main>
);
}
点击删除按钮,会有try again的按钮,点击try again会继续返回到删除按钮的列表页面,就是删除之前的页面。外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传
使用notFound函数处理404错误,在/app/lib/data.ts中,加个打印看看uuid
export async function fetchInvoiceById(id: string) {
try {
// ...
console.log(invoice); // Invoice is an empty array []
return invoice[0];
} catch (error) {
console.error('Database Error:', error);
throw new Error('Failed to fetch invoice.');
}
}
访问http://localhost:3000/dashboard/invoices/2e94d1ed-d220-449f-9f11-f0bbceed9645/edit,这个时候还是返回try again
/dashboard/invoices/[id]/edit/page.tsx中,添加
import { notFound } from 'next/navigation';//加入
//Page函数使用Promise.all返回invoice后,判断数据是否存在,下边加上
if (!invoice) {
notFound();
}
创建个/dashboard/invoices/[id]/edit/not-found.tsx页面,notFound优先于
error.tsx
import Link from 'next/link';
import { FaceFrownIcon } from '@heroicons/react/24/outline';
export default function NotFound() {
return (
<main className="flex h-full flex-col items-center justify-center gap-2">
<FaceFrownIcon className="w-10 text-gray-400" />
<h2 className="text-xl font-semibold">404 Not Found</h2>
<p>Could not find the requested invoice.</p>
<Link
href="/dashboard/invoices"
className="mt-4 rounded-md bg-blue-500 px-4 py-2 text-sm text-white transition-colors hover:bg-blue-400"
>
Go Back
</Link>
</main>
);
}
这个时候访问http://localhost:3000/dashboard/invoices/2e94d1ed-d220-449f-9f11-f0bbceed9645/edit,会返回notFound
把app/lib/actions.ts中deleteInvoice改回来吧,测试结束
错误处理文档链接
14.提高易用性
1. eslint-plugin-jsx-a11y
Next.js包含eslint-plugin-jsx-a11y,在ESLint配置中添加插件,以帮助及早发现可访问性问题
/package.json中增加
//scripts中加入
"lint": "next lint"
运行
pnpm lint
显示,不然你提交到vercel会部署失败
✔ No ESLint warnings or errors
验证ESlint,/app/ui/invoices/table.tsx中
<Image
src={invoice.image_url}
className="rounded-full"
width={28}
height={28}
alt={`${invoice.name}'s profile picture`} // 删除这行
/>
你会发现错误
./app/ui/invoices/table.tsx
88:23 Warning: Image elements must have an alt prop, either with meaningful text, or an empty string for decorative images. jsx-a11y/alt-text
2. 表单验证
在http://localhost:3000/dashboard/invoices/create页面,不填任何东西,点击提交按钮,请求服务端直接报错,
2.1客户端验证
我们可以在客户端加个required属性
/app/ui/invoices/create-form.tsx中,加入required,测试
<input
id="amount"
name="amount"
type="number"
placeholder="Enter USD amount"
className="peer block w-full rounded-md border border-gray-200 py-2 pl-10 text-sm outline-2 placeholder:text-gray-500"
required
/>
验证后删除这个验证,开始测试服务端验证。
2.2服务端验证
也可以使用服务器端验证,通过验证服务器上的表单,可以解决:
- 确保您的数据在发送到数据库之前是预期的格式
- 降低恶意用户绕过客户端验证的风险
- 对于被认为有效的数据,有一个真实的来源
使用React的useActionState钩子来处理表单错误
useActionState钩子:
•有两个参数:(action, initialState)。
•返回两个值:[state, formAction] -表单状态,以及表单提交时调用的函数。
2.2.1添加
/app/ui/invoices/create-form.tsx中,
'use client';//标记为客户端
// ...
import { useActionState } from 'react'; // 导入 useActionState hook(可能是自定义 hook)
export default function Form({ customers }: { customers: CustomerField[] }) {
// 使用 useActionState hook 来创建表单的状态和动作(创建发票的动作)
const [state, formAction] = useActionState(createInvoice, initialState);
// ...
return <form action={formAction}>...</form>;// 渲染一个表单,表单的动作由 formAction 提供
}
/app/ui/invoices/create-form.tsx中,可以定义个initialState
// ...
import { createInvoice, State } from '@/app/lib/actions'; // 从指定路径导入 createInvoice 函数和 State 类型
export default function Form({ customers }: { customers: CustomerField[] }) {
// 定义初始状态
const initialState: State = { message: null, errors: {} };
// ...
/app/lib/actions.ts中,修改FormSchema,加入异常返回参数
const FormSchema = z.object({
id: z.string(),
customerId: z.string({
invalid_type_error: 'Please select a customer.',
}),
amount: z.coerce
.number()
.gt(0, { message: 'Please enter an amount greater than $0.' }),
status: z.enum(['pending', 'paid'], {
invalid_type_error: 'Please select an invoice status.',
}),
date: z.string(),
});
在/app/lib/actions.ts中,加入状态
// ...
export type State = {
errors?: {
customerId?: string[];
amount?: string[];
status?: string[];
};
message?: string | null;
};
export async function createInvoice(prevState: State, formData: FormData) {//使用状态
// ...
}
/app/lib/actions.ts中,safeParse()将返回一个包含成功或错误字段的对象。这将有助于更优雅地处理验证,而无需将此逻辑放入try/catch块中。
//createInvoice函数中,CreateInvoice.parse改为
const validatedFields = CreateInvoice.safeParse({
/app/lib/actions.ts中,validatedFields属性下边继续加验证
if (!validatedFields.success) {
return {
errors: validatedFields.error.flatten().fieldErrors,
message: 'Missing Fields. Failed to Create Invoice.',
};
}
/app/lib/actions.ts中,验证后使用validatedFields.data获取数据
const { customerId, amount, status } = validatedFields.data;//结构数据
/app/ui/invoices/create-form.tsx中的select标签属性id为customer的加入属性
aria-describedby="customer-error"
/app/ui/invoices/create-form.tsx中,在
<div id="customer-error" aria-live="polite" aria-atomic="true">
{state.errors?.customerId &&
state.errors.customerId.map((error: string) => (
<p className="mt-2 text-sm text-red-500" key={error}>
{error}
</p>
))}
</div>
/app/ui/invoices/create-form.tsx中的select标签属性id为amount的加入属性
aria-describedby="amount-error"
/app/ui/invoices/create-form.tsx中,在
<div id="amount-error" aria-live="polite" aria-atomic="true">
{state.errors?.amount &&
state.errors.amount.map((error: string) => (
<p className="mt-2 text-sm text-red-500" key={error}>
{error}
</p>
))}
</div>
/app/ui/invoices/create-form.tsx中的select标签属性id为pending和paid都加入属性
aria-describedby="status-error"
/app/ui/invoices/create-form.tsx中,在fieldset标签中加入,展示错误提示
<div id="amount-error" aria-live="polite" aria-atomic="true">
{state.errors?.amount &&
state.errors.amount.map((error: string) => (
<p className="mt-2 text-sm text-red-500" key={error}>
{error}
</p>
))}
</div>
3.2 编辑
/app/ui/invoices/edit-form.tsx也跟添加一样,代码如下
// ...
import { updateInvoice, State } from '@/app/lib/actions';
import { useActionState } from 'react';
export default function EditInvoiceForm({
invoice,
customers,
}: {
invoice: InvoiceForm;
customers: CustomerField[];
}) {
const initialState: State = { message: null, errors: {} };
const updateInvoiceWithId = updateInvoice.bind(null, invoice.id);
const [state, formAction] = useActionState(updateInvoiceWithId, initialState);
return <form action={formAction}>{/* ... */}</form>;
}
/app/lib/actions.ts
export async function updateInvoice(
id: string,
prevState: State,
formData: FormData,
) {
const validatedFields = UpdateInvoice.safeParse({
customerId: formData.get('customerId'),
amount: formData.get('amount'),
status: formData.get('status'),
});
if (!validatedFields.success) {
return {
errors: validatedFields.error.flatten().fieldErrors,
message: 'Missing Fields. Failed to Update Invoice.',
};
}
const { customerId, amount, status } = validatedFields.data;
const amountInCents = amount * 100;
try {
await sql`
UPDATE invoices
SET customer_id = ${customerId}, amount = ${amountInCents}, status = ${status}
WHERE id = ${id}
`;
} catch (error) {
return { message: 'Database Error: Failed to Update Invoice.' };
}
revalidatePath('/dashboard/invoices');
redirect('/dashboard/invoices');
}
在/app/ui/invoices/edit-form.tsx和添加一样的加法