使用 Next.js 四年后,我如何处理和构建企业前端应用程序
哎哟
介绍
在快节奏的前端开发领域,保持领先地位对于构建成功的企业级应用至关重要。经过四年的使用 Next.js 以及包含 Tailwind CSS、TypeScript、TurboRepo、ESLint、React Query 等强大工具包,我积累了宝贵的见解和最佳实践,愿意与其他开发者分享。在本篇博文中,我们将探讨如何为大型企业构建和构建前端应用程序,最大限度地提高性能、可维护性和可扩展性。
注意:本文表达的是个人观点,我提倡的方法可能不适合你的具体情况。
有效企业前端架构的指导原则
在为企业级应用程序构建前端解决方案时,拥有一套明确的原则就像指引方向的北极星,确保你的开发工作顺利进行。在本节中,我将分享我在企业环境中使用 Next.js 的经验中总结出的指导原则。
模块化和组件化
原则:分而治之
在企业级应用的庞大格局中,代码很快就会变得难以驾驭。拥抱模块化和组件化,将前端分解成易于管理的部分。将组件视为乐高积木,每个积木都有特定的用途。这不仅增强了代码的可重用性,还简化了开发团队的维护和协作。不仅要考虑将应用程序拆分成更小的组件,还要考虑将其分解成更小的独立应用程序的可能性。这正是 Turbo repo 等工具的优势所在。
关注点分离(SoC)
原则:保持代码库整洁
为了保持代码的完整性,请遵循关注点分离 (SoC) 原则。确保您的组件专注于各自的职责,无论是渲染 UI、处理业务逻辑还是管理状态。这种分离不仅使代码更易于理解,也方便测试和调试。
可扩展性设计
原则:规划增长
企业应用程序并非一成不变,而是不断发展演变的。设计前端架构时,务必考虑可扩展性。这意味着,所选择的模式和工具必须能够适应日益增长的流量、数据量和功能复杂性。Next.js 的可扩展性友好型设计可以成为您实现这一目标的宝贵助力。
可维护性和代码质量
原则:精心制作
代码是产品的基础。从第一天起,就优先考虑可维护性和代码质量。执行编码规范,进行代码审查,并投入资金进行自动化测试。维护良好的代码库不仅更易于使用,而且不易出现错误和回归问题。在工作中,我最近开发了一个组件库和一个基本的样式指南,用于在我们的前端应用程序上强制执行标准。文档还没写完,别介意😂。
默认可访问性
原则:从一开始就包容
无障碍是现代 Web 开发中不可或缺的一部分。从一开始就将其作为默认做法。确保您的应用程序可供所有人使用,无论是否残障。利用 Next.js 对无障碍标准和工具的支持,打造包容性的用户体验。对于一些需要无障碍的组件,例如标签页、下拉菜单等,我使用Radix UI等工具。
以绩效为导向的发展
原则:速度很重要
企业用户期望流畅的体验。务必始终优先考虑性能。优化资源,减少不必要的请求,并充分利用 Next.js 的性能功能,例如自动代码拆分、Suspense 流式传输和图像优化。快速的应用程序不仅能满足用户需求,还能对 SEO 产生积极影响。
安全第一
原则:守卫你的城堡
安全性应该融入您的前端架构之中。防范跨站脚本 (XSS) 和跨站请求伪造 (CSRF) 等常见漏洞。密切关注安全更新和最佳实践,并将 Next.js 的内置安全功能视为额外的防御层。
国际化(i18n)和本地化(l10n)
原则:放眼全球
在我们这个互联互通的世界里,全球化思维至关重要。从一开始就实现国际化 (i18n) 和本地化 (l10n),以满足多样化的用户群体的需求。Next.js 对这些功能提供了出色的支持,让您更轻松地创建多语言应用程序。
这些指导原则构成了使用 Next.js 构建高效企业前端架构的基石。它们如同指南针,确保您的开发工作符合大型应用程序的需求,使其具备健壮性、可维护性和用户友好性。在接下来的章节中,我们将深入探讨如何将这些原则转化为切实可行的策略和最佳实践。
文件夹和文件结构
在 React 中,使用经过深思熟虑的文件夹结构来组织项目对于可维护性和可扩展性至关重要。一种常见的方法是根据文件的功能和用途来排列它们。以下是我在应用程序中通常使用的示例文件夹结构:
├─ src/
│ ├─ components/
│ │ ├─ ui/
│ │ │ ├─ Button/
│ │ │ ├─ Input/
│ │ │ ├─ ...
│ │ │ └─ index.tsx
│ │ ├─ shared/
│ │ │ ├─ Navbar/
│ │ └─ charts/
│ │ │ ├─ Bar/
│ ├─ modules/
│ │ ├─ HomePage/
│ │ ├─ ProductAddPage/
│ │ ├─ ProductPage/
│ │ ├─ ProductsPage/
│ │ │ ├─ api/
│ │ │ │ └─ useGetProducts/
│ │ │ ├─ components/
│ │ │ │ ├─ ProductItem/
│ │ │ │ ├─ ProductsStatistics/
│ │ │ │ └─ ...
│ │ │ ├─ utils/
│ │ │ │ └─ filterProductsByType/
│ │ │ └─ index.tsx
│ │ ├─ hooks/
│ │ ├─ consts/
│ │ └─ types/
│ │ └─ lib/
| | └─ styles/
│ │ │ ├─ global.css
│ │ └─ ...
│ ├─ public/
│ │ ├─ ...
│ │ └─ index.tsx
│ ├─ eslintrc.js
│ ├─ package.json
│ └─ tsconfig.json
└─ ...
-
src/components:此目录包含你的 UI 组件。它进一步细分
ui
为通用 UI 组件和shared
可能在应用程序不同部分复用的组件。 -
src/modules:此目录包含应用程序的各个模块或页面。每个模块可能都有自己的文件夹,其中包含用于 API 调用、组件和实用功能的子目录。
-
src/pages:如果您使用的是 Next.js,则此文件夹仅应用作应用程序的入口点。业务逻辑不应驻留在此处。pages 文件夹中的组件应仅渲染来自 modules 文件夹的页面。
-
src/modules/ProductsPage:此模块与产品相关,它包含 API 调用、组件(如
ProductItem
和ProductsStatistics
)和实用功能(filterProductsByType
)的子目录。 -
src/lib:此文件夹可能包含一些实用函数,这些函数稍后可以转换为跨多个应用程序使用的包。这与src/utils不同,后者可能包含一些实用函数,这些函数稍后转换为包没有任何意义。
-
src/styles:此目录包含全局样式(
global.css
)以及可能的其他样式相关文件。 -
src/public:此文件夹包含不经过构建过程的静态资产。它可能包括图像、字体和
index.html
文件。 -
src/consts、src/types:这些目录可能分别包含常量和 TypeScript 类型定义。
-
src/hooks:此目录可能包含整个应用程序中使用的自定义钩子。
-
eslintrc.js:这是 ESLint 的配置文件,ESLint 是一款流行的 JavaScript 代码检查工具。它用于强制执行编码规范并捕获代码中的潜在错误。
该tsconfig
文件的配置如下:例如,如果你想导入一个Button
组件,你可以这样做import { Button } from '@/components/ui'
。下面是如何配置它的片段tsconfig.json
。
{
...
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}
TypeScript 编码约定
我遵循的惯例受到了这份指南的启发。我强烈建议你阅读它,下面的代码片段也来自该指南。
所有类型都必须用类型别名定义
// ❌ Avoid interface definitions unless you need to extend or implement them
interface UserRole = 'admin' | 'guest'; // invalid - interface can't define (commonly used) type unions
interface UserInfo {
name: string;
role: 'admin' | 'guest';
}
// ✅ Use type definition
type UserRole = 'admin' | 'guest';
type UserInfo = {
name: string;
role: UserRole;
};
避免使用多个参数
// ❌ Avoid having multiple arguments
transformUserInput('client', false, 60, 120, null, true, 2000);
// ✅ Use options object as argument
transformUserInput({
method: 'client',
isValidated: false,
minLines: 60,
maxLines: 120,
defaultInput: null,
shouldLog: true,
timeout: 2000,
});
命名约定
尽管确定最佳名称可能具有挑战性,但请尝试通过遵守既定的约定来增强代码的可读性并保持一致性,以适应未来的开发人员:
变量
- 当地人骆驼案例
products
,productsFiltered
is
以has
等为前缀的布尔值isDisabled
,hasProduct
- 常量大写
PRODUCT_ID
- 对象常量
单数,以 const 断言大写,并可选择满足类型(如果有的话)。
const ORDER_STATUS = {
pending: 'pending',
fulfilled: 'fulfilled',
error: 'error',
} as const satisfies OrderStatus;
功能
驼峰式命名法filterProductsByType
,formatCurrency
泛型
名称以大写字母 T 开头TRequest
(TFooBar
类似.Net 内部实现)。
避免(流行的惯例)使用一个字符 来命名泛型T
,K
等等,我们引入的变量越多,就越容易混淆。
// ❌ Avoid naming generics with one character
const createPair = <T, K extends string>(first: T, second: K): [T, K] => {
return [first, second];
};
const pair = createPair(1, 'a');
// ✅ Name starts with the capital letter T
const createPair = <TFirst, TSecond extends string>(
first: TFirst,
second: TSecond
): [TFirst, TSecond] => {
return [first, second];
};
const pair = createPair(1, 'a');
包和工具。
在应用程序开发中,利用第三方工具来避免不必要的重复工作是一种常见的做法。以下是我在构建可扩展应用程序时使用的一些软件包。
React 查询/Tanstack 查询
React Query 在管理复杂企业应用程序中的数据获取和同步方面非常有效。它提供了一种统一的方法来从 API 中获取数据、进行缓存和处理变更。在企业环境中,应用程序通常需要与多个 API 和服务进行交互。React Query 可以通过集中数据管理和减少样板代码来简化此过程。
反应上下文
React Context 有助于管理跨组件的全局状态,无需进行 prop 钻取。这在企业级应用中尤其有用,因为共享状态(例如用户身份验证或偏好设置)需要在整个应用程序中访问。
我通常将使用 React Context 或其他状态管理工具作为最后的手段。建议尽量减少对全局状态的依赖。相反,应尽量使状态更接近具体需要的状态。
柏
Cypress 是一款出色的端到端 (E2E) 测试工具。在企业应用程序中,确保关键工作流和功能在不同的屏幕和组件上正常运行至关重要。Cypress 是迄今为止我最喜欢的工具。每当我的测试通过,它都让我确信我引入的代码没有破坏应用程序。随着企业应用程序的发展,进行回归测试以捕捉新代码更改的任何意外副作用至关重要。Cypress 通过自动化测试流程来促进这一点。
React 测试库:
React 测试库对于 React 组件的单元测试和集成测试至关重要。在企业级应用程序中,验证各个组件是否按预期工作对于应用程序的健壮性至关重要。React 测试库允许对每个组件进行单独以及与其他组件的联合测试。
NextAuth.js:
NextAuth.js 简化了 Next.js 应用程序中身份验证和授权的实现。在企业环境中,安全的用户管理至关重要。企业通常采用单点登录 (SSO) 解决方案来简化跨多个应用程序的用户身份验证。NextAuth.js 支持各种 SSO 提供程序,非常适合企业身份验证需求。NextAuth.js 还提供了实现自定义身份验证流程的灵活性。
我这里有一个博客,向您展示如何User
使用模块扩充在 TypeScript 中自定义 NextAuth.js 中的默认模型。
Turbo Repo
这也是我最喜欢的工具。Turbo Repo 是一款管理单一仓库的宝贵工具。在大型企业应用程序中,代码库可能非常庞大,包含各种模块、服务和共享代码。Turbo Repo 有助于高效地组织、版本控制和部署这些代码库。在企业环境中,跨团队和项目的代码共享很常见。Turbo Repo 支持高效的代码共享,使团队能够在共享库和组件上进行协作。
故事书
Storybook 允许开发者隔离 UI 组件,并在受控环境中展示它们。这使得演示单个组件的外观和行为变得轻而易举,无需浏览整个应用程序。在大型企业应用程序中,不同的开发者或团队可能负责 UI 的不同部分。Storybook 提供了一个集中式平台,用于展示和讨论 UI 组件,促进高效的协作并确保一致的设计语言。这是我使用 Storybook 开发并记录的示例组件库。(顺便说一下,它仍在开发中)
在企业环境中,这些工具共同提供了用于构建、测试和维护大型应用程序的综合工具包,解决了数据管理、状态处理、测试、身份验证和代码组织等关键方面。
可重用组件的编码风格
当我开发可重复使用的组件(如输入、对话框等)时,我会尝试遵循一些最佳实践。
让我们一起尝试一些开发组件的最佳实践Button
,您会发现它不仅仅是视觉设计。
组件可重用性
确保你的按钮组件设计为可在应用程序的不同部分重复使用。它应该足够灵活,以适应各种用例。
定制道具
提供常用自定义选项的 props,例如尺寸、颜色、变体(例如主按钮、次按钮)和禁用状态。这让开发者能够轻松调整按钮以适应不同的 UI 上下文。
无障碍注意事项
实现适当的无障碍功能,例如 aria-label、aria-disabled 和焦点管理。这可确保辅助技术用户能够有效地与按钮交互。
语义HTML
使用语义化的 HTML 元素(例如 )作为按钮组件。这可以增强可访问性和 SEO,并确保按钮组件在不同设备上的正常运行。
模仿原生按钮元素
我们遵循的所有这些最佳实践都迫使我们编写可预测的代码。如果您开发了一个自定义按钮组件,请使其工作和行为像按钮一样。您将从我们将要一起编写的示例组件中看到,我尝试通过扩展原生按钮元素来包含按钮可以接收的所有属性。
错误处理
如果按钮可能会导致错误状态(例如,提交表单),请提供一种方法来处理并将这些错误传达给用户。
测试
编写单元测试来验证按钮组件在不同场景下的行为是否符合预期。测试用例应涵盖各种 props 和事件处理程序。
文档
记录按钮组件的使用方法,包括可用的 props、事件处理程序以及任何特定的用例。提供示例和代码片段来指导开发人员。这正是 Storybook 的亮点所在。
跨浏览器兼容性:
在不同的浏览器中测试按钮组件,以确保一致的行为和外观。
版本控制和变更日志
如果按钮组件是共享库的一部分,请实施版本控制并维护变更日志,以便让开发人员了解更新和变更。
编码
对于我的组件,我通常有像这样的文件。Button.tsx
,,,。如果您使用 CSS,您可能会有类似的东西。Button.stories.tsx
Docs.mdx
Button.test.ts
Button.module.css
components/ui/Button.tsx
这是主要组件,cn
功能是合并类并处理冲突。它是tw-merge
库的包装器。
import React from 'react';
import {
forwardRef,
type ButtonHTMLAttributes,
type JSXElementConstructor,
type ReactElement,
} from 'react';
import { AiOutlineLoading3Quarters } from 'react-icons/ai';
import type { VariantProps } from 'cva';
import { cva } from 'cva';
import Link from 'next/link';
import { cn } from '@/lib';
const button = cva(
'flex w-max items-center border-[1.5px] gap-2 transition duration-200 ease-linear focus:outline-0 focus:ring ring-offset-1 dark:ring-offset-blue-dark',
{
variants: {
variant: {
outline: '...',
solid: '...',
naked: '...',
},
rounded: {
none: 'rounded-none',
sm: 'rounded',
md: 'rounded-lg',
lg: 'rounded-xl',
full: 'rounded-full',
},
color: {
primary: '...',
danger: '...',
info: '...',
warning: '...',
light: '...',
secondary: '...',
},
size: {
xs: '...',
sm: '...',
md: '...',
lg: '...',
},
disabled: {
true: '...',
},
active: {
true: '...',
},
loading: {
true: '...',
},
fullWidth: {
true: '...',
},
align: {
center: '...',
left: '...',
right: '...',
between: '...',
},
},
compoundVariants: [
{
variant: 'solid',
color: ['secondary', 'warning', 'danger', 'info'],
className: '...',
},
{
variant: 'solid',
color: 'primary',
className: '...',
},
{
variant: 'outline',
color: ['primary', 'secondary', 'warning', 'danger', 'info'],
className: '...',
},
{
variant: 'outline',
color: 'light',
className:
'...',
},
{
variant: 'naked',
color: ['primary', 'secondary', 'warning', 'danger', 'info'],
className:
'...',
},
{
disabled: true,
variant: ['solid', 'outline', 'naked'],
color: ['primary', 'secondary', 'warning', 'danger', 'info', 'light'],
className: '...',
},
{
variant: 'outline',
color: ['primary', 'secondary', 'warning', 'danger', 'info', 'light'],
className: '...',
},
{
variant: 'naked',
color: 'primary',
className: '...',
},
],
defaultVariants: {
size: 'md',
variant: 'solid',
color: 'primary',
rounded: 'lg',
align: 'center',
},
}
);
interface BaseProps
extends Omit<
ButtonHTMLAttributes<HTMLButtonElement>,
'color' | 'disabled' | 'active'
>,
VariantProps<typeof button> {
href?: string;
loadingText?: string;
target?: '_blank' | '_self' | '_parent' | '_top';
as?: 'button' | 'a' | JSXElementConstructor<any>;
}
export type ButtonProps = BaseProps &
(
| {
rightIcon?: ReactElement;
leftIcon?: never;
}
| {
rightIcon?: never;
leftIcon?: ReactElement;
}
);
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
(props, ref) => {
const {
as: Tag = 'button',
variant,
color,
rounded,
size,
target = '_self',
loading,
fullWidth,
align,
loadingText,
href,
active,
rightIcon,
leftIcon,
className,
disabled,
children,
...rest
} = props;
const classes = cn(
button({
variant,
color,
size,
disabled,
loading,
active,
rounded,
fullWidth,
align,
}),
className
);
return (
<>
{href ? (
<Link className={classes} href={href} target={target}>
{leftIcon}
{children}
{rightIcon}
</Link>
) : (
<Tag className={classes} disabled={disabled} ref={ref} {...rest}>
{loading ? (
<>
<AiOutlineLoading3Quarters className='animate-spin' />
{loadingText || 'Loading...'}
</>
) : (
<>
{leftIcon}
{children}
{rightIcon}
</>
)}
</Tag>
)}
</>
);
}
);
Button.displayName = 'Button';
components/ui/Button.stories.tsx
该文件包含故事书的按钮故事。
import { Meta, StoryObj } from '@storybook/react';
import React from 'react';
import { FaRegSmileWink, FaThumbsUp, FaYinYang } from 'react-icons/fa';
import { FiArrowUpRight } from 'react-icons/fi';
import { Button } from './Button';
export default {
title: 'Components/Button',
component: Button,
parameters: {},
args: {
children: 'Click me!',
},
argTypes: {
children: {
description: 'This is the text of the button, can be a node.',
control: { type: 'text' },
},
color: {
options: ['primary', 'danger', 'info', 'warning', 'secondary', 'light'],
control: { type: 'select' },
description: 'This controls the color scheme of the button',
table: {
defaultValue: { summary: 'primary' },
},
},
variant: {
options: ['solid', 'outline', 'naked'],
control: { type: 'select' },
description: 'This controls the variant of the button',
table: {
defaultValue: { summary: 'solid' },
},
},
size: {
options: ['sm', 'md', 'lg'],
control: { type: 'radio' },
description: 'This controls the size of the button',
table: {
defaultValue: { summary: 'md' },
},
},
loading: {
control: { type: 'boolean' },
description: 'This controls the loading state of the button',
table: {
defaultValue: { summary: false },
},
},
href: {
control: { type: 'text' },
description:
'If this is set, the button will be rendered as an anchor tag.',
},
className: {
control: { type: 'text' },
description: 'Classes to be applied to the button',
},
disabled: {
control: { type: 'boolean' },
description: 'If true, the button will be disabled',
table: {
defaultValue: { summary: false },
},
},
rightIcon: {
options: ['Smile', 'ThumbsUp', 'YinYang'],
mapping: {
Smile: <FaRegSmileWink />,
ThumbsUp: <FaThumbsUp />,
YinYang: <FaYinYang />,
},
description:
'If set, the icon will be rendered on the right side of the button',
},
leftIcon: {
options: ['Smile', 'ThumbsUp', 'YinYang'],
mapping: {
Smile: <FaRegSmileWink />,
ThumbsUp: <FaThumbsUp />,
YinYang: <FaYinYang />,
},
description:
'If set, the icon will be rendered on the left side of the button',
},
loadingText: {
control: { type: 'text' },
description:
'If set, the text will be rendered while the button is in the loading state',
},
target: {
control: { type: 'text' },
description:
'If set, the target will be rendered as an attribute on the anchor tag',
table: {
defaultValue: { summary: '_self' },
},
},
as: {
options: ['button', 'a'],
control: { type: 'select' },
description:
'If set, the button will be rendered as the specified element',
table: {
defaultValue: { summary: 'button' },
},
},
},
} as Meta<typeof Button>;
type Story = StoryObj<typeof Button>;
export const Primary: Story = {
args: {},
};
export const Secondary: Story = {
args: {
color: 'secondary',
},
};
export const Danger: Story = {
args: {
color: 'danger',
},
};
export const Warning: Story = {
args: {
color: 'warning',
},
};
export const Light: Story = {
args: {
color: 'light',
},
};
export const Info: Story = {
args: {
color: 'info',
},
};
export const Custom: Story = {
args: {
className: 'bg-[yellow] text-[black] border-[orange]',
style: { borderRadius: '3.5rem' },
},
};
export const WithRightIcon: Story = {
args: {
rightIcon: <FiArrowUpRight className='h-5 w-auto' />,
},
};
export const WithLeftIcon: Story = {
args: {
leftIcon: <FiArrowUpRight className='h-5 w-auto' />,
},
};
export const Disabled: Story = {
args: {
disabled: true,
},
};
export const OutlineVariant: Story = {
args: {
variant: 'outline',
color: 'danger',
},
};
export const NakedVariant: Story = {
args: {
variant: 'naked',
color: 'danger',
},
};
export const Loading: Story = {
args: {
loading: true,
},
};
export const CustomLoadingText: Story = {
args: {
loading: true,
loadingText: 'Processing...',
},
};
export const AsLink: Story = {
args: {
href: 'https://fin.africa',
children: 'Visit fin website',
rightIcon: <FiArrowUpRight className='h-5 w-auto' />,
},
};
export const FullWidth: Story = {
args: {
fullWidth: true,
children: 'Visit fin website',
rightIcon: <FiArrowUpRight className='h-5 w-auto' />,
},
};
components/ui/Docs.mdx
故事文件可以记录组件的工作方式,但 markdown 文件可以有更广泛的文档。
我用来开发组件的约定Button
与我尝试遵循的所有组件的约定相同。
关键要点
-
拥有某种设计系统,无论是开源解决方案还是您自己的设计系统。
-
让 TypeScript 成为你的好朋友。充分利用 TypeScript,用它来强制用户使用你的组件。我们的 Button 组件就是一个很好的例子。它有两个 props
leftIcon
和rightIcon
。我们使用 TypeScript 来确保只设置其中一个,否则会向开发人员报错。
export type ButtonProps = BaseProps &
(
| {
rightIcon?: ReactElement;
leftIcon?: never;
}
| {
rightIcon?: never;
leftIcon?: ReactElement;
}
);
-
记录你的代码和组件。使用 Storybook 之类的工具。
-
制定某种风格指南来确保你与你的团队使用相同的语言。
-
编写转储代码。保持代码库简洁明了、目标明确。每段代码都应该有一个明确的目的。
-
了解底层工作原理。在了解了 React 如何检查两个值是否相同后,我在这里发表了一篇文章。
结论
我们探讨了我使用的一些方法和工具。虽然我并没有涵盖所有可用的工具,但我建议你根据自己的具体需求选择合适的工具。建议你坚持使用自己擅长的技术,而不是仅仅为了追求新奇而采用。
归根结底,客户最关心的是最终产品,而不是你使用的具体技术。无论是 React、Vue 还是其他工具,都应优先使用能够快速部署的工具和工作流程,以造福用户。