中文

深入探讨 React 的组件架构,比较组合与继承。了解 React 为何偏爱组合,并探索如高阶组件(HOCs)、渲染属性(Render Props)和 Hooks 等模式,以构建可扩展、可重用的组件。

React 组件架构:为什么组合优于继承

在软件开发领域,架构至关重要。我们构建代码的方式决定了其可扩展性、可维护性和可重用性。对于使用 React 的开发者来说,最基本的架构决策之一围绕着如何在组件之间共享逻辑和 UI。这让我们回到了面向对象编程中的一个经典辩论,并将其重新构想用于 React 的组件化世界:组合 vs. 继承

如果你有 Java 或 C++ 等经典面向对象语言的背景,继承可能感觉是自然而然的首选。它是一个强大的概念,用于创建 'is-a' 关系。然而,React 官方文档提供了一个清晰而强烈的建议:"在 Facebook,我们在数千个组件中使用 React,我们没有发现任何我们建议创建组件继承层次结构的用例。"

本文将全面探讨这一架构选择。我们将深入剖析继承和组合在 React 上下文中的含义,展示为什么组合是惯用且更优的方法,并探索强大的模式——从高阶组件到现代 Hooks——这些模式使组合成为开发者构建面向全球受众的健壮和灵活应用程序的最佳工具。

理解旧有范式:什么是继承?

继承是面向对象编程(OOP)的核心支柱。它允许一个新类(子类)获取现有类(超类或父类)的属性和方法。这创建了一种紧密耦合的 'is-a'(是一种)关系。例如,一个 GoldenRetriever 是一种 Dog,而 Dog 是一种 Animal

非 React 上下文中的继承

让我们看一个简单的 JavaScript 类示例来巩固这个概念:

class Animal {
  constructor(name) {
    this.name = name;
  }

  speak() {
    console.log(`${this.name} makes a noise.`);
  }
}

class Dog extends Animal {
  constructor(name, breed) {
    super(name); // Calls the parent constructor
    this.breed = breed;
  }

  speak() { // Overrides the parent method
    console.log(`${this.name} barks.`);
  }

  fetch() {
    console.log(`${this.name} is fetching the ball!`);
  }
}

const myDog = new Dog('Buddy', 'Golden Retriever');
myDog.speak(); // Output: "Buddy barks."
myDog.fetch(); // Output: "Buddy is fetching the ball!"

在这个模型中,Dog 类自动从 Animal 获取 name 属性和 speak 方法。它还可以添加自己的方法(fetch)并覆盖现有方法。这创建了一个僵化的层次结构。

为什么继承在 React 中步履维艰

虽然这种 'is-a' 模型适用于某些数据结构,但当它应用于 React 中的 UI 组件时,会产生严重问题:

由于这些问题,React 团队围绕一个更灵活、更强大的范式设计了该库:组合。

拥抱 React 方式:组合的力量

组合是一种设计原则,它倾向于 'has-a'(拥有)或 'uses-a'(使用)关系。组件不是作为另一个组件存在,而是拥有其他组件或使用它们的功能。组件被视为构建块——就像乐高积木一样——它们可以通过各种方式组合起来创建复杂的 UI,而无需受限于僵化的层次结构。

React 的组合模型用途极其广泛,它体现在几种关键模式中。让我们从最基本到最现代和最强大的模式进行探索。

技术 1:使用 `props.children` 进行包含

最直接的组合形式是包含。在这种情况下,组件充当通用容器或“盒子”,其内容从父组件中传入。React 为此提供了一个特殊的内置 prop:props.children

想象一下,你需要一个 Card 组件,它可以以一致的边框和阴影包裹任何内容。与其通过继承创建 TextCardImageCardProfileCard 变体,不如创建一个通用的 Card 组件。

// Card.js - A generic container component
function Card(props) {
  return (
    <div className="card">
      {props.children}
    </div>
  );
}

// App.js - Using the Card component
function App() {
  return (
    <div>
      <Card>
        <h1>Welcome!</h1>
        <p>This content is inside a Card component.</p>
      </Card>

      <Card>
        <img src="/path/to/image.jpg" alt="An example image" />
        <p>This is an image card.</p>
      </Card>
    </div>
  );
}

在这里,Card 组件不知道也不关心它包含什么。它只是提供了包装样式。在起始和结束 <Card> 标签之间的内容会自动作为 props.children 传递。这是解耦和可重用性的一个绝佳例子。

技术 2:使用 Props 进行专业化

有时,一个组件需要多个“空位”由其他组件填充。虽然你可以使用 props.children,但更明确和结构化的方式是将组件作为常规 props 传递。这种模式通常被称为专业化。

考虑一个 Modal 组件。模态框通常有一个标题部分、一个内容部分和一个动作部分(带有“确认”或“取消”等按钮)。我们可以设计我们的 Modal 来接受这些部分作为 props。

// Modal.js - A more specialized container
function Modal(props) {
  return (
    <div className="modal-backdrop">
      <div className="modal-content">
        <div className="modal-header">{props.title}</div>
        <div className="modal-body">{props.body}</div>
        <div className="modal-footer">{props.actions}</div>
      </div>
    </div>
  );
}

// App.js - Using the Modal with specific components
function App() {
  const confirmationTitle = <h2>Confirm Action</h2>;
  const confirmationBody = <p>Are you sure you want to proceed with this action?</p>;
  const confirmationActions = (
    <div>
      <button>Confirm</button>
      <button>Cancel</button>
    </div>
  );

  return (
    <Modal
      title={confirmationTitle}
      body={confirmationBody}
      actions={confirmationActions}
    />
  );
}

在这个例子中,Modal 是一个高度可重用的布局组件。我们通过为其 titlebodyactions 传入特定的 JSX 元素来使其专业化。这比创建 ConfirmationModalWarningModal 子类要灵活得多。我们只需根据需要将 Modal 与不同的内容组合。

技术 3:高阶组件 (HOCs)

为了共享非 UI 逻辑,例如数据获取、认证或日志记录,React 开发者过去常常转向一种名为高阶组件(HOCs)的模式。尽管在现代 React 中很大程度上被 Hooks 取代,但理解它们至关重要,因为它们代表了 React 组合故事中的一个关键演变步骤,并且仍然存在于许多代码库中。

HOC 是一个函数,它接受一个组件作为参数并返回一个新的、增强的组件。

让我们创建一个名为 withLogger 的 HOC,它会在组件更新时记录其 props。这对于调试很有用。

// withLogger.js - The HOC
import React, { useEffect } from 'react';

function withLogger(WrappedComponent) {
  // It returns a new component...
  return function EnhancedComponent(props) {
    useEffect(() => {
      console.log('Component updated with new props:', props);
    }, [props]);

    // ... that renders the original component with the original props.
    return <WrappedComponent {...props} />;
  };
}

// MyComponent.js - A component to be enhanced
function MyComponent({ name, age }) {
  return (
    <div>
      <h1>Hello, {name}!</h1>
      <p>You are {age} years old.</p>
    </div>
  );
}

// Exporting the enhanced component
export default withLogger(MyComponent);

withLogger 函数包装了 MyComponent,赋予它新的日志记录功能,而无需修改 MyComponent 的内部代码。我们可以将相同的 HOC 应用于任何其他组件,以赋予它相同的日志记录功能。

HOC 的挑战:

技术 4:渲染属性 (Render Props)

渲染属性模式作为 HOC 某些缺点的解决方案而出现。它提供了一种更明确的共享逻辑方式。

一个带有渲染属性的组件将一个函数作为 prop(通常命名为 render)接收,并调用该函数以确定渲染什么,同时将任何状态或逻辑作为参数传递给它。

让我们创建一个 MouseTracker 组件,它跟踪鼠标的 X 和 Y 坐标,并将其提供给任何想要使用它们的组件。

// MouseTracker.js - Component with a render prop
import React, { useState, useEffect } from 'react';

function MouseTracker({ render }) {
  const [position, setPosition] = useState({ x: 0, y: 0 });

  const handleMouseMove = (event) => {
    setPosition({ x: event.clientX, y: event.clientY });
  };

  useEffect(() => {
    window.addEventListener('mousemove', handleMouseMove);
    return () => {
      window.removeEventListener('mousemove', handleMouseMove);
    };
  }, []);

  // Call the render function with the state
  return render(position);
}

// App.js - Using the MouseTracker
function App() {
  return (
    <div>
      <h1>Move your mouse around!</h1>
      <MouseTracker
        render={mousePosition => (
          <p>The current mouse position is ({mousePosition.x}, {mousePosition.y})</p>
        )}
      />
    </div>
  );
}

在这里,MouseTracker 封装了跟踪鼠标移动的所有逻辑。它本身不渲染任何东西。相反,它将渲染逻辑委托给它的 render prop。这比 HOC 更明确,因为你可以在 JSX 中清楚地看到 mousePosition 数据来自何处。

children prop 也可以用作函数,这是这种模式的一个常见且优雅的变体:

// Using children as a function
<MouseTracker>
  {mousePosition => (
    <p>The current mouse position is ({mousePosition.x}, {mousePosition.y})</p>
  )}
</MouseTracker>

技术 5:Hooks(现代且首选的方法)

Hooks 于 React 16.8 引入,彻底改变了我们编写 React 组件的方式。它们允许你在函数组件中使用状态和其他 React 特性。最重要的是,自定义 Hooks 为在组件之间共享有状态逻辑提供了最优雅、最直接的解决方案。

Hooks 以更简洁的方式解决了 HOC 和渲染属性的问题。让我们将 MouseTracker 示例重构为一个名为 useMousePosition 的自定义 hook。

// hooks/useMousePosition.js - A custom Hook
import { useState, useEffect } from 'react';

export function useMousePosition() {
  const [position, setPosition] = useState({ x: 0, y: 0 });

  useEffect(() => {
    const handleMouseMove = (event) => {
      setPosition({ x: event.clientX, y: event.clientY });
    };

    window.addEventListener('mousemove', handleMouseMove);
    return () => {
      window.removeEventListener('mousemove', handleMouseMove);
    };
  }, []); // Empty dependency array means this effect runs only once

  return position;
}

// DisplayMousePosition.js - A component using the Hook
import { useMousePosition } from './hooks/useMousePosition';

function DisplayMousePosition() {
  const position = useMousePosition(); // Just call the hook!

  return (
    <p>
      The mouse position is ({position.x}, {position.y})
    </p>
  );
}

// Another component, maybe an interactive element
import { useMousePosition } from './hooks/useMousePosition';

function InteractiveBox() {
  const { x, y } = useMousePosition();

  const style = {
    position: 'absolute',
    top: y - 25, // Center the box on the cursor
    left: x - 25,
    width: '50px',
    height: '50px',
    backgroundColor: 'lightblue',
  };

  return <div style={style} />;
}

这是一个巨大的改进。没有“包装器地狱”,没有 prop 命名冲突,也没有复杂的渲染属性函数。逻辑被完全解耦到一个可重用的函数(useMousePosition)中,任何组件都可以通过一行清晰的代码“接入”该有状态逻辑。自定义 Hooks 是现代 React 中组合的终极表达,允许你构建自己的可重用逻辑块库。

快速比较:React 中的组合 vs. 继承

为了总结 React 上下文中的主要区别,这里有一个直接的比较:

方面 继承(React 中的反模式) 组合(React 中首选)
关系 “is-a”关系。一个专业化组件是一种基组件的版本。 “has-a”或“uses-a”关系。一个复杂组件拥有较小的组件或使用共享逻辑。
耦合 高。子组件与父组件的实现紧密耦合。 低。组件独立,可以在不同上下文中重用而无需修改。
灵活性 低。僵化的、基于类的层次结构使得在不同组件树之间共享逻辑变得困难。 高。逻辑和 UI 可以像积木一样以无数种方式组合和重用。
代码可重用性 限于预定义的层次结构。你只想要“香蕉”时,却得到了整个“大猩猩”。 优秀。小型、专注的组件和 hooks 可以在整个应用程序中重用。
React 惯用语 官方 React 团队不鼓励使用。 构建 React 应用程序的推荐和惯用方法。

结论:以组合思维构建

组合与继承的争论是软件设计中的一个基础性话题。尽管继承在经典 OOP 中有其一席之地,但 UI 开发的动态、基于组件的特性使其不适合 React。该库从根本上就是为拥抱组合而设计的。

通过偏爱组合,你将获得:

作为一名全球 React 开发者,掌握组合不仅仅是遵循最佳实践——更是理解使 React 成为如此强大和高效工具的核心理念。从创建小型、专注的组件开始。对通用容器使用 props.children,对专业化使用 props。对于共享逻辑,首先选择自定义 Hooks。通过以组合思维构建,你将很好地构建出优雅、健壮且经久耐用的 React 应用程序。