首页 / 文章

在不会导致生产环境出现回退的情况下拆分过大的 React 组件

安全拆分大型 React 组件的逐步方法:明确职责划分、提取纯逻辑、负责状态管理,并分小步进行交付。

发布者:REACTAPP.TOP阅读约 17 分钟3880 词

来源: React Components Are Getting Too Big: How to Refactor Without Breaking Production

每个成熟的 React 代码库都有一个这样的组件:它功能完备、能带来收入,但任何需要修改它的人都会对其心生畏惧。最初它只是一个简单的界面,后来陆续增加了过滤、分页、API 调用、权限控制、验证功能、对话框、导出功能、分析功能以及越来越多的效果处理。本指南将介绍如何通过降低组件的复杂性而非仅仅压缩文件大小来缩小这类组件的规模,并且会以不会危及生产环境行为的步骤来进行说明。

一个典型的例子已经悄然涵盖了该功能的所有相关要素:

function ProductManagement() {
  // API calls

  // Redux selectors
  // Local state
  // Filters
  // Pagination
  // Sorting
  // Selection
  // Permissions
  // Validation
  // Business rules
  // Export
  // Modal handling
  // Analytics
  // Hundreds of lines of JSX
}

当代码行数达到千行甚至更多时,人们往往会认为问题出在长度上。其实并非如此。代码过长本身并不一定构成问题;真正成问题的是那些因诸多无关原因而需要修改的组件。下文所述的工作并非要将一个文件拆分成十个,而是为了降低后续修改的成本与风险。

行数并非正确的衡量标准

诸如“凡超过300行的代码都需重构”这样的规则虽然易于执行,却难以识别真正的风险。想象一下有500行、主要由声明式标记构成且逻辑很少的代码;再想象一下仅有200行,却包含了API调用、状态转换、权限检查、验证、业务规则以及嵌套条件渲染的代码。显然,行数较少的那个文件反而更难以安全地修改。

关键在于认知负荷。一个有用的问题是:团队成员需要多少上下文才能自信地修改这个组件?

复杂性带来负面影响的迹象

注意以下信号:

  • 多个无关的 useEffect 调用
  • 大量独立的状态变量
  • 直接从 UI 组件发起的 API 请求
  • 业务规则嵌藏在点击处理函数中
  • 深度嵌套的条件渲染
  • 权限逻辑与界面展示混杂在一起
  • 相同的数据转换在多个地方重复出现
  • 不同的职责模块负责读写同一状态
  • 通过中间组件传递的冗长属性链
  • 重复的网络请求
  • 为验证一条简单规则却必须加载整个页面的测试
  • 同一文件中频繁出现合并冲突
  • 最明显的症状是来自团队氛围而非技术问题:人们会避开该文件。一旦“别碰那个,总是出故障”成为团队的共识,这个组件就不再是风格问题,而是工程风险。

    在修改代码前明确职责

    第一步不是在编辑器中提取JSX,而是写下该组件实际的功能。对于产品管理界面,其功能结构可能如下所示:

    ProductManagement
    │
    ├── Fetch products
    ├── Transform API response
    ├── Filter products
    ├── Sort products
    ├── Manage selection
    ├── Validate permissions
    ├── Export data
    ├── Open/close dialogs
    ├── Handle errors
    └── Render UI
    

    从这份清单就能看出,该组件同时承担着多种角色。按功能类型划分,其职责可分为数据处理、行为逻辑和界面展示三部分:

                  ProductManagement
                         │
           ┌─────────────┼─────────────┐
           │             │             │
           ▼             ▼             ▼
         Data         Behavior      Presentation
           │             │             │
           ▼             ▼             ▼
         API       Filters/Rules      Table
         State     Selection          Dialog
                   Validation         Actions
    

    良好的重构应从这种分类开始。首先确定职责边界,之后再考虑文件结构。

    目标:明确责任划分的层次结构

    将一个大文件拆分为五个小文件会改变磁盘上的结构,但不会影响设计:

    BigComponent.tsx
          ↓
    5 smaller .tsx files
    

    真正的目标是实现分层结构,使界面、行为、领域逻辑和数据访问各有其固定位置:

    ProductPage
         │
         ├─────────────── UI
         │
         ├─────────────── Hooks / Behavior
         │
         ├─────────────── Domain Logic
         │
         └─────────────── Data Access
    

    在实际应用中,按功能划分文件夹是表达这一结构的便捷方式:

    features/products/
    │
    ├── components/
    │   ├── ProductFilters.tsx
    │   ├── ProductTable.tsx
    │   ├── ProductActions.tsx
    │   └── ProductDialog.tsx
    │
    ├── hooks/
    │   ├── useProducts.ts
    │   ├── useProductFilters.ts
    │   └── useProductSelection.ts
    │
    ├── services/
    │   └── productService.ts
    │
    ├── utils/
    │   ├── productMapper.ts
    │   └── productValidator.ts
    │
    └── types.ts
    

    文件夹名称的重要性不如每个模块所承担的功能:

    • ProductTable负责渲染表格。
    • useProductFilters包含过滤功能。
    • useProducts向React提供产品数据。
    • productService负责与后端通信。
    • productValidator存放纯粹的验证规则。

    理想的结构应是单向流程,即每一层仅依赖于其下方的那一层:

    UI
     ↓
    Behavior
     ↓
    Data Access
     ↓
    Backend
    

    而不是一个所有问题都相互交织的网页:

    UI
     ↕
    State
     ↕
    API
     ↕
    Business Logic
     ↕
    UI
    

    如需更深入地了解该问题中 prop-drilling 的情况,请参阅 削减 React 的 prop-drilling 问题及过度庞大的组件规模。

    第一步:将纯逻辑移出 React

    纯函数是风险最低的提取对象,因此适合作为起点。像这样的状态辅助函数通常定义在组件内部:

    const getProductStatus = (product: Product) => {
      if (!product.active) {
        return "Inactive";
      }
    
      if (product.stock <= 0) {
        return "Out of Stock";
      }
      return "Available";
    };
    

    其中没有任何内容依赖于 React:没有 useState,没有 useEffect,没有 Redux,也没有上下文。该函数没有理由保留在组件中,因此应将其移至独立的模块中:

    utils/productStatus.ts
    

    一旦被移到那里,就可以直接测试该规则,而无需进行任何渲染操作:

    describe("getProductStatus", () => {
      it("returns inactive for disabled products", () => {
        expect(
          getProductStatus({
            active: false,
            stock: 10
          })
        ).toBe("Inactive");
      });
    });
    

    这是在几乎无需承担风险的情况下取得的显著收益:业务规则现已与渲染逻辑解耦,测试失败时能直接定位到规则本身而非某个页面。其指导原则很简单:如果某些逻辑不需要 React,那么 React 就不应负责处理它们。

    第二步:将 React 行为提取到钩子中

    接下来是真正属于 React 的部分:协同工作的状态变量和效应函数。加载产品功能通常由三个状态变量和一个效应函数组成:

    const [products, setProducts] = useState([]);
    const [loading, setLoading] = useState(false);
    const [error, setError] = useState(null);
    
    useEffect(() => {
      setLoading(true);
      fetchProducts()
        .then(setProducts)
        .catch(setError)
        .finally(() => setLoading(false));
    }, []);
    

    将其原封不动地移入自定义钩子后,它就变成了一个具有简单返回值的独立单元:

    function useProducts() {
      const [products, setProducts] = useState([]);
      const [loading, setLoading] = useState(false);
      const [error, setError] = useState(null);
    
      useEffect(() => {
        setLoading(true);
        fetchProducts()
          .then(setProducts)
          .catch(setError)
          .finally(() => setLoading(false));
      }, []);
      return {
        products,
        loading,
        error
      };
    }
    

    随后页面只需一行代码即可使用它:

    function ProductManagement() {
      const {
        products,
        loading,
        error
      } = useProducts();
    
    // composition
    }
    

    该组件现在包含的实现细节更少,更像是对其界面构成要素的描述。刻意逐字复制代码是为了确保提取代码后行为不会改变。诸如最初将loading设置为true(以避免首次渲染时短暂显示为“已加载”状态)、在组件卸载后忽略响应,或是采用服务器端状态管理库之类的改进,都应留到后续的单独修改中处理。

    并非所有内容都应变成钩子

    这就是重构容易演变为过度设计的地方。有些代码库甚至会为每个小函数都创建一个钩子:

    useProductName()
    useProductStatus()
    useProductLabel()
    useProductColor()
    useProductFormatter()
    

    这并非架构上的改进。只有当自定义钩子能够封装React的各种行为——如状态、效应、上下文、其他钩子、订阅功能或生命周期相关逻辑时,它才有存在的价值。如果仅仅需要一个格式化工具的话,

    formatProductName(product)
    

    这时,直接使用函数调用就完全合适了:

    formatProductName(product)
    

    如果将其包装成钩子函数,

    useProductName(product)
    

    就会受到钩子规则的约束,无法在条件判断或循环中调用它,而且没有任何好处。抽象应当减轻代码库的认知负担,而非增加负担。

    确定每块状态的归属

    状态存储的位置是重构过程中最重要的决策之一。一种有用的分类方式是将服务器数据、全局UI状态和本地UI状态区分开来:

    Application State
    │
    ├── Server State
    │   ├── Products
    │   ├── Orders
    │   └── Customers
    │
    ├── Global UI State
    │   ├── Authentication
    │   ├── Theme
    │   └── Application Preferences
    │
    └── Local UI State
        ├── Modal
        ├── Selected Tab
        └── Temporary Form State
    

    两种极端做法都不合适:将所有数据都放入Redux会导致全局存储过于臃肿,而全部保留为本地状态则会造成重复和属性链问题。对于每块状态,需要回答以下问题:

    • 哪些组件会读取它?
    • 它的有效期是多久?
    • 它是属于服务器还是客户端?
    • 它在页面跳转后是否仍需存在?
  • 是否已经存在该数据的权威来源?
  • 一个可靠的准则:尽可能将状态保存在靠近其使用者的地方,但不得超出它们共同需求所允许的范围。

    避免创建“上帝父组件”

    拆分组件可能会引发新的问题。虽然子组件规模较小,但父组件现在却承载了所有状态并向下传递:

    <ProductPage
      products={products}
      filters={filters}
      setFilters={setFilters}
      selected={selected}
      setSelected={setSelected}
      permissions={permissions}
      loading={loading}
      error={error}
      onExport={handleExport}
      onDelete={handleDelete}
      onEdit={handleEdit}
    />
    

    代码文件虽然变短了,但耦合度依然相同;只是层级上升了而已。在试图通过上下文或全局存储来隐藏这些属性之前,先思考一下该状态是否真的需要共享,或者组件的边界划分是否正确。当多个子组件确实需要相同的值时,使用上下文是合适的;而当状态属于整个应用范围时,则应使用全局存储。这两种方式都不应用来掩盖与业务领域不匹配的组件边界。

    使用“绞杀者模式”逐步替换组件

    一次性重写整个组件是最具风险的做法,而逐部分替换则安全得多。最初,旧组件负责所有功能:

    BigComponent
    └── Everything
    

    随后,诸如过滤等功能会被移至独立的组件中,而其余功能保持不变:

    BigComponent
    ├── ProductFilters
    └── Everything else
    

    在下一轮迭代中再处理表格功能:

    BigComponent
    ├── ProductFilters
    ├── ProductTable
    └── Everything else
    

    最终,页面完全由新组件构成,旧实现则被彻底取代:

    ProductPage
    ├── ProductFilters
    ├── ProductTable
    ├── ProductActions
    └── ProductDialog
    

    这就是“绞杀者模式”,通常用于后端系统的迁移,现在也被应用于前端:新组件在旧实现周围逐步形成,每个新组件负责一项功能,同时应用程序的其余部分仍可正常运行。其优势在于无需让生产环境直接从

    100% Legacy
    

    跳到

    100% New
    

    在单次发布中,每个中间状态均可交付且可逆。

    保持公共契约的稳定性

    在规模较大的组织中,这样的组件通常由多个带有特定属性的界面来呈现:

    <ProductManagement
      clientId={clientId}
      onSelect={handleSelect}
    />
    

    在整个重构过程中保持该接口不变,其内部结构则可完全重新组织:

    Existing Public API
            │
            ▼
     ProductManagement
            │
       ┌────┼────┐
       ▼    ▼    ▼
     Hooks  UI  Services
    

    使用者无需知晓内部实现已迁移到钩子、UI组件和服务中,这样就能将每一步的影响范围控制在较小范围内。这种规范对于共享组件库、设计系统、微前端、企业级应用以及被多个团队使用的任何系统都至关重要。如果确实需要更改API,应通过独立的、明确的迁移操作来完成。

    拆分那些承担多重职责的逻辑

    大型组件通常包含执行多个互不相关操作的效果:

    useEffect(() => {
      fetchData();
    
      updateAnalytics();
      validatePermissions();
      calculateSomething();
    }, [dependency]);
    

    问题不在于语句的数量,而在于这些语句很可能代表了四项独立的职责,每项都有其自身的实际依赖关系。应将它们拆分,让每个效果明确说明它实际上响应什么:

    useEffect(() => {
      fetchData();
    }, [dependency]);
    
    useEffect(() => {
      updateAnalytics();
    }, [analyticsDependency]);
    

    在此过程中,还要查找那些仅用于计算值的效果。将过滤后的列表存储在状态中并通过效果进行同步,会带来额外的渲染开销,并可能导致数据过时:

    const [filteredProducts, setFilteredProducts] = useState([]);
    
    useEffect(() => {
      setFilteredProducts(
        products.filter(product => product.active)
      );
    }, [products]);
    

    在渲染过程中直接计算会更简单,且结果始终一致:

    const filteredProducts = products.filter(
      product => product.active
    );
    

    只有在有实际必要的情况下才进行缓存或同步,比如存在耗时的计算任务(此时应使用useMemo而非效果函数)。

    将服务器状态与UI状态分开

    在较大的界面中,区分这两类状态能很快带来好处:

                         ProductPage
                            │
                ┌───────────┴───────────┐
                │                       │
           Server State             UI State
                │                       │
          ┌─────┴─────┐          ┌──────┴──────┐
          │           │          │             │
       products    loading     filter        modal
       error       status      selected      tab
    

    产品列表与对话框是否打开属于完全不同类型的数据。产品由后端管理,而对话框则由浏览器控制。服务器数据需要缓存、重新获取、加载以及错误处理,而UI状态则是短暂且仅存在于本地的。将它们混同在同一个useState状态中会使得组件更难理解。关于使用专用工具处理此类问题的方法,可参阅React Query与Redux:重新思考大型应用中的服务器状态。

    重构工作流程,而非彻底重写

    实际的生产环境重构是一个循环过程:

    Identify
       ↓
    Understand
       ↓
    Protect
       ↓
    Extract
       ↓
    Test
       ↓
    Validate
       ↓
    Deploy
       ↓
    Measure
       ↓
    Repeat
    

    并非如此:

    Delete component
          ↓
    Rewrite everything
          ↓
    Hope production works
    

    正是这种差异决定了对现有系统进行重构与从零开始开发新项目之间的区别。

    最终的数据流呈现形式

    通过分离职责,用户交互会按照明确定义的层次结构进行传递并返回:

    User
     │
     ▼
    ProductPage
     │
     ▼
    useProductFilters
     │
     ▼
    useProducts
     │
     ▼
    ProductService
     │
     ▼
    API
     │
     ▼
    Backend
     │
     ▼
    ProductService
     │
     ▼
    useProducts
     │
     ▼
    ProductTable
     │
     ▼
    Updated UI
    

    简而言之,其原理就是一条直接的依赖关系链:

    Presentation
          ↓
    Behavior
          ↓
    Data Access
          ↓
    Backend
    

    表格不应了解认证机制的运作方式,API服务也不应知晓表格的渲染方式,业务规则更不必关心用户界面是使用模态框还是抽屉式设计。这就是具有明确目标的职责分离:任何变更都仅限于某一层内。

    注意避免重复的API请求

    这种退化问题相当常见,值得专门提出警告。在重构之前,有一个组件负责获取数据:

    ProductPage
         │
         └── fetchProducts()
    

    经过拆分后,页面及其子组件都会调用同一个数据获取函数:

    ProductPage
       │
       ├── useProducts()
       │
       └── ProductTable
              │
              └── useProducts()
    

    代码看起来更整洁了,但现在的简单 fetch-in-effect 钩子会执行两次,导致请求量翻倍,还可能让两个组件显示不同的数据快照。每当移动数据获取逻辑时,都要弄清楚谁负责管理该服务器状态——应该存在唯一的真实数据源。如果多个组件需要相同的远程数据,要么只获取一次并传递给它们,要么使用能够去重请求的缓存服务器状态层,而不是让每个组件都独立获取数据。

    测试是安全网

    在修改关键组件之前,先列出业务功能所依赖的行为:

    Product Management
    │
    ├── Filter products
    ├── Select products
    ├── Edit product
    ├── Delete product
    ├── Export products
    └── Permission handling
    

    然后在适当的层面为这些行为提供保护:

    • 单元测试适用于验证、转换、业务规则、格式化以及映射操作。
    • 组件测试适用于渲染、交互以及加载和错误状态的处理。
  • 端到端测试适用于关键工作流程、涉及多个组件的行为以及后端集成。
  • 代码重构并不要求100%的测试覆盖率。它只需要对关键行为有信心即可。在开始之前针对当前行为编写的测试最具价值,因为它们能证明一切都没有改变。

    小型拉取请求也是设计的一部分

    审查成本和回滚成本会随着变更规模的增大而上升。可以对比以下几种情况:

    Extract ProductFilters
    

    接着是

    Extract ProductTable
    

    然后是

    Extract product-fetching behavior
    

    而单个变更则被命名为

    Refactor ProductManagement
    

    它涉及

    4,000 lines changed
    

    较小的代码修改更易于审查和回退,能降低出现回归的风险,保留可读性强的 Git 历史记录,简化调试过程并减少合并冲突。对技术负责人而言,这与其说是一种代码质量偏好,不如说是一种让团队能够同步推进工作的方法。

    将重构与其他变更分开处理

    不要试图将重构与清单上的其他所有更改一起处理:

    React upgrade
          +
    State migration
          +
    API changes
          +
    UI redesign
          +
    CSS migration
          +
    Component refactoring
    

    如果此类发布后生产环境出现故障,要找出问题根源会十分困难,因为这六项更改中的任何一项都可能是原因。应当按顺序逐一排查:

    Refactor
       ↓
    Stabilize
       ↓
    Measure
       ↓
    Optimize
    

    一次只进行一项重大更改,这样就能确保每项回归问题都有明确的归属。

    高风险替换功能的使用开关

    对于关键工作流程,功能开关可以通过让两种实现方式共存来提供额外的安全保障:

    return useNewProductExperience
      ? <NewProductManagement />
      : <LegacyProductManagement />;
    

    随后,你可以在监控错误率和业务指标的同时逐步增加曝光度:

    1%
     ↓
    5%
     ↓
    25%
     ↓
    50%
     ↓
    100%
    

    这种方法对于高流量页面、对收入至关重要的流程、企业级应用、大型微前端以及存在风险的架构变更尤为有用。应将此标志视为临时措施:一旦新路径得到验证,就删除旧实现及该标志,否则最终会需要同时维护两者。

    改进前后

    简化后的起点是将数据处理、过滤、选择、导出和对话框功能整合到同一个函数中:

    function ProductManagement() {
      const [products, setProducts] = useState([]);
      const [filter, setFilter] = useState("");
      const [selected, setSelected] = useState([]);
      const [loading, setLoading] = useState(false);
      const [showDialog, setShowDialog] = useState(false);
    
      useEffect(() => {
        // Fetch products
      }, []);
      const filteredProducts = products.filter(...);
      const handleSelect = (...) => {
        // Selection logic
      };
      const handleExport = (...) => {
        // Export logic
      };
      return (
        <>
          {/* hundreds of lines of JSX */}
        </>
      );
    }
    

    这些做法本身并无不妥。问题在于某个组件承担了过多功能。经过重构后,页面会将钩子与展示组件相连:

    function ProductManagement() {
      const {
        products,
        loading
      } = useProducts();
    
    const {
        filter,
        setFilter,
        filteredProducts
      } = useProductFilters(products);
      const {
        selected,
        handleSelect
      } = useProductSelection();
      return (
        <ProductPage>
          <ProductFilters
            value={filter}
            onChange={setFilter}
          />
          <ProductTable
            products={filteredProducts}
            selected={selected}
            onSelect={handleSelect}
            loading={loading}
          />
          <ProductActions
            products={selected}
          />
        </ProductPage>
      );
    }
    

    该页面现在描述的是组合模式,这是页面级组件所能拥有的最佳状态。每个钩子函数和子组件都可以单独被读取、测试和修改。

    体积小并不等于速度快

    人们很容易认为将

    1 large component
    

    转变为

    10 smaller components
    

    就能提升性能。实际上它本身并不会带来优化效果,反而可能因为增加额外的渲染、重复的计算、频繁的上下文更新、重复的请求、不必要的useMemo和useCallback,以及每次渲染都会改变标识的属性而导致情况变得更糟。架构与性能虽有关联,但属于不同的考量范畴。在优化之前应先进行测量,并通过性能分析及生产环境监控数据来确认是否真的有所改进。

    以下次修改的代价来判断成功与否

    代码行数的减少是一个令人愉悦的附带效果:

    1,200 lines → 300 lines
    

    但这并非最终目标。应从以下维度比较修改前后的组件表现:

    • 认知复杂度与耦合度,这两项应有所下降
    • 可测试性,这一项应有所提升
    • 重复的API调用次数以及生产环境中的错误数量
    • 以毫秒为单位的渲染时间
    • 发布后的回归率
    • 进行一次常规修改所需的工作量

    您应追求的目标方向是:

    Complexity       ↓
    Coupling         ↓
    Regression risk  ↓
    Testability      ↑
    Maintainability  ↑
    Developer speed  ↑
    

    最宝贵的成果是降低修改成本,而非减少代码行数。

    何时不应进行重构

    并非每个大型组件都值得投入如此多的精力。不合理的理由包括:文件过长、有人更喜欢小文件、某种新架构正流行、想要引入其他库,或者代码不符合个人喜好。如果该组件稳定、经过充分测试、很少被修改、风险较低且处于隔离状态,那么大规模重构带来的风险可能反而大于其带来的好处。最终的决定取决于权衡:

    Expected future benefit
              │
              ▼
       Is it worth the
        refactoring risk?
              │
           Yes / No
    

    这是一种基于对未来变更预期的工程判断,而非格式偏好问题。

    理解现有行为才是最困难的部分

    编写新的实现方案往往并非最困难的部分。真正的难点在于充分理解当前的行为逻辑,以便保留用户所依赖的功能。生产环境中的组件蕴含着许多仅凭代码本身无法察觉的信息:未明文规定的产品假设、访问规则、与旧系统的接口连接方式、它能容忍的异常后端响应、下游系统依赖的事件跟踪机制、针对特定浏览器的解决方案、已无人记得的调用方信息、以往故障修复方案以及其他团队所持有的期望。正是这些隐藏的知识使得彻底重写代码比逐步拆分改造风险大得多。需要牢记的优先级是:首先保留现有行为,再优化结构,最后进行性能提升。

    生产环境代码重构检查清单

    开始之前:

    • 找出该组件的所有使用方。
    • 记录其当前的行为逻辑,并明确其职责范围。
  • 明确每个状态值的拥有者以及它所依赖的 API。
  • 识别关键工作流程,审查现有测试,并记录基准生产指标。
  • 在重构过程中:

    • 保持公共接口的稳定性。
    • 先提取纯函数,再处理 React 相关逻辑。
    • 将状态放在其使用者附近,避免使用没有实际价值的抽象层。
    • 排除无关的更改,注意防止重复请求,并保持 Pull Request 的规模较小。

    在合并之前:

    • 类型检查、单元测试、组件测试和端到端测试以及构建过程均需通过。
    • 不得出现重复请求,也不存在意外的渲染问题。

    部署之后:

    • 监控错误、API 流量和性能,并与基准值进行对比。
    • 密切关注关键的用户工作流程。
  • 一旦新路径得到认可,即可删除过时的代码。
  • 总结

    大型 React 组件并非问题所在,真正的问题在于失控的复杂性。错误的策略如下:

    Big Component
          ↓
    Rewrite Everything
    

    正确的策略则是通过耐心迭代,最终实现更简洁的架构:

    Big Component
          ↓
    Understand
          ↓
    Protect
          ↓
    Extract
          ↓
    Test
          ↓
    Measure
          ↓
    Repeat
          ↓
    Simpler Architecture
    

    目标并非增加文件数量,而是实现更安全的变更:发布版本不会让任何人措手不及,同时让后续开发者能够在六个月后毫无顾虑地修改代码库。