在快节奏的前端开发领域,牢牢掌握技术发展前沿对搭建成功的企业级应用至关重要。在使用 Next.js 及其强大的技术栈(包括 Tailwind CSS、TypeScript、TurboRepo、ESLint、React Query 等)长达四年后,我已经积累了许多宝贵的见解和最佳实践,希望与其他开发者分享。本文将探讨如何为大规模企业构建和组织前端应用,以达到性能、可维护性和可扩展性最大化。
注意:本文表达个人观点,我提倡的方法可能不适用于您的具体情况。
在为企业级应用构建前端解决方案时,有一个明确定义的原则集可以作为指导你的发展方向的罗盘。在此节中,我会分享在企业环境中使用 Next.js 所积累的原则。
原则:分而治之
在庞大的企业级应用领域,代码可以迅速变成一头难以驯服的野兽。拥抱模块化和组件化,将你的前端拆分成可管理的部件。把组件想象为乐高积木,每个都服务于特定目的。这不仅增强了代码的可重用性,也简化了你的开发团队内部的维护和协作。不要只考虑将应用分割成更小的组件,也要考虑将其拆分成更小的独立应用。这是 Turbo Repo 等工具大显身手的地方。
原则:保持代码整洁
为了维持代码的可理解性,遵循关注点分离(SoC)原则。确保你的组件专注于各自的责任,无论是渲染 UI、处理业务逻辑还是管理状态。这种隔离不仅使代码更易于理解,还有利于测试和调试。
原则:规划未来增长
企业应用不是静态的,它们在演进。在设计前端架构时就考虑到可扩展性。这意味着选择能适应流量、数据量和功能复杂性增长的模式和工具。Next.js 的面向可扩展性的设计可以成为这项努力中的宝贵帮手。
原则:精心编写
代码是你产品的基石。从第一天就优先考虑可维护性和代码质量。实施编码标准,进行代码审查,并投资于自动化测试。一个维护良好的代码库不仅更易于使用,也更少 Bug 和回归。我最近在工作中开发了一个组件库和一个基本的风格指南来规范我们的前端应用。请不要介意文档,它们还未完成 😂。
原则:从一开始就行动
可访问性是现代 Web 开发的必需品。从一开始就将其作为默认实践。确保你的应用可被所有人使用,无论是否残疾。利用 Next.js 对可访问性标准和工具的支持来创建包容的用户体验。我使用像 Radix UI 这样的工具来构建一些需要可访问性的组件,如标签页、下拉菜单等。
原则:速度至关重要
企业用户期待快速的体验。在每一个决定点都优先考虑性能。优化资源,最小化不必要的请求,并利用 Next.js 的性能特性,如自动代码拆分、suspense 流加载和图像优化。一个快速的应用不仅取悦用户,还对 SEO 有积极影响。
原则:守卫你的城堡
安全应该贯穿你的前端架构的方方面面。防范常见的漏洞,如跨站脚本(XSS)和跨站请求伪造(CSRF)。保持警惕,采用安全更新和最佳实践,并考虑 Next.js 内置的安全特性作为额外的防线。
原则:全球思考
在这个互联的世界,全球化思维至关重要。从一开始就实施国际化(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
└─ ...
ui
来存放通用 UI 组件,和 shared
来存放在应用不同部分可能被重用的组件。
ProductItem
和 ProductsStatistics
)和工具函数(filterProductsByType
)的子目录。
global.css
)和其他与样式相关的文件。
index.html
文件。
tsconfig
文件配置后,如果你想导入一个 Button
组件,可以这样导入:import { Button } from '@/components/ui'
。下面是 tsconfig.json
中的配置片段。
{
...
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}
我遵循的约定受此指南的启发。我强烈推荐你阅读它,下面的代码片段也出自该指南。
// ❌ 除非需要扩展或实现,避免接口定义
interface UserRole = 'admin' | 'guest';
interface UserInfo {
name: string;
role: 'admin' | 'guest';
}
// ✅ 使用类型定义
type UserRole = 'admin' | 'guest';
type UserInfo = {
name: string;
role: UserRole;
};
// ❌ 避免有多个参数
transformUserInput('client', false, 60, 120, null, true, 2000);
// ✅ 使用选项对象作为参数
transformUserInput({
method: 'client',
isValidated: false,
minLines: 60,
maxLines: 120,
defaultInput: null,
shouldLog: true,
timeout: 2000,
});
尽管确定最佳命名可能具有挑战性,但请尽量遵循确立的约定来增强代码可读性并为未来的开发者保持一致性:
products
, productsFiltered
is
, has
等 isDisabled
, hasProduct
PRODUCT_ID
const ORDER_STATUS = {
pending: 'pending',
fulfilled: 'fulfilled',
error: 'error',
} as const satisfies OrderStatus;
驼峰式: filterProductsByType
, formatCurrency
名称以大写字母 T 开头 TRequest
, TFooBar
(类似 .Net 内部实现)。
避免(常见约定)用一个字符命名泛型 T
,K
等,我们引入的变量越多,就越容易混淆。
// ❌ 用一个字符命名泛型
const createPair = <T, K extends string>(first: T, second: K): [T, K] => {
return [first, second];
};
const pair = createPair(1, 'a');
// ✅ 以大写字母 T 开头
const createPair = <TFirst, TSecond extends string>(
first: TFirst,
second: TSecond
): [TFirst, TSecond] => {
return [first, second];
};
const pair = createPair(1, 'a');
在应用开发中,利用第三方工具来避免不必要的重复工作是常见做法。下面是我在构建可扩展应用时使用的一些包。
React Query 在管理复杂企业应用中的数据获取和同步方面非常有益。它提供了从 API 获取数据、缓存和处理变更的统一方式。在企业环境下,应用通常需要与多个 API 和服务进行交互。React Query 可以通过集中化数据管理和减少样板代码来简化这个过程。
React Context 在通过各组件管理全局状态方面发挥重要作用,无需 prop drilling。这在共享状态(如用户认证或偏好设置)需要在整个应用中可访问的企业应用中特别有价值。
我通常只把 React Context 或其他状态管理工具作为最后手段。建议尽量减少对全局状态的依赖。而是将状态保存在更接近其所需的具体位置。
Cypress 是端到端(E2E)测试的优秀工具。在企业应用中,确保不同屏幕和组件上的关键流程和功能正常运行至关重要。Cypress 是迄今为止我最喜欢的工具。每当我的测试通过时,这能让我确信我引入的代码没有破坏应用。随着企业应用的发展,进行回归测试以捕获任何新代码变更的意外副作用至关重要。Cypress 通过自动化测试过程来实现这一点。
React Testing Library 是对 React 组件进行单元和集成测试的必备之物。在企业应用中,验证各个组件的预期工作方式对健壮的应用非常关键。React Testing Library 允许彻底测试每个组件的隔离情况,以及与其他组件的结合情况。
NextAuth.js 简化了在 Next.js 应用中实现认证和授权。在企业环境中,安全的用户管理势在必行。企业通常采用单点登录(SSO)解决方案,在多个应用中简化用户认证。NextAuth.js 支持各种 SSO 提供商,非常适合企业认证需求。NextAuth.js 还提供实现自定义认证流程的灵活性。
我在这篇博客中展示了如何使用 TypeScript 的模块扩展自定义 NextAuth.js 中的默认 User
模型。
这也是我最喜爱的工具。Turbo Repo 是管理 monorepo 的高价值工具。在大型企业应用中,代码库可以非常庞大,包含不同的模块、服务和共享代码。Turbo Repo 可以高效地组织、版本控制和部署这些代码库。在企业环境中,跨不同团队和项目的代码共享很常见。Turbo Repo 实现了有效的代码共享,允许团队在共享库和组件上进行协作。
Storybook 允许开发者隔离 UI 组件并在可控环境中展示它们。这使得演示单个组件的外观和行为变得很容易,而无需浏览整个应用。在大型企业应用中,不同的开发人员或团队可能负责 UI 的不同部分。Storybook 提供了展示和讨论 UI 组件的集中平台,促进高效协作并确保一致的设计语言。这里是一个我使用 Storybook 开发和文档化的示例组件库。(这还在开发中)
在企业环境下,这些工具共同提供了一个全面工具包,用于构建、测试和维护大规模应用,解决数据管理、状态处理、测试、认证和代码组织等关键方面。
在开发诸如输入框、对话框等可重用组件时,我尽量遵循一些最佳实践。
让我们一起尝试为 Button
组件开发一些最佳实践,你会发现这不仅仅是视觉设计。
确保你的按钮组件被设计成可以在应用不同部分重用。它应该足够灵活以适应不同的使用场景。
提供常见定制选项的属性,如大小、颜色、变体(例如主要、次要)和禁用状态。这使得开发者可以轻松地将按钮调整为不同的 UI 上下文。
正确的可访问性功能,如 aria-label、aria-disabled 和焦点管理,可以确保辅助技术的用户可以有效地与按钮进行交互。
为你的按钮组件使用语义化 HTML 元素(例如 <button>
)。这增强了可访问性和 SEO,并确保在不同设备上表现出正确的行为。
我们遵循的所有最佳实践都督促我们编写可预测的代码。如果你开发一个自定义按钮组件,请确保它的工作方式和行为像一个按钮。你会从我们一起编写的示例组件中看到,我试图通过扩展原生按钮元素来包含按钮可以接受的所有属性。
如果按钮可能导致错误状态(例如提交表单),请提供一种处理和向用户传达这些错误的方法。
编写单元测试以验证按钮组件在不同场景下的预期行为。测试用例应覆盖不同的属性和事件处理程序。
记录按钮组件的使用方式,包括可用属性、事件处理程序和任何特定使用场景。提供示例和代码片段以指导开发者。这是 Storybook 的强项。
在不同浏览器中测试按钮组件,以确保行为和外观的一致性。
如果按钮组件是共享库的一部分,请实施版本控制并维护变更日志,以让开发者了解更新和更改。
对于我的组件,我通常有这样的文件。Button.tsx
、Button.stories.tsx
、Docs.mdx
、Button.test.ts
。如果使用 CSS,可能有 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
这个文件包含 Storybook 中按钮的 stories。
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
stories 文件可以记录组件的工作方式,但 markdown 文件可以包含更广泛的文档。
我使用的开发 Button
组件的约定,也是我尝试在所有组件上遵循的约定。
leftIcon
和 rightIcon
。我们使用 TypeScript 确保只设置其中一个,否则会向开发者报错。
export type ButtonProps = BaseProps &
(
| {
rightIcon?: ReactElement,
leftIcon?: never,
}
| {
rightIcon?: never,
leftIcon?: ReactElement,
}
);
我们探讨了我使用的一些方法和工具。虽然我没有涵盖我所有工具,但我建议确定什么适合你的特定要求。最好坚持你熟练的技术,而不是仅因新颖而采用某项技术。
归根结底,客户最关心的是最终产品,而不是你使用的特定技术。无论是 React、Vue 还是其他工具,都要优先使用那些能够快速部署以造福用户的工具和工作流程。
参考来源:dev.to/josemukoriv…