首页 / React 学习笔记 / 27

REACT · Vol.IV · LESSON 27 · 组件设计与工程化

组件拆分与职责边界(对比三层架构)

实战核心#架构#工程化

一个 300 行的「上帝组件」

上一篇(第 26 篇)用 HOC 和 render props 解决了「横切逻辑怎么复用」,但还有一个更致命的坏味道没治:一个组件从请求、业务到渲染什么都干——前端的「上帝组件」。你几乎一定见过它:

UserPage.tsx · 一个真实的「上帝组件」(节选,全文件约 300 行)
import { useState, useEffect } from "react";

export default function UserPage() {
  // ── 第 1 坨:数据请求 ──────────────────────
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);
  const [keyword, setKeyword] = useState("");

  useEffect(() => {
    setLoading(true);
    fetch(`/api/users?keyword=${keyword}`)
      .then(res => res.json())
      .then(data => {
        // ── 第 2 坨:业务规则 ──────────────────
        const adults = data.filter(u => u.age >= 18);
        adults.sort((a, b) => b.createdAt - a.createdAt);
        setUsers(adults);
      })
      .catch(e => setError(e.message))
      .finally(() => setLoading(false));
  }, [keyword]);

  // ── 中间还有 100 多行:选中态、删除、批量操作、防抖……

  // ── 最后 150 行:内联样式、布局、渲染 ────
  return (
    <div className="user-page">
      <input value={keyword} onChange={e => setKeyword(e.target.value)} />
      {loading ? (
        <p>加载中…</p>
      ) : (
        users.map(u => (
          <div key={u.id}>
            <span>{u.name}</span>
            <span>{u.status === "ACTIVE" ? "在职" : "离职"}</span>
          </div>
        ))
      )}
    </div>
  );
}
后端类比:Controller 里写 SQL

这个组件等于一个 800 行的 Controller:方法里开 JDBC 连接、写 SQL、做业务校验,最后手工拼 HTML 返回。code review 里看到你会直接打回——翻译成 React,很多人却觉得「前端都这样」。不是的,前端同样需要分层。

上帝组件的问题不只是「长」,而是三种变化节奏完全不同的东西被焊死在一起:接口字段(高频)、业务规则(中频)、设计稿(最高频)——谁变,你都得打开这同一个 300 行的文件。「改一处、崩三处」就是这么来的。

前端的三层架构:展示、容器、逻辑 Hook

治法你在后端天天用:按职责分层。React 生态里对应的分法是展示组件、容器组件、逻辑 Hook 三层:

层(前端形态)只负责什么Java 类比
展示组件
UserList、Avatar
拿到 props 就画出界面,交互通过回调上报;不发请求、不写业务规则View / Thymeleaf 模板 / DTO 渲染
容器组件
UserListPage
接线:调 Hook 拿数据、持有页面级状态、组装 UI;不写规则不写样式Controller
逻辑 Hook
useUsers
封装业务规则与数据访问,返回「数据 + 操作」;不 import 组件、不碰 DOMService + Mapper

判断一个函数属于哪层,看输入输出就够了:展示组件是「props 进、UI 出」的纯函数;逻辑 Hook 是「参数进、数据出」的函数;容器组件只做搬运和编排。展示组件不知道数据来自 REST 还是 mock,就像 Thymeleaf 模板不知道数据来自 Oracle 还是 MySQL。

React 项目 Spring Boot 容器组件 UserListPage Controller 逻辑 Hook useUsers Service 展示组件 UserList View / DTO 渲染 左:React 项目的三件套 · 右:你熟悉的 Spring 三层
图 1React 三层与 Spring 三层的职责对应:把熟悉的架构直觉平移到组件里

动手重构:一拆三

拆法直接给结论:把 UserPage 拆成 useUsers(逻辑 Hook)+ UserList(展示组件)+ UserListPage(容器组件),各回各家。

第一块:useUsers —— 业务规则与数据访问(Service)

useUsers.ts · 逻辑层:只关心「用户数据」这条业务线,不认识任何组件
import { useEffect, useState } from "react";
import { fetchUsers } from "../api/user";
import type { User } from "./types";

export function useUsers(keyword: string) {
  const [users, setUsers] = useState<User[]>([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    const controller = new AbortController();
    setLoading(true);

    fetchUsers(keyword, controller.signal)
      .then(data => {
        // 业务规则:只看已成年用户,按创建时间倒序(改规则只改这里)
        const visible = data
          .filter(u => u.age >= 18)
          .sort((a, b) => b.createdAt - a.createdAt);
        setUsers(visible);
        setError(null);
      })
      .catch((e: Error) => {
        if (e.name !== "AbortError") setError(e.message);
      })
      .finally(() => setLoading(false));

    // 清理函数:卸载或 keyword 变化时取消上一次请求(第 9 篇讲过)
    return () => controller.abort();
  }, [keyword]);

  return { users, loading, error };
}

这就是第 13 篇「自定义 Hook」的标准形态:use 开头的函数,内部用 useState/useEffect,对外返回数据与操作。它和 UI 零耦合——可以像测 Service 一样单测它,换三套 UI 都不用动一行。

第二块:UserList —— 纯展示(View)

UserList.tsx · 展示层:props 进、界面出;不发请求、不写规则
import type { User } from "./types";

interface UserListProps {
  users: User[];
  loading: boolean;
  selectedId: number | null;
  onSelect: (id: number) => void;
}

export function UserList({ users, loading, selectedId, onSelect }: UserListProps) {
  if (loading) return <p>加载中…</p>;
  if (users.length === 0) return <p>没有符合条件的用户</p>;

  return (
    <ul className="user-list">
      {users.map(u => (
        <li
          key={u.id}
          className={u.id === selectedId ? "active" : ""}
          onClick={() => onSelect(u.id)}
        >
          <span>{u.name}</span>
          <span>{u.status === "ACTIVE" ? "在职" : "离职"}</span>
        </li>
      ))}
    </ul>
  );
}

它的 import 里没有任何 fetch、api、业务字眼。换数据源(mock、GraphQL、另一个后端)它一字不改;单测只需「造 props → 断言渲染结果」,和用 DTO 测模板渲染一个套路。

第三块:UserListPage —— 接线(Controller)

UserListPage.tsx · 容器层:不生产数据,只负责编排和传递
import { useState } from "react";
import { useUsers } from "./useUsers";
import { UserList } from "./UserList";
import { SearchInput } from "../../components/ui";

export default function UserListPage() {
  const [keyword, setKeyword] = useState("");
  const [selectedId, setSelectedId] = useState<number | null>(null);

  // 接线 1:页面状态 keyword 驱动 Hook 重新取数
  const { users, loading, error } = useUsers(keyword);

  if (error) return <p role="alert">加载失败:{error}</p>;

  return (
    <div className="user-page">
      <SearchInput value={keyword} onChange={setKeyword} placeholder="搜索用户" />
      // 接线 2:Hook 的数据 + 页面状态,通过 props 交给展示组件
      <UserList
        users={users}
        loading={loading}
        selectedId={selectedId}
        onSelect={setSelectedId}
      />
    </div>
  );
}

拆完每个文件都能一眼读完,三条变化曲线解耦:接口重构只动 useUsers,设计改版只动 UserList,页面交互只动 UserListPage。将来用上第 33 篇的 TanStack Query,useUsers 还能再瘦一圈——三层职责不变。

核心要点
  • 展示组件:props 进 UI 出,无副作用,像 View,最好测也最可复用。
  • 容器组件:调 Hook、持有页面级状态、组装 UI,像 Controller,应该薄。
  • 逻辑 Hook:业务规则 + 数据访问,像 Service,可脱离 UI 独立测试与复用。

判别清单:这段代码该放哪一层

分层最难的不是「拆」,是「判断」。给你两个问题,按顺序问,答案自然浮现:

  1. 换 UI 会变吗?设计稿改版、出移动端——要重写吗?要,它属于展示层(样式、布局、文案格式化、交互反馈)。
  2. 换数据源会变吗?REST 换 GraphQL、mock 换真接口、加缓存——要重写吗?要,它属于逻辑 Hook(请求、缓存、轮询、数据整形)。
  3. 两个都答「不会」——要么是容器组件的编排逻辑,要么沉成共享层的通用工具。
这段逻辑该放哪两个问题怎么答
金额分转元、保留两位小数src/lib 工具函数换 UI 不变;换数据源也不变——通用规则
「只看已成年用户,按时间倒序」useUsers(Hook)换 UI 不变;换数据源要变——规则贴着数据走
列表隔行变色、空态插画UserList(展示)换 UI 必变;换数据源不变——纯展示
「先搜用户,再拿他的订单」容器组件(编排)两头都不沾,纯粹是「接线的先后顺序」

再往上抽一层:UI 组件库思维

第 1 问里「换 UI 不变」的通用积木——Button、Input、Modal、Table——值得单独一层(src/components/ui)。它们比展示组件更纯:连业务词汇都没有,只认 variant、size 这类设计语言。类比公司的 common 包 / 公共 jar:一次打磨、处处复用,里面不该出现 if (userType === "VIP")。攒着攒着,你就长出了自己的「设计系统」——第 25 篇的 children 与复合组件,正是把这些积木拼好用的粘合剂。

Button.tsx · 设计系统的最小单元:只懂「按钮」,不懂业务
import type { ReactNode } from "react";

interface ButtonProps {
  variant?: "primary" | "ghost" | "danger";   // 只认设计语言,不认业务
  onClick?: () => void;
  children: ReactNode;
}

export function Button({ variant = "primary", children, onClick }: ButtonProps) {
  return <button className={`btn btn--${variant}`} onClick={onClick}>{children}</button>;
}
// 这套 interface 写法,下一篇第 28 篇细讲

别教条:三层是拐杖,不是镣铐

最后泼一盆冷水:不是每个组件都值得拆三层。一个 40 行的标签选择器,硬拆成 useTags + TagList + TagPage,是自找麻烦——文件数翻三倍,读代码的人跳来跳去,困惑比清晰多。这和第 20 篇讲状态管理选型是同一个精神:架构是演进出来的,不是开局颁发的。

TagBadge.tsx · 40 行的小组件:一气呵成就好,别硬拆三层
import { useState } from "react";

export function TagBadge({ initialTags }: { initialTags: string[] }) {
  const [tags, setTags] = useState(initialTags);

  function remove(tag: string) {
    setTags(tags.filter(t => t !== tag));
  }

  return (
    <span className="tag-badge">
      {tags.map(t => (
        <em key={t}>
          {t}
          <button onClick={() => remove(t)}>×</button>
        </em>
      ))}
    </span>
  );
}

什么时候才值得动手拆?出现这些信号再说:

  • 同一份 UI 要出现在第二个地方——复用需求真实出现了;
  • 想给业务规则写测试,却发现它焊死在 JSX 里;
  • 后端改一个字段名,你要在一堆样式代码里大海捞针;
  • 「改样式的人」和「改逻辑的人」开始频繁冲突同一个文件。

另外,真实项目未必「一个页面恰好三层」:有的容器薄到只剩一行,直接在路由文件里调 Hook 渲染展示组件;有的 Hook 就是第 33 篇的一行 useQuery分层是职责的划分,不是文件数的指标。

本章回顾

我们把一个 300 行的上帝组件拆成了三层:useUsers(逻辑 Hook,≈ Service)、UserList(展示组件,≈ View)、UserListPage(容器组件,≈ Controller)。归属判断只记两问:换 UI 会变吗?换数据源会变吗?但别教条——架构服务于变化,拆分跟着复用与测试的真实需求走。

三层拆完,新问题来了:层与层之间靠什么连接?靠 Props。而 Props 的形状、哪些可选、回调参数是什么类型,靠什么保证不出错?下一篇第 28 篇,我们把 TypeScript 请进来,让「契约」从口头约定变成编译期红线。

Comments · 评论