next.js-学习4
- 创业
- 2025-09-21 08:54:01

next.js-学习4 11.添加搜索和分页添加搜索框搜索表格数据防抖分页 12.增删改票据1. 创建票据1.1测试添加 2. 更新票据2.1测试更新 3. 删除票据3.1测试删除 13.错误处理1.添加try/catch1.1 测试删除失败 2.处理错误错误处理文档链接 14.提高易用性1. eslint-plugin-jsx-a11y2. 表单验证2.1客户端验证2.2服务端验证2.2.1添加 3.2 编辑
更新下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改回来吧,测试结束
错误处理文档链接 Error Handlingerror.js API ReferencenotFound() API Referencenot-found.js API Reference 14.提高易用性 1. eslint-plugin-jsx-a11yNext.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和添加一样的加法
next.js-学习4由讯客互联创业栏目发布,感谢您对讯客互联的认可,以及对我们原创作品以及文章的青睐,非常欢迎各位朋友分享到个人网站或者朋友圈,但转载请说明文章出处“next.js-学习4”