阅读 13 分钟

合理区分前端组件类型

这篇文档以 React 为主,但对于其他框架同理。

现代前端项目整个应用程序几乎都是由组件组成的。比如,有用来做页面布局用的共享组件,有用来显示数据卡片的通用组件,也有用于约定式路由(也可以叫做文件路由)的页面组件。这些组件会有不同的用途,写法也不尽相同。大体上可以将这些组件分为三种类型,每种类型的组件都有特定的用途并遵循不同的规则。

页面组件 (Page Component)

页面组件通常与路由挂钩。不同的页面路由大多显示不同页面内容,页面组件则是用来承载这些页面内容的。现在的前端主流路由体系有两套,即编程式路由约定式路由。对于不同的路由体系,对于页面组件的命名规范会有所不同。

对于约定式路由来说,页面组件通常存放于前端框架要求的目录下。比如对于 Next.js 的 Pages Router 来说,页面组件存放于 pages 目录下(pages/about.tsx);但是对于 Next.js 的 App Router 来说,则是目录下固定名字的文件(pages/about/page.tsx)。这种情况下,这些页面组件通常使用 kebab-case 命名规范来命名组件文件(user-profile.tsxhome.tsx),毕竟文件名会直接作为网页链接的一部分,也因此组件文件名末尾不需要 -page 后缀。而实际的组件命名,通常使用 PascalCase 命名规范,并且组件名末尾要求带有 Page 后缀(function UserProfilePage(props) {})。

而对于编程式路由,页面组件通常就存放于 pages 目录下,并且不管是文件名还是组件名,都使用 PascalCase 命名规范,并且名字末尾都应该带上 Page 后缀。如果是多层级的路由,层级文件夹也应该使用 PascalCase 命名规范,并且页面组件的文件名和组件名都应该包含层级文件夹的名字作为前缀(pages/Auth/AuthLoginPage.tsx)。

之前提到过,页面组件是用来承载路由对应的网页内容的,它相当于一个盒子,但只提供包装功能,不会有网页内容的功能实现。页面组件基本上只做有限的几个事情:

提取和验证路由参数。网页 URL 允许携带参数,需要哪些参数,以什么形式携带参数,这些是路由工具库来定义的。但是市面上的路由工具库除了 TanStack Router 之外,普遍缺乏对参数的类型安全检查。

实现用户角色授权检查。不同的页面可能需要不同的用户角色才能访问,页面组件可以用来实现这些授权检查逻辑。

路由结构和功能模块解耦。对于功能模块来说,只需要要求哪些参数即可,不需要再关注参数是怎么来的。简单页面的功能(比如说“关于我”页面)可以直接在页面组件中实现,但不推荐这样做,没有必要。

快速定位页面代码。不再需要每次都从 routes 定义里去找某个页面对应的组件是哪一个。

// src/pages/Order/OrderOrderDetailPage.tsx

import { useParams } from "react-router";

export default function OrderOrderDetailPage() {
  // 简单例子,实际生产开发的时候,可以封装 Hooks 来做这些事情
  
  // 路由参数类型检查
  const params = useParams<{ orderId: string }>();
  const orderId = Number(params.orderId);
  if (isNaN(orderId) || String(orderId).length !== 19) {
    return <div>Invalid Order ID</div>;
  }
  // 如果使用 TanStack Router 这样的类型安全的路由库,
  // 通常这里会直接提供类型安全的已定义好类型的 Hooks
  const { orderId } = OrderOrderDetailPageRoute.useSearch() // <- orderId: number, length=19

  // 用户角色权限检查
  const user = useAtomValue(UserAtom);
  if (!['admin', 'sale', 'finance'].includes(user.role)) {
    return <div>Access denied</div>;
  }

  return (
    <div className="order-detail-page">
      <!-- 这是一个视图组件,会在下文介绍 -->
      <OrderDetailView orderId={orderId} />
    </div>
  )
}

这里以 React Router 7 为例

视图组件 (View Component)

视图组件用来组织构建整个网页的功能布局。以力扣题库页面为例,我们可以自然地将页面以树形结构分层分割为以下的模块:

AppVieww
├── HeaderView
└── ContentView
    └── ProblemSetView
        ├── ProblemSetNavigationView
        ├── ProblemSetMainView
        │   ├── ProblemSetMainAdView
        │   └── ProblemSetMainListView
        ├── ProblemSetDailyView
        └── ProblemSetPopularView

功能布局树形结构,类似 DOM 结构

2026 年 4月 · 力扣题库页面(未登录)

这样做的主要目的是让组件树结构和 UI 视觉层级尽量保持一致,并且让单个组件本身不会变得太大,代码太多。理论上我们可以把整个页面的功能都直接实现在页面组件里,但是这样会导致页面组件文件变得又臭又长。通过将页面组件拆分成多个视图组件,我们可以让每个组件的职责更加单一,代码更加清晰易读。因此,在符合 UI 视觉层级的前提下,分割视图组件的颗粒度,取决于父组件是否膨胀得太大、过于复杂了。如果一个视图组件的代码足够简洁清晰(比如将逻辑封装到 Hooks 里),即便是这个视图组件实现了多个功能模块,也没有必要继续分割下去。

通常情况下,大部分的视图组件都只会在整个 DOM 里使用一次。如果你希望在两个不同的地方,显示完全一致的界面,提供完全一致的功能,那么也可以在多个地方使用同一个视图组件,这种情况一般是在实现一些全局功能的时候使用,比如说全局提示框、全局通知之类的。

另一个重点是,视图组件通常不应该定义大量的 Props,这是一个经典问题,组件之间的状态传递、更新,逐层手动传递属性、逐层处理事件回调过于繁琐,使用 Context 又相对麻烦,因此,全局状态库便是一个非常好的选择。不过,视图组件还是可以传递一些简单的参数,比如说 orderId: number,或者 `userId: number`,这些参数通常被传递给 TanStack Query 这样的工具库用来获取数据。

总的来说,虽然这些视图组件实际上被分割为多个文件,但是逻辑上你可以把这些视图组件和它们最顶层的页面组件当成是整体的一个组件,只不过是原本 `useState` (本地状态) 被换成了 useAtom (全局状态,但是被人为规则限制在模块内使用)而已。

// 正确:使用全局状态而非 props 的视图组件
export const UserProfileView: React.FC = () => {
  const [user, setUser] = useAtom(userAtom); // ✓ 在视图组件中允许使用
  const [settings, setSettings] = useAtom(settingsAtom);

  // 复杂业务逻辑
  // 使用多个通用组件
};

// ❌ 避免:在视图组件之间传递太多 props
interface UserProfileViewProps {
  user: User;
  settings: Settings;
  onUserChange: (user: User) => void;
  onSettingsChange: (settings: Settings) => void;
  // ... 更多 props
}

export const UserProfileView: React.FC<UserProfileViewProps> = ({ ... }) => {
  // ❌ 这会造成不必要的 prop 层层传递
  // ✅ 视图间数据共享应使用 Jotai
};

最后,对于视图组件的命名,和通用组件一样,使用 PascalCase 命名规范,并且在文件名末尾添加 View 后缀(UserProfileView.tsxDashboardView.tsx)。同样地,和通用组件一样,视图组件还需要在文件名上添加当前的模块名作为前缀(ProblemSetMainListView)。

通用组件 (Universal Component)

通用组件强调受控可复用无副作用

在 React 上的 HTML 原生组件和一系列组件库,通常都同时支持受控模式和非受控模式。由于这些组件都是为了能支持不同业务项目的需求,不同的项目可能有不同的要求,因此它们被设计为支持非受控模式不可厚非。但是本篇文章介绍的组件类型,是基于具体的业务项目来考虑的,这算是一个带偏好的硬性规定,如无特殊用途和目的,请将通用组件实现为受控模式,这样数据流向清晰可预测,便于调试和测试。

可复用表示通用组件可以在应用中被多次使用,并且根据传入不同的参数数据,可以达成期望中的不同的目的。比如说我们设计一个用于显示成员信息的卡片,用来显示多个成员的个人信息。

无副作用要求通用组件:一、不能根据来自外部的状态(组件输入参数 Props 不算作外部状态)来决定如何渲染 UI;二、也不能因为用户的操作而直接修改来自外部的状态或输入参数,而是必须通过组件事件的方式通知外部某个状态应该改变。通用组件不应当知道"谁在使用它",也不应当关心"它在什么业务场景下被使用"。一个组件如果在两个场景下需要不同的行为,应该通过 Props 来配置差异,而不是在组件内部做场景判断。

// ✅ 受控组件
export type DatePickerProps = {
  value: Date;
  onChange: (date: Date) => void;
}
export const DatePicker: React.FC<DatePickerProps> = (props) => {
  // 组件逻辑
};

// ‼️ 错误的做法:使用全局状态的非受控组件
export const DatePicker: React.FC<DatePickerProps> = (props) => {
  const [date, setDate] = useAtom(globalDateAtom); // ❌ 避免这样做
  // 组件逻辑

  const onConfirmButtonClick = (date: Date) => {
    setDate(date); // ❌ 避免这样做
    value = date; // ❌ 避免这样做
  }
};

总体上来说,通用组件可以看作是一个可以拥有内部私有状态的纯函数。但是,在组件内部定义私有状态,一般是用于实现组件功能和行为的辅助状态,例如日期区间选择器,可能会处于“已经选择了开始时间还需要选择结束时间”的中间状态。


export type DateRangePickerProps = {
  value: [Date, Date] | null;
  onChange: (date: [Date, Date]) => void;
}

export const DatePicker: React.FC<DatePickerProps> = (props) => {

  // ✅ 内部私有状态,辅助中间子状态
  const [startDate, setStartDate] = useState<Date | null>(null);
  const [endDate, setendDate] = useState<Date | null>(null);
  
  const onConfirmButtonClick = () => {
    if (!startDate | !endDate) return;
    props.onChange([startDate, endDate]);
  }
};

对于通用组件的命名,无论是全局还是模块级别,始终使用 PascalCase 命名规范来命名组件文件和组件本身,(DatePicker.tsx、MemberCard.tsx)。由于通用组件与路由无关,不需要像页面组件那样区分文件名和组件名的命名差异。但根据存放位置,模块特定的通用组件名应当带有模块名作为前缀,以避免不同模块之间产生命名冲突(src/modules/Order/components/OrderStatusBadge.tsx)。出于方便和简洁的考虑,通用组件不需要在文件名末尾添加上 Component 的后缀。

简单总结

页面组件 (Page Components)

用途:定义应用程序的页面路由结构。

位置:存放在 pages 目录中。

命名规范:

  • 【约定式路由】使用 kebab-case(例如:user-profile.tsxhome.tsx
  • 【编程式路由】使用 PascalCase (例如: AuthLoginPage.tsx

特点:

  • 主要处理路由相关功能
  • 实现身份验证/授权检查
  • 提取和验证路由参数
  • 简单页面可以直接在页面组件中实现,但不推荐这样做
  • 应将复杂的 UI 逻辑委托给视图组件

使用场景示例:

  • 路由守卫和身份验证
  • URL 参数解析和验证
  • 页面级数据获取初始化
  • 简单的路由包装器

视图组件 (View Components)

用途:组合应用程序的页面并实现业务特定的 UI 逻辑。

位置:

  • parts/<Module>/views 用于存放模块特定的视图组件

命名规范:使用 PascalCase,通常以 View 结尾(例如:UserProfileView.tsxDashboardView.tsx)。

特点:

  • 可以有内部状态
  • 可以依赖外部状态管理(例如 Jotai)
  • 与项目特定的业务逻辑紧密耦合
  • 通常代表应用程序的主要部分或完整页面

使用时机:

  • 项目中大多数组件将是视图组件
  • 这些组件与项目的特定需求紧密相关
  • 当需要集成多个通用组件并管理它们的状态时
  • 当实现需要全局状态的复杂业务逻辑时

关于 Props 的重要说明:

  • 视图组件通常不需要大量的 props 定义
  • 视图组件之间的数据共享应通过 Jotai(全局状态管理)处理
  • 使用共享的 atoms 进行视图间通信,而不是通过 props 传递数据
  • 这简化了组件 API 并使数据流更加灵活

通用组件 (Universal Components)

用途:作为纯粹的受控组件的可复用组件。

位置:

  • src/components 用于存放全局通用组件
  • parts/<Module>/components 用于存放模块特定的通用组件

命名规范:使用 PascalCase(例如:DatePicker.tsxButton.tsx)。

特点:

  • 可以有内部状态
  • 应实现为受控组件
  • 不应依赖外部状态管理(例如 Jotai)
  • 数据流完全由父组件控制
  • 确保数据状态一致性
  • 可以被视为有状态的纯函数组件

重要规则:

  • 始终实现为受控组件
  • 将所有数据流控制权交给父组件
  • 不要在通用组件中使用全局状态管理(Jotai)
  • 应该是可复用的,并且独立于特定业务逻辑