在现代 web 开发中,Next.js 已经成为构建快速、可扩展且 SEO 友好的 React 应用程序的最流行框架之一。它的强大功能,例如服务器端渲染 (SSR),使其成为一个适用于各种项目的多功能框架。然而,就像任何强大的框架一样,遵守最佳实践对于确保项目随着发展保持可维护性、可扩展性和高性能至关重要。
在这篇博客中,我们将探讨构建高效 Next.js 应用程序的五个关键最佳实践:组织文件结构的规范、服务器端获取数据、组件分离、使用 react-hook-form 和 Zod 创建可扩展的表单,同时进行数据验证,以及利用自定义钩子来实现可重用的逻辑。采用这些实践将帮助你简化开发过程,提升代码质量,并保持代码整洁、易于扩展。
1. 文件结构在前端项目中,有5个值得注意的文件夹,这些文件夹包含了样板代码。
- app/:仅处理路由、布局、CSS 和显示页面的组件
- components/:包含可重用的 UI 组件和特定页面的组件,使代码更模块化且单一功能化
- lib/:包含辅助函数和与 API 相关的逻辑。这里存放的是可重用且与特定组件或页面无关的逻辑,如数据获取工具或格式化助手函数
- hooks/:用于自定义的 React 钩子,封装可重用的逻辑,比如数据获取、状态处理或其他共享的业务逻辑。自定义钩子可以简化需要在组件之间共享的复杂状态逻辑,使其更加简洁
- types/:存放所有类型定义,集中管理在此处,便于在整个应用程序中维护和引用
这个文件结构的目的如下,好处有:
- 组织清晰性:它提供了一个清晰的结构:它使项目更容易导航和理解,尤其是在复杂性增加时。每个文件夹都有一个明确的角色,有助于开发人员和新团队成员找到或添加代码。
- 模块化和可重用性:通过分离关注点(例如,UI组件、业务逻辑、实用函数)来促进代码重用,这减少了重复,从而形成一个更高效且易于维护的代码库。
- 可扩展性:该结构允许项目平滑扩展,通过添加新功能而不使代码库变得纠缠复杂。它支持功能、团队规模和项目规模的增长。
- 可维护性:通过明确的责任分离,更容易维护和重构代码。当代码模块化且组织良好时,更容易发现和解决错误和问题。
- 协作:这种井井有条的结构促进了团队内部更好的协作。开发人员可以在项目的不同部分工作而不互相干扰,新团队成员可以快速理解项目的架构。
- 性能优化:一个有组织的代码库有助于识别性能瓶颈并更高效地优化它们,例如懒加载组件、优化API调用或组织数据获取逻辑。
我们来举个例子,这些组件是在一个嵌套的 app
文件夹里。
这使得页面变得更复杂,降低了清晰度,增加了复杂性。现在的app
文件夹不仅处理可重用的UI组件,还处理特定页面的组件,这使得结构不再那么模块化。
服务器端渲染 (SSR) 会在服务器上生成网页的 HTML,然后发送到浏览器。这与客户端渲染 (CSR) 不同,后者中浏览器加载一个最小的 HTML 文件,并使用 JavaScript 获取和显示内容。在 Next.js 中,SSR 通过其文件路由和特殊生命周期方法内置实现。当用户请求一个页面时,Next.js 在服务器上渲染该页面,处理数据获取或处理数据,并在客户端发送预渲染的 HTML,使页面更快地变得可交互。
- 提升 SEO(搜索引擎优化):服务器端获取确保内容在发送到客户端之前完全渲染。这对搜索引擎来说至关重要,因为它们可以读取完整页面,而不需要依赖 JavaScript 在页面加载后填充页面。
- 更快的初始加载:由于页面已经预渲染了数据,用户会立即看到完整的内容,从而减少了加载时间的感觉。这对于使用较慢网络连接或无法高效处理客户端 JavaScript 渲染的用户尤为重要。
- 更好的性能:服务器端获取将数据抓取和渲染的任务转移到服务器,减少了客户端的工作负载。这对于资源有限的设备(例如移动设备或旧电脑)来说是有益的。
- 更好的用户体验:用户会在初始加载时看到完全填充的页面,这使应用程序感觉更快、更响应。在页面加载后无需额外的网络请求来获取数据,从而改善了整体体验。
- 减少初始页面的延迟:通过服务器端获取数据并在页面发送给客户端之前渲染,服务器可以从更快的来源(如数据库或缓存)获取数据,以加快初始页面加载。
- 增强安全性:服务器端获取可以在服务器端获取并渲染敏感数据,从而更好地控制敏感数据,减少在客户端暴露敏感信息的风险。
Next.js 的应用路由默认将页面作为服务器组件。这里举一个服务器组件的例子:
// app/pond/page.tsx 文件
...
const 池塘列表页面 = async () => {
const 获取池塘列表 = async () => {
try {
const 池塘: Pond[] = await fetchPonds();
return 池塘;
} catch (error) {
return [];
}
}
const 池塘 = await 获取池塘列表();
...
return (
<div>
...
</div>
);
}
export default 池塘列表页面;
我们可以看到下面的 .gif,页面加载后,一切都已经被服务器预获取和预渲染。因此,用户不需要额外的网络请求来获取数据,用户也不会在组件加载后感觉到数据加载的过程。
我们将 pond 获取功能也做成一个服务器动作,从而可以获取环境变量和读取 cookies,这样可以隐藏 API 密钥或 API 地址等敏感信息。
// lib/pond/fetch-ponds.ts
'use server'
import { Pond } from "@/types/pond";
import { cookies } from "next/headers";
export async function fetchPonds(): Promise<Pond[]> {
const token = cookies().get('accessToken')?.value
const res = await fetch(`${process.env.API_BASE_URL}/api/pond/`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
}
})
const data = await res.json()
if (!res.ok) {
throw new Error(data.message)
}
return data
}
.env文件(环境配置文件)
3 组件分割前端代码可能会很复杂,包含很多代码行,尤其是在处理、提交和获取功能以及视图方面。因此需要分离,以减少代码的复杂性,防止一个组件承担过多的责任,这最终使得管理和调试变得更加容易。
例如,AddPond
功能会打开一个弹窗,里面有一个表单来填写池塘的相关信息。
AddPond.tsx (添加池塘组件文件)
下面的代码将处理作为模态弹出框开启器的按钮,包括处理弹出框的打开状态。
'use client';
import React, { useState } from 'react';
import { Button } from '@/components/ui/button';
import { IoIosAdd } from 'react-icons/io';
import { Modal as DialogContent } from '@/components/ui/modal';
import { PondForm } from '@/components/pond';
import { Dialog, DialogTrigger } from '@/components/ui/dialog';
const AddPond: React.FC<React.HTMLAttributes<HTMLDivElement>> = (props) => {
const [isModalOpen, setIsModalOpen] = useState(false)
return (
<div {...props}>
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogTrigger asChild>
<Button className='flex'>
新增池塘{' '}<IoIosAdd size={20} className='ml-1' />
</Button>
</DialogTrigger>
<DialogContent title='新增池塘'>
<PondForm setIsModalOpen={setIsModalOpen} />
</DialogContent>
</Dialog>
</div>
);
};
export default AddPond;
PondForm.tsx
下面的代码处理表单输入、验证和提交的过程,如果有错误,则显示错误提示。
'use client'
...
const PondForm: React.FC<PondFormProps> = ({ pond, setIsModalOpen }) => {
...
const {
register,
handleSubmit,
watch,
formState: { errors, isSubmitting },
reset
} = useForm<PondInput>({
resolver: zodResolver(PondInputSchema),
defaultValues: pond && {
name: pond.name,
length: pond.length,
width: pond.width,
depth: pond.depth,
}
})
...
const onSubmit = async (data: PondInput) => {
try {
setError(null)
const imageList = data.image as FileList
data.image = imageList[0]
const formData = objectToFormData(data)
const res = await addOrUpdatePond(formData, pond?.pond_id)
if (!res.success) {
setError('保存失败')
return
}
reset()
setIsModalOpen(false)
window.location.reload()
} catch (error) {
setError('保存失败')
}
}
...
return (
<div>
<form className='space-y-4' onSubmit={handleSubmit(onSubmit)}>
<div>
<Input
{...register('name')}
placeholder='池塘名'
className='h-12'
/>
{errors.name && <p className='text-red-500 mt-1 text-sm'>{errors.name.message?.toString()}</p>}
</div>
...
<Button className='w-full bg-blue-500 hover:bg-blue-600 active:bg-blue-700' type='submit' disabled={isSubmitting}>
保存
</Button>
{error && <p className='w-full text-center text-red-500'>{error}</p>}
</form>
</div>
)
}
export default PondForm
正如我们所见,PondForm.tsx
的代码已经足够长了。我们不想让一个文件同时处理模态框的打开和表单,更不用说让一个组件包含大量的代码了。
在处理 Next.js 表单时,尤其是数据复杂且需要严格验证的情况下,结合 react-hook-form
和 zod
的使用可显著提升性能和代码可读性。
react-hook-form
高度优化以高效处理 React 表单,高效减少渲染次数,并内置支持受控和非受控输入。zod
提供了一个强大的、与 TypeScript 兼容的验证库,可以与react-hook-form
无缝集成,允许你定义和实施复杂的验证规则。
这种组合使您可以创建既坚固又高度可重用的表单组件,这些组件易于维护和适应需求的变化。
一个比如是上面几段提到的PondForm
组件。不过,这里展示的是它使用的架构和推断类型。
// schema.ts
import { z } from 'zod';
export const PondInputSchema = z.object({
name: z.string().min(1, { message: '请输入池塘名字' }),
length: z.number().positive({ message: '长度必须为正数' }),
width: z.number().positive({ message: '宽度必须为正数' }),
depth: z.number().positive({ message: '深度必须为正数' }),
image: z.any().optional(),
})
export type PondInput = z.infer<typeof PondInputSchema>
PondInputSchema
定义了表单数据的预期结构和验证规则。它指定了每个字段的要求,例如,name
是必填字段,而 length
必须是正数。这种集中的方式确保在整个应用程序中验证的一致性。通过在一个地方设置这些规则,你可以使验证逻辑更加可重用且便于更新。从而减少可能出现的错误。
PondInput
这个推断出的类型是由模式自动生成的,为 TypeScript 提供了正确的数据结构支持。此推断出的类型确保了类型安全,因此,任何引用 PondInput
的代码都会遵循模式中定义的结构。它还使编辑器中的自动完成和错误检查功能得以实现,从而使开发更加顺畅无阻,通过在编码过程中捕获问题,从而降低运行时错误的风险。
此外,来自 react-hook-form
的 useForm
钩子通过处理表单状态、验证和提交来简化表单管理。当与 zod
结合使用时,它会利用 zodResolver
根据模式自动验证表单数据,从而用户在操作表单时无需手动跟踪输入或错误。此钩子还提供了诸如 handleSubmit
之类的函数来处理表单提交,以及 register
函数来将输入与表单状态关联,提高代码可读性并减少冗余代码。
const PondForm: React.FC<PondFormProps> = ({ pond, setIsModalOpen }) => {
...
const {
register,
handleSubmit,
watch,
formState: { errors, isSubmitting },
reset
} = useForm<PondInput>({
resolver: zodResolver(PondInputSchema), // 解决函数,用于解析表单数据
defaultValues: pond && { // 默认值,如果存在pond对象,则初始化这些值
name: pond.name,
length: pond.length,
width: pond.width,
depth: pond.depth,
}
})
未使用 react-hook-form
和 zod
时的扩展性问题
这里展示了一个不用react-hook-form
或zod
的表单实现,我们可以从中看出不使用这些库的差异以及为什么使用它们会有好处。
const PondForm: React.FC<PondFormProps> = ({ pond, setIsModalOpen }) => {
const [formData, setFormData] = useState({
name: pond?.name || '',
length: pond?.length || '',
width: pond?.width || '',
depth: pond?.depth || '',
image: null,
});
...
const [error, setError] = useState<string | null>(null);
const [formErrors, setFormErrors] = useState<any>({});
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value, files } = e.target;
setFormData({
...formData,
[name]: files ? files[0] : value,
});
};
const validateForm = () => {
const errors: any = {};
if (!formData.name) errors.name = '请输入池塘名称';
if (Number(formData.length) <= 0) errors.length = '请输入正确的长度';
if (Number(formData.width) <= 0) errors.width = '请输入正确的宽度';
if (Number(formData.depth) <= 0) errors.depth = '请输入正确的深度';
setFormErrors(errors);
return Object.keys(errors).length === 0;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!validateForm()) return;
try {
setError(null);
const data = objectToFormData(formData);
const res = await addOrUpdatePond(data, pond?.pond_id);
if (!res.success) {
setError('保存池塘失败');
return;
}
setIsModalOpen(false);
window.location.reload();
} catch (error) {
setError('保存池塘失败');
}
};
...
return (
<div>
<form className='space-y-4' onSubmit={handleSubmit}>
<div>
<Input
name='name'
value={formData.name}
onChange={handleChange}
placeholder='池塘名称'
className='h-12'
/>
{formErrors.name && <p className='text-red-500 mt-1 text-sm'>{formErrors.name}</p>}
</div>
...
<Button className='w-full bg-blue-500 hover:bg-blue-600 active:bg-blue-700' type='submit'>
保存更改
</Button>
{error && <p className='w-full text-center text-red-500'>{error}</p>}
</form>
</div>
);
};
export default PondForm;
随着表单变得越来越复杂(例如,更多的字段、动态输入或条件验证),不使用 react-hook-form
和 zod
这样的工具手动管理表单会越来越困难:
- 手动状态追踪:对于较大的表单,手动跟踪每个字段的状态并处理每个输入字段的变化会产生大量的样板代码,这会导致更多的冗余代码并增加错误率,这使得表单更难维护。
- 重复且容易出错的验证:没有使用
zod
,验证逻辑需要为每个表单字段重复编写。随着表单变大,这可能会导致验证不一致或遗漏,尤其是在添加或删除字段时。 - 处理动态字段:当需要动态添加或移除字段时,手动更新每个字段的状态和验证会变得非常麻烦。在大型表单中,这可能导致状态不匹配或验证错误。
- 错误管理变得复杂:随着表单的规模扩大,跟踪和显示每个字段的错误变得更加困难,尤其是处理深层嵌套数据结构或条件验证时。
react-hook-form
和 zod
解决这些问题呢
- 状态管理的扩展性:
react-hook-form
自动跟踪并更新每个表单字段的状态。随着表单的增长,你不需要为每个字段编写自定义的状态管理代码,这使得扩展变得更加容易。这减少了错误的风险,尤其是在大型表单中。 - 集中和可扩展的验证:通过
zod
,验证逻辑集中在一个模式中。你可以在一个地方定义所有规则,如果需要添加、移除或更新一个字段,只需要修改模式即可。这保持了你的验证逻辑的一致性和可扩展性。 - 动态字段处理:
react-hook-form
支持轻松地动态添加/移除字段,当添加或移除字段时,会自动更新表单数据及其验证规则。这使得表单更加灵活和适应不断变化的需求。 - 高效的错误管理:
react-hook-form
通过自动管理每个字段的错误状态,使错误管理更加可扩展。这确保了当表单增长时,你不需要手动管理每个输入字段的验证消息。与zod
的集成确保了错误消息的一致性和易管理性,即使在复杂的表单中也是如此。 - 保持类型安全性:当表单结构发生变化(如添加/移除字段或更改字段类型)时,
zod
和react-hook-form
确保表单保持类型安全。从zod
推断出的类型会随着模式的更改而自动更新,确保在编译时而非运行时捕获任何结构变化。 - 性能优化:使用
react-hook-form
,只有与表单状态交互的字段元素才会重新渲染,这改善了性能,特别是在大型表单中。(你可以在 react-hook-form 官方网站上查看这种效果)
没有 react-hook-form
和 zod
,管理大型复杂表单时,由于状态管理、验证、动态字段和错误追踪的挑战,很容易出现错误。这些库自动处理这些任务,使表单更容易扩展,减少重复,并保持代码清晰。react-hook-form
的状态管理和 zod
的基于模式的验证相结合,确保表单能够无缝扩展且没有错误。
在 React 和 Next.js 中,自定义钩子是一种强大的抽象出逻辑、减少重复并提高应用的可维护性和可扩展性的方法。虽然 React 内置了一些钩子,如 useState
、useEffect
和 useContext
,自定义钩子允许开发者将可复用的逻辑打包进一个函数中。这样可以提高应用的可维护性和可扩展性。
'use client'
...
const Sidebar = () => {
const [user, setUser] = useState(null);
const [open, setOpen] = useState(false);
const [pathname, setPathname] = usePathname();
useEffect(() => {
const fetchUser = async () => {
const fetchedUser = await getUser();
setUser(fetchedUser);
};
fetchUser();
return () => {
setUser(null);
};
}, [pathname]);
const handleLogout = async () => {
logout();
setOpen(false);
};
return (
/* 一些 JSX 代码关于侧边栏 */
)
};
export default Sidebar;
上面的例子使用了常见的 useState
状态变量,并在同一个组件里使用 useEffect
来获取和处理状态变化。下面就是使用自定义的 useUser
钩子来重构的组件。
// 侧边栏.tsx
...
const Sidebar = () => {
const [open, setOpen] = useState(false)
const user = useUser()
const handleLogout = async () => {
logout()
setOpen(false)
}
...
// useUser.tsx
export const useUser = () => { // 获取用户信息
const [user, setUser] = useState<User | null>(null);
const pathname = usePathname()
useEffect(() => {
const fetchUser = async () => {
const user = await getUser(); // 获取用户
setUser(user);
};
fetchUser();
return () => {
setUser(null); // 清除用户信息
};
}, [pathname]);
return user // 返回用户信息
}
自定义挂钩的好处:实例比较本质上,自定义钩子是利用React内置钩子来管理状态相关逻辑的JavaScript函数。它们是可以在不同组件间共享的可重用代码块,有助于代码的组织和重用。
- Kimera Moses, 2023年在Medium上
可复用性:
因此,我们可以让获取用户信息的逻辑在多个组件间复用。比如在没有自定义钩子的第一个例子中,获取和管理用户信息的逻辑直接写在了 Sidebar
组件里。这将导致每个需要获取用户信息的组件都需要重复这些代码,从而造成冗余。
将逻辑提取到 useUser
,我们现在可以在任何需要用户信息的组件中使用它,而无需重复编写获取数据和管理状态的代码。这减少了冗余,并遵循了 DRY(不要重复自己) 原则,使代码库在应用增长时更加易于维护。
抽象的概念
useUser
自定义钩子简化了获取和管理用户数据的过程。这使得 Sidebar
组件更加简洁明了,专注于其核心任务:渲染界面。如果没有自定义钩子,获取用户、处理副作用(例如 useEffect
)和管理状态(例如 useState
)的逻辑会把组件变得复杂,阅读和维护起来更加困难。
通过将此逻辑抽象为 useUser
,组件只需调用这个钩子,而用户数据的获取、处理和管理逻辑都被封装在这个钩子里面。这种抽象让组件变得更简单易读。
分离关注点
自定义钩子促进更好的关注点分离。在没有自定义钩子的 Sidebar
示例中,组件不仅负责管理 UI(渲染侧边栏、导航项),还负责获取用户数据。这种职责混杂使得随着复杂性的增加,组件更难维护。
通过使用自定义的 useUser
钩子,数据获取的责任完全从组件中移出。Sidebar
只关心根据用户数据来渲染 UI,而不关心数据是如何获取的。这带来了更好的代码组织和更易于维护。逻辑和展示层被清晰地分离。
试试看
自定义钩子更容易单独测试,因为它们可以在不依赖完整的组件或UI渲染的情况下进行测试。对于 useUser
,你可以在测试套件中模拟 getUser
函数的行为,并验证钩子可以正确处理获取的数据后,更新状态,并返回预期的结果。这一点使测试更加方便。
相比之下,如果没有自定义钩子,测试Sidebar
组件时还需要设置其内部的useEffect
和useState
逻辑来测试用户获取功能。这样会增加测试的复杂性,使测试更难管理。
通过将用户获取逻辑隔离在 useUser
中,测试变得更加简单,你可以确保应用程序的每一部分都能按预期运行,而无需进行复杂的组件层面的测试。
总之,自定义钩子(custom hooks)有助于你编写更易于维护、易于扩展和易于测试的 React 代码,帮助你保持代码的整洁、组织有序和高效。
结论通过遵循此博客中讨论的最佳实践——有效的文件组织、分离的组件和模块、优化服务器端数据获取、使用 react-hook-form 和 Zod 的可扩展表单功能以及可重用的自定义钩——您可以确保您的 Next.js 应用程序保持高效、可维护和可扩展。这些原则不仅能提升您的开发体验,还能使代码更加条理化。使团队合作更顺畅,并且更易于长期扩展项目。采用这些实践,可以充分发挥 Next.js 的优势,并轻松构建强大的应用程序。
参考如下: 使用静态类型检查的 TypeScript 先验模式验证 - zod.dev React表单验证钩子www.react-hook-form.com nextjs-boilerplate/docs/CODE_STYLE.md 在主分支中 · dwarvesf/nextjs-boilerplate 带有强烈观点的 React 模板,适用于构建大规模 web 应用程序 - nextjs-boilerplate/docs/CODE_STYLE.md 在主分支中 · …github.com 为什么要在 React Hook Form 中用 Zod?Zod 是一个主要基于 TypeScript 的模式声明和验证库,让你可以定义结构和类型。…www.linkedin.com React 和 Next.js 中的自定义钩子的力量 —— 探索[https://medium.com/@markminj/理解next-js中的ssr及其优势-e54ffed48294#:~:text=Next.js 中的服务器端渲染 (SSR),提供更好的用户体验](https://medium.com/@markminj/理解next-js中的ssr及其优势-e54ffed48294#:~:text=Next.js 中的服务器端渲染 (SSR),提供更好的用户体验)