Back to blog
Frontend

组件化开发的核心秘密:如何实现真正的「高内聚、低耦合」?

从 Props、State、单向数据流到组件拆分与复用,深入理解 React 组件化开发的核心原则与内功心法。

Kerwin · 2024-11-10

1. Props 与组件通信

在上一章中,我们已经搭建好了开发环境,并对 React 的核心思想及 JSX 语法有了初步的认识。现在,我们将正式进入组件化开发的世界。组件是 React 应用的基石,理解如何构建组件、如何让它们之间有效通信,是掌握 React 的关键。本章将深入探讨组件的输入 (Props)内部状态 (State) 以及如何响应用户交互。

1.1 Props 与组件通信

任何有意义的 React 应用都是由多个组件构成的组件树。这些组件并非孤立存在,它们需要相互协作,传递信息,共同构成完整的用户界面。实现组件间通信最基础、最核心的机制,就是 Props

Props 是 "properties" 的缩写,其作用与 JavaScript 函数的参数非常相似。如果说组件是一个函数,那么 Props 就是这个函数接收的参数。父组件通过 Props 将数据和功能传递给子组件,从而实现对子组件的配置和控制。

让我们来看一个简单的例子。假设我们有一个 Welcome 组件,我们希望它能向不同的用户显示欢迎信息。

1.2 示例:Props 传递

// 1. Welcome.tsx (子组件)
type WelcomeProps = {
  name: string;
};

const Welcome = (props: WelcomeProps) => {
  return <h1>Hello, {props.name}</h1>;
};

// 2. App.tsx (父组件)
const App = () => {
  return (
    <div>
      <Welcome name="Sara" /> 
      <Welcome name="Cahal" /> 
      <Welcome name="Edite" /> 
    </div>
  );
};

在这个例子中,App 组件作为父组件,三次渲染Welcome 子组件。每次渲染时,它都通过一个名为 name 的 Prop,向 Welcome 组件传递了不同的值。Welcome 组件则在其函数参数中接收这个 props 对象,并读取 props.name 来动态地渲染内容。

1.2 关键特性:Props 是只读的 (Read-Only)

关于 Props,有一个至关重要的原则必须牢记:Props 是只读的 (Read-Only)。子组件绝不能尝试修改接收到的 Props。

所有 Props 都使得组件的输出仅依赖于输入,这使得组件的行为非常可预测。这种自顶向下的数据流动方式,通常被称为 "单向数据流"。数据就像瀑布一样,从组件树的顶端流向末端,这使得追踪数据的来源和变化变得非常简单,极大地降低了应用的复杂度。

1.3 扩展:children Prop

除了传递自定义数据外,React 还提供了一个特殊的 Prop:children。这个 Prop 的值不是通过属性传递,而是通过组件的闭合标签之间的内容来决定的。它使得我们可以轻松地创建具有 “插槽” 功能的容器类组件。

设想想一个 Card 组件,它需要一套统一的边框和阴影形式,但内部的内容是灵活多变的。

2. 使用 TypeScript 定义 Props 类型

随着应用框架的扩大,组件的 Props 可能会变得越来越复杂。如果我们不小心传递了错误类型的数据,或者遗漏了必需的 Prop,程序就可能在运行时出错,为了在开发阶段就避免这类问题,我们引入了 TypeScript

为组件的 Props 添加类型定义,就像是为组件签署了一份 "契约"。这份契约明确规定了该组件需要哪些 Props,以及每个 Prop 的数据类型是什么。这不仅能提供强大的编辑器自动补全错误检查,还让代码本身成为了最好的文档。

在 TypeScript 中,我们通常使用 typeinterface 关键字来定义 Props 的类型。对于组件 Props 而言,两者在功能上几乎可以互换,选择哪一个更多是团队的风格偏好。

让我们以一个更复杂的的用户资料卡片组件 UserProfile 添加类型定义。

2.1 示例:为 Props 添加类型定义

// 1. UserProfile.tsx
type UserProfileProps = {
  name: string;
  age: number;
  isVerified: boolean;
  hobbies?: string[]; // '?' 表示这是一个可选的 Prop
};

const UserProfile = (props: UserProfileProps) => {
  const { name, age, isVerified, hobbies } = props;
  return (
    <div>
      <h3>{name} {isVerified ? '✔' : ''}</h3>
      <p>Age: {age}</p>
      {hobbies && hobbies.length > 0 && (
        <p>Hobbies: {hobbies.join(', ')}</p>
      )}
    </div>
  );
};

// 2. App.tsx
const App = () => {
  return (
    <UserProfile
      name="John Doe"
      age={25}
      isVerified={true}
      hobbies={['reading', 'coding']}
    />
  );
};

在上述代码中,UserProfileProps 类型契约清晰地描述了 UserProfile 组件的 "API":它必须接收 name, age, isVerified 三个 Prop,并且它们的类型分别是我们期望的 string, number, boolean。同时,它还可以选择性地接收一个名为 hobbies 的字符串数组。

2.2 类型检查带来的效益

如果我们在使用 UserProfile 组件时违反了这个契约,TypeScript 编译器和我们的代码编辑器会立刻给出错误提示,例如:

  • <UserProfile name="Jane" isVerified={false} /> // 错误:属性 age 在类型中缺失。
  • <UserProfile name="Jane" age="25" isVerified={false} /> // 错误:不能将类型 string 分配给类型 number (因为 age 的值被错误地写成了字符串 "25")。

这种即时的反馈极大地提升了代码的健壮性开发效率

3. 事件处理与合成事件系统 (Event Handling)

我们已经了解了如何通过 Props 从父组件流向子组件。但如果子组件需要将信息传回给父组件呢?比如,当用户点击子组件中的一个按钮时,父组件的状态需要发生改变。这种自下而上的通信,通常通过 事件处理 来实现。

在 React 中处理事件的方式与在原生 DOM 中非常相似,但有几个细微的差别:

  1. 命名采用驼峰式: React 事件的命名采用驼峰式 (camelCase),而不是纯小写。例如,onclick 变为 onClick
  2. 传递函数: 我们传递的是一个函数作为事件处理程序,而不是一个字符串。

一个基本的事件处理如下所示:

3.2 示例:基本事件处理

const Button = () => {
  const handleClick = () => {
    alert('Button clicked!');
  };

  return (
    <button onClick={handleClick}>
      Click Me
    </button>
  );
};

在这里,我们将 handleClick 函数作为 Prop 传递给了 <button> 元素的 onClick 属性。当用户点击按钮时,React 会调用这个函数。

3.3 核心思想:子传父通信

要实现子组件传信息到父组件,核心思想就是将父组件中的函数作为 Prop 传递给子组件。父组件定义了行为(做什么),子组件则决定了何时触发该行为(何时调用该函数)。

让我们构建一个场景:父组件 Dashboard 需要知道其子组件 LoginButton 何时被点击。

3.4 子组件向父组件通信(事件处理进阶)

我们将父组件中的函数作为 Prop 传递给子组件,实现子组件通知父组件的行为。

示例:子传父通信
// 1. LoginButton.tsx (子组件)
type LoginButtonProps = {
  // 我们定义一个 onLoginClick 的 Prop,它的类型是一个不接收参数、无返回值的函数
  onLoginClick: () => void;
};

const LoginButton = ({ onLoginClick }: LoginButtonProps) => {
  // 当按钮被点击时,调用从父组件传来的 onLoginClick 函数
  return <button onClick={onLoginClick}>Login</button>;
};

// 2. Dashboard.tsx (父组件)
const Dashboard = () => {
  const handleUserLogin = () => {
    console.log('User is trying to log in from the Dashboard!');
    // 在这里可以处理登录逻辑,比如更新父组件的状态
  };

  return (
    <div>
      <h1>Welcome to the Dashboard</h1>
      {/* 将 handleUserLogin 函数作为 Prop 传递给子组件 */}
      <LoginButton onLoginClick={handleUserLogin} /> 
    </div>
  );
};

通过这种模式,LoginButton 组件保持了其通用性,它只负责渲染一个按钮并报告点击事件,而不关心点击后具体会发生什么。所有的业务逻辑都保留在了父组件 Dashboard 中,实现了清晰的职责分离

3.5 合成事件系统 (SyntheticEvent)

值得一提的是,我们传递给 onClick 等事件处理程序的事件对象 e,并不是原生的浏览器事件对象,而是一个 合成事件 (SyntheticEvent) 对象。

  • 作用: 它是 React 对原生事件的一个跨浏览器包装器
  • 优势: 它抹平了不同浏览器在事件系统上的差异,使得我们写的事件处理代码能够在所有浏览器中表现一致,无需担心兼容性问题。

3. 状态管理:useState Hook 详解

Props 是从外部传入且不可变的,而 State 则是组件内部自己管理的数据,并且它是可变的。在函数式组件中,我们用来赋予组件状态能力的工具,就是 React 最基础也最重要的 Hook 之一:useState

useState 的用法

useState 的调用本身非常简单,它接收一个参数作为状态的初始值,然后返回一个包含两个元素的数组。我们通常使用 JavaScript 的数组解构语法来接收这两个值:

import { useState } from 'react';

const Counter = () => {
  // 1. 调用 useState,传入初始值 0
  // 2. 解构返回的数组
  const [count, setCount] = useState(0); 

  // ...
};

3.1 详解返回的两个成员

让我们来仔细解读解构出来的这两个成员:

1. 状态值 (count)

  • 这是状态变量。 它是在每次组件渲染时,持有当前状态值的常量
  • 在上面的例子中,它第一次渲染时的值是传入的初始值 0

2. 更新函数 (setCount)

  • 这是更新函数。 它是我们用来改变 count 状态的唯一途径
  • 注意: 直接修改 count 的值(例如 count = count + 1)是无效的,并且严重违反了 React 的原则。

3.2 状态更新的循环 (State Update Lifecycle)

当我们调用更新函数(如 setCount(1))时,React 会做两件重要的事情:

  1. 它会计划一次对状态的更新,将新的状态值保存起来。
  2. 它会触发该组件的一次重新渲染 (re-render)

在下一次渲染发生时,useState 会返回更新后的最新状态值。正是这个 “状态更新 ->触发重新渲染-> 使用新状态渲染 UI” 的循环,构成了 React 动态交互的核心。

3.3 State 的不可变性 (Immutability) 与函数式更新

1.基础计数器示例

我们首先回顾一个基本的 useState 计数器示例:

import { useState } from 'react';

const Counter = () => {
  const [count, setCount] = useState(0);

  const handleIncrement = () => {
    // 递增 count 并更新状态
    setCount(count + 1); 
  };

  return (
    <div>
      <p>Current count is: {count}</p>
      {/* 点击按钮时,触发状态更新和组件重排渲染 */}
      <button onClick={handleIncrement}>Increment</button>
    </div>
  );
};

2. 核心原则:状态的不可变性

在使用 useState 时,一个核心原则是状态的不可变性 (Immutability)

对于对象或数组这样的引用类型,我们不应该直接修改它们内部的属性,而应该总是创建一个新的对象或数组来替换旧的

  • 原因: React 通过浅层比较来判断状态是否发生了变化。如果您只是修改了原对象的属性,对象的引用地址并未改变,React 可能会认为状态没有变化,从而错过重新渲染。
❌ 错误的做法(直接修改引用)
const [user, setUser] = useState({ name: 'Alice', age: 25 });

const handleAgeIncrement = () => {
  user.age += 1; // 🚫 错误!直接修改了原对象
  setUser(user); // 传入的是旧的引用,React 可能不会更新
};
✅ 正确的做法(创建新对象)
const [user, setUser] = useState({ name: 'Alice', age: 25 });

const handleAgeIncrement = () => {
  // ✅ 使用展开语法 (...) 创建一个新的对象,并覆盖 age 属性
  const newUser = { ...user, age: user.age + 1 };
  
  setUser(newUser);
};

3.4 函数式更新 (Functional Updates)

此外,更新函数(Setter 函数,如 setCount)还支持接收一个函数作为参数,这种形式被称为函数式更新

  • 工作方式: 这个函数会接收到前一个状态作为参数,并返回新的状态值。
  • 推荐用法: 使用函数式更新是更安全、更推荐的做法。它可以避免在快速连续的更新中由于闭包导致的依赖旧状态的问题。

示例:函数式更新

// 使用函数式更新,确保总是基于最新的状态进行计算
setCount(prevCount => prevCount + 1);