首页 / 文章 / 从DOM脚本到基于状态的UI:React的核心模型

从DOM脚本到基于状态的UI:React的核心模型

为何 React 用声明式组件取代手动 DOM 更新,以及 JSX、props、state、重新渲染和单向数据流是如何构成一个统一的思维模型的。

4868 词

通过简单的DOM操作,就能轻松创建一个可以更新计数器、更换图标并播放动画的点赞按钮。但如果在实时动态内容中加入50个这样的按钮,且每个按钮还要同时记录评论、分享次数以及保存状态,手工编写的DOM代码就会变得难以应对。React的存在正是为了解决这类问题:你只需描述当前数据下界面应有的样子,React会自动判断需要进行哪些DOM更改。

本指南将按逻辑顺序逐步讲解实现这一目标的原理:JSX、组件、属性、状态、重新渲染、声明式风格,以及数据和事件在组件树中的传递方式。学习完之后,你应该能够预判组件何时会重新渲染,判断某个状态信息应存储在何处,并识别那些会导致更新出错的初学者常见错误。

React旨在解决的问题

在组件库成为常规之前,交互式用户界面意味着需要使用命令式代码:先找到相关元素,然后在有变化时手动执行所有修改操作。对于一个简单的点赞按钮,其实现方式如下:

// Traditional DOM manipulation
const button = document.getElementById("like-button");
const countEl = document.getElementById("like-count");
const icon = document.getElementById("like-icon");
let liked = false;
let count = 42;

而点击处理函数则必须记住两种状态下的所有视觉细节:

button.addEventListener("click", function() {
  if (!liked) {
    liked = true;
    count++;
    countEl.textContent = count;
    button.classList.add("liked");
    button.classList.remove("unliked");
    icon.src = "/icons/heart-filled.svg";
    button.style.color = "#e0245e";
    // trigger animation
    button.classList.add("animate-pop");
    setTimeout(() => button.classList.remove("animate-pop"), 300);
  } else {
    liked = false;
    count--;
    countEl.textContent = count;
    button.classList.remove("liked");
    button.classList.add("unliked");
    icon.src = "/icons/heart-empty.svg";
    button.style.color = "#6c757d";
  }
});

对于单个按钮来说这还算可控。但现在想象一下有50篇帖子的动态内容,每篇帖子都有独立的点赞、评论、分享和保存控件,这些控件都可能因为用户点击或服务器发送的新数据而发生变化。这样一来,脚本就会变成由大量元素查找、监听器和变量构成的复杂网络,需要人工逐一维护。随着应用规模的扩大,会出现三种故障模式:

  • 同步问题。DOM显示的内容与变量中的数据不一致。如果你更新了计数器元素却忘了修改变量,或者反之,那就需要调试两个不同的数据来源。
  • 复用性差。处理函数与特定的元素ID及HTML结构绑定,因此将按钮移到其他页面时就必须重新编写代码。
  • 复杂性高。每新增一个功能,都要求开发者了解它可能影响的其它部分;哪怕是微小的改动也可能破坏页面中其他功能。

2013年由Facebook开源的React通过一个理念解决了以上三个问题:不再直接发送DOM指令,而是描述在特定状态下应有的界面。React会将这个描述与屏幕上实际显示的内容进行对比,然后应用差异部分。由你描述界面,React负责更新。

JSX:可编译为 JavaScript 的标记语言

React 代码中最奇怪的一点就是在函数内部出现了类似 HTML 的标记:

function Greeting() {
  return (
    <div className="greeting">
      <h1>Hello, Priya!</h1>
      <p>Welcome back.</p>
    </div>
  );
}

那种标记就是 JSX,这是一种语法扩展,允许你在 JavaScript 文件中编写元素树结构。它既不是 HTML,也不是浏览器能够理解的格式。在代码实际运行之前,会通过构建步骤(如 Babel、TypeScript 编译器或使用它们的打包工具)将其重写为普通的函数调用。你需要这样编写:

// What you write (JSX):
return (
  <h1 className="title">Hello!</h1>
);

然后编译器会生成与之等价的内容:

// What the compiler transforms it into:
return React.createElement("h1", { className: "title" }, "Hello!");

React.createElement仅返回一个描述该元素的普通对象,其中包含其类型、属性和子元素信息。React通过读取这些对象来决定实际的DOM应包含什么内容。(较新的JSX转换会使用react/jsx-runtime中的其他辅助函数,但生成的仍是类似的结构描述对象。)没人会手动编写这类调用代码;JSX的存在正是为了让嵌套的标记结构更易于阅读。

JSX与HTML的不同之处

其语法与HTML相近但并不完全相同。最容易让人混淆的差异包括:

// HTML                              JSX
// ─────────────────────────────     ──────────────────────────────────
// class="title"                    className="title"  ← JS reserved word
// for="email"                      htmlFor="email"    ← JS reserved word
// <input> (self-closing optional)  <input />          ← must self-close
// onclick="handler()"              onClick={handler}  ← camelCase, no quotes
// style="color: red"               style={{ color: "red" }}  ← JS object

在 JavaScript 中,class 和 for 是保留字,因此 JSX 使用 className 和 htmlFor。每个元素都必须被关闭。事件处理函数采用驼峰命名法,且接收的是函数而非字符串。内联样式以对象形式存在。如需深入了解这种语法背后的权衡,可参阅 为何 JSX 不是 HTML。

嵌入 JavaScript 表达式

大括号为 JavaScript 打开了一个应用窗口。任何能计算出值的表达式都可以放在其中:变量、函数调用、三元运算符、数组方法。在此示例中,首先计算在线状态:

function UserCard({ user }) {
  const isOnline = user.lastSeen < Date.now() - 5 * 60 * 1000;

随后通过多个表达式来构建标记内容:截断后的个人简介、条件性的类名、条件性的标签以及格式化的日期:

  return (
    <div className="card">
      <img src={user.avatar} alt={user.name} />
      <h2>{user.name}</h2>
      <p>{user.bio.length > 100 ? user.bio.slice(0, 100) + "..." : user.bio}</p>
      <span className={isOnline ? "badge-green" : "badge-grey"}>
        {isOnline ? "Online" : "Offline"}
      </span>
      <p>Joined: {new Date(user.joinedAt).toLocaleDateString()}</p>
    </div>
  );
}

规则很简单:大括号用于包裹表达式,而非语句。你可以在标记中直接使用三元运算符而不用if块,也可以使用.map()而不用for循环。

组件:返回UI的函数

组件是一种可重用的、自包含的UI元素,它以JavaScript函数的形式编写并返回JSX。这就是其完整定义:

// A component is just a function that returns JSX
function Button() {
  return (
    <button className="btn">
      Click me
    </button>
  );
}

使用方式与HTML标签相同,而且可以随意添加多个:

function App() {
  return (
    <div>
      <Button />
      <Button />
      <Button />
    </div>
  );
}

通过单个定义即可渲染出三个完全相同的按钮。

将小型组件组合成大型组件

当组件相互嵌套时,它们的功能会变得更加强大。那些小型、单一功能的组件就像积木一样被组合成更大的组件。一个头像组件只知道如何显示图片:

function Avatar({ src, alt }) {
  return <img className="avatar" src={src} alt={alt} />;
}

在此基础上还有一系列稍大的组件:名称块、将头像与姓名结合在一起的用户信息行,以及将用户信息与帖子内容结合在一起的明信片:

function UserName({ name, handle }) {
  return (
    <div>
      <strong>{name}</strong>
      <span>@{handle}</span>
    </div>
  );
}function UserInfo({ user }) {
  return (
    <div className="user-info">
      <Avatar src={user.avatar} alt={user.name} />
      <UserName name={user.name} handle={user.handle} />
    </div>
  );
}function PostCard({ post, author }) {
  return (
    <article className="post-card">
      <UserInfo user={author} />
      <p>{post.content}</p>
      <span>{post.likes} likes</span>
    </article>
  );
}

每一层都有其特定的功能。Avatar负责渲染图片,UserInfo则将头像与姓名并排显示,PostCard则负责构建整个帖子结构。这正是React所倡导的做法:将界面拆分成尽可能小的合理部分,为每个部分明确职责,再通过组合实现整体功能。关于构建可复用React组件的指南进一步阐述了组件设计的相关内容。

为何组件名称要大写

React 通过首字母来区分原生元素和组件。<button>会变成 DOM 按钮;而<Button>则会让 React 在作用域中查找名为Button的变量并调用它。以小写字母命名的组件会被视为未知的 HTML 标签,这往往是“我的组件没有渲染内容”这一问题的常见原因。

Props:从外部配置组件

一个始终显示“点击我”的按钮并没有太大用处。Props(即属性的缩写)是父组件向子组件传递数据的方式,因此一个定义可以适用于多种情况。

Props 的传递方向是单向的,从渲染另一个组件的组件传向下方被渲染的组件,并以单个对象参数的形式传递。父组件会像设置属性一样编写它们:

// Parent passes data as props (looks like HTML attributes)
function App() {
  return (
    <div>
      <Button label="Submit" colour="blue" />
      <Button label="Cancel" colour="grey" />
      <Button label="Delete" colour="red" />
    </div>
  );
}

子组件会拆解出它所需的元素:

// Child receives them as an object
function Button({ label, colour }) {
  return (
    <button className={`btn btn-${colour}`}>
      {label}
    </button>
  );
}

一个定义,三个外观不同的按钮,因为它们接收了不同的值。

属性可以携带任何类型的值

字符串只是开始而已。数字、布尔值、数组、对象和函数都是有效的属性。例如,产品卡片可以接收数据对象、回调函数以及标志位:

function ProductCard({ product, onAddToCart, featured }) {
  return (
    <div className={`card ${featured ? "card-featured" : ""}`}>
      <img src={product.image} alt={product.name} />
      <h3>{product.name}</h3>
      <p>₹{product.price.toLocaleString()}</p>
      <p>{product.rating} ★ ({product.reviewCount} reviews)</p>
      <button onClick={onAddToCart}>
        Add to Cart
      </button>
    </div>
  );
}

在调用处,这些值是通过大括号传递的:

// Usage:
<ProductCard
  product={{ name: "Headphones", price: 2499, rating: 4.3, reviewCount: 128 }}
  onAddToCart={() => addToCart(product.id)}
  featured={true}
/>

请注意,内联的 onAddToCart 回调函数引用了 product.id,但这里的 product 只是作为属性传递的原始对象,并非作用域内的变量。在实际代码中,应使用在父组件中定义的变量,例如 onAddToCart={() => addToCart(item.id)}。将函数作为属性传递是子组件上报事件的标准方式,这一点在后面还会提到。

属性是只读的

组件绝不能修改自身的属性。在渲染过程中,这些属性都是固定的输入值。当需要更改时,应由父组件进行修改并使用新值重新渲染子组件。在子组件内部重新赋值属性是错误的做法:

// WRONG — never modify props
function Button({ count }) {
  count = count + 1; // ← this is a mistake
  return <button>{count}</button>;
}

重新赋值只会改变局部变量;它不会影响到父组件,且在下次渲染时就会消失。当组件需要可变化的值时,这就是状态存在的意义:

// CORRECT — props are read-only, use state for data that changes
function Button({ initialCount }) {
  const [count, setCount] = useState(initialCount);
  return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}

在这里,initialCount仅用于初始化状态;在首次渲染之后,计数器就由该组件自行管理了。

状态:组件的私有内存

属性来自外部,而状态则是组件拥有并可随时间变化的数据。当状态发生变化时,React会使用新值重新渲染该组件,这样你就无需手动操作DOM了。

状态是通过从React导入的useState钩子来创建的:

import { useState } from "react";

计数器展示了其基本用法:

function Counter() {
  const [count, setCount] = useState(0);
  //     ↑ current value   ↑ function to update it    ↑ initial value  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
      <button onClick={() => setCount(count - 1)}>Decrement</button>
      <button onClick={() => setCount(0)}>Reset</button>
    </div>
  );
}

useState(0)会返回一个包含当前值(count,初始值为0)和设置函数(setCount)的数组。通过设置函数传入新值,可以告知React将该值存储起来并重新渲染组件。

利用状态重建点赞按钮

最初那种命令式的点赞按钮现在变得简洁多了。首先进行导入操作:

import { useState } from "react";

接着需要为liked和count的任意组合描述对应的按钮样式:

function LikeButton({ initialLikes }) {
  const [liked, setLiked] = useState(false);
  const [count, setCount] = useState(initialLikes);  function handleClick() {
    if (liked) {
      setLiked(false);
      setCount(c => c - 1);
    } else {
      setLiked(true);
      setCount(c => c + 1);
    }
  }  return (
    <button
      onClick={handleClick}
      className={liked ? "btn-liked" : "btn-default"}
    >
      {liked ? "♥" : "♡"} {count}
    </button>
  );
}

这里没有元素查找操作,也没有classList调用或textContent赋值。处理函数仅负责更新两个状态值,而JSX则根据这些值决定按钮的显示样式。React会确保DOM与组件状态保持一致。

注意更新器函数 setCount(c => c - 1)。传递函数而非数值可以获取最新状态,这在同一事件中有多个更新操作时更为安全。

每个实例都保留自己的状态

状态属于组件的特定实例,而非组件定义本身。渲染三个类似按钮时,每个按钮都有独立的 liked 和 count 值;点击其中一个不会影响其他按钮:

function Feed() {
  return (
    <div>
      <LikeButton initialLikes={24} />   {/* has its own state */}
      <LikeButton initialLikes={7} />    {/* has its own state */}
      <LikeButton initialLikes={156} />  {/* has its own state */}
    </div>
  );
}

哪些内容应存储在状态中

以下类型的值适合使用状态来保存:

  • 由于用户交互或外部数据输入而随时间变化的值
  • 一旦发生变化就需要更新用户界面的值
  • 属于该特定组件实例的值

避免将那些可以通过现有属性或状态计算得出的值(应在渲染时进行计算),以及那些无需重新渲染就会变化的值放入状态中,比如计时器ID,这类值更适合放在ref中。

重新渲染的原理

重新渲染是整个React模型背后的核心机制。当状态发生变化时,React会再次调用你的组件函数,获取最新的UI描述,将其与之前的描述进行比较,然后仅更新DOM中有所差异的部分。

在首次渲染时,计数器会生成相应的标记,React则会创建对应的DOM节点:

Initial render:
  count = 0
  Component runs → returns <p>Count: 0</p> <button>Increment</button>
  React creates DOM nodes

当用户点击时,设置函数会触发新的渲染,React随后会比较两次得到的描述:

User clicks Increment:
  setCount(1) called
  React re-renders the component
  count = 1
  Component runs again → returns <p>Count: 1</p> <button>Increment</button>
  React compares: <p>Count: 0</p> vs <p>Count: 1</p>
  React updates only the text node inside <p>
  Button is unchanged — React leaves it alone

在真实的 DOM 中,只有段落内的文本会发生变化,按钮则保持不变。React 不会重新构建整个页面,而是计算出最少的更改内容并仅应用这些更改,因此频繁的重新渲染通常不会有问题。

什么会导致组件重新渲染

当组件的状态发生变化时,它就会再次渲染:

1. The component's own state changes (setCount, setUser, etc.)
         ↓
   Component re-renders

在另外几种情况下,它也会再次渲染:

2. The component's props change (parent passes different values)
         ↓
   Component re-renders3. A context the component uses changes
         ↓
   Component re-renders4. The parent re-renders
         ↓
   All children re-render (unless memoized)

实际上,“props 发生变化”和“父组件重新渲染”是从两个不同角度观察到的同一事件:子组件之所以能收到新的 props,只是因为其父组件重新渲染了。实际后果是,默认情况下,父组件的渲染会级联影响到所有子组件,无论这些子组件的 props 是否发生了变化,除非它们被加入了记忆化处理。

这种级联反应很少会成为问题。渲染只不过是生成对象的函数调用;真正耗时的部分在于操作真实的 DOM,而 React 会尽量将这一操作降到最低。大多数实际的性能问题都源于组件和状态的架构方式,而非渲染次数的多少。

虚拟 DOM 与协调机制

React 会为 UI 维护一个轻量级的 JavaScript 表示形式,通常称为虚拟 DOM。每次渲染都会生成一棵新的元素对象树,React 会通过一种名为协调机制的过程将其与之前的树进行对比。对比的结果就是需要执行的实际 DOM 操作列表。

你永远不会直接操作这一层,它属于实现细节。之所以重要,是因为在内存中比较普通对象的成本很低,而真正的 DOM 操作则相对缓慢。通过这种比较来处理每一次更新,可以让 React 批量处理并减少这些高成本的操作。

声明式 UI 与命令式 UI

React 是声明式的,理解这个词才是真正的思维转变。让我们比较两种渲染列表的方式。

命令式:列出步骤

命令式版本首先清空容器:

// Imperative: manual DOM manipulation
const list = document.getElementById("list");
list.innerHTML = "";  // clear it

然后手动创建、配置并添加每个项目:

items.forEach(item => {
  const li = document.createElement("li");
  li.textContent = item.name;
  li.className = item.active ? "active" : "";
  li.addEventListener("click", () => handleClick(item.id));
  list.appendChild(li);
});

这段代码是一系列指令:创建这个节点,设置那个属性,附加这个监听器,将其插入到指定位置。你需要负责操作之前的状态、之后的状态以及中间的每一个步骤。

声明式:描述最终结果

声明式版本规定了对于任何items数组,列表应呈现的样式:

// Declarative: describe the desired output
function ItemList({ items, onItemClick }) {
  return (
    <ul>
      {items.map(item => (
        <li
          key={item.id}
          className={item.active ? "active" : ""}
          onClick={() => onItemClick(item.id)}
        >
          {item.name}
        </li>
      ))}
    </ul>
  );
}

不存在“先清空列表再重新构建”的操作。你只需描述目标状态,React会自行决定如何从当前的DOM结构过渡到该目标状态。key属性会在每次渲染时告诉React哪些元素是哪个,这样它就能移动、更新或删除相应的元素,而无需重新创建所有元素。

为何声明式风格适合UI开发

  • 可预测性。在相同的属性和状态下,组件会渲染出相同的结果。你可以将界面视为数据的纯函数:UI = f(state, props)。
  • 减少记忆负担。你无需记住DOM当前的样式或需要进行的修改,只需描述当前的状态即可。
  • 错误更少。大多数与手动操作DOM相关的错误,比如只更新了某个元素而未更新其相邻元素,或是监听器与数据不同步,只要整个视图都基于状态生成,这类错误就根本不会出现。
  • 组件树与单向数据流

    每个React应用都是一棵树。根组件通常为App,它会渲染子组件,而这些子组件又会继续渲染各自的子组件,从而形成与屏幕结构一致的层次结构:

    App
    ├── Navbar
    │   ├── Logo
    │   ├── NavLinks
    │   └── UserMenu
    │       ├── Avatar
    │       └── DropdownMenu
    ├── Dashboard
    │   ├── Sidebar
    │   │   ├── SidebarLink (×5)
    │   │   └── UserStats
    │   └── MainContent
    │       ├── StatsRow
    │       │   ├── StatCard (×4)
    │       └── RecentActivity
    │           ├── ActivityItem (×10)
    └── Footer
    

    数据自上而下传递

    数据通过属性从父组件传递给子组件。组件无法直接访问兄弟组件或父组件的状态。正是这种单向数据流使得大型应用依然易于理解:

    App (has user, notifications, theme)
      │
      ├── Navbar (receives: user, notifications)
      │     │
      │     └── UserMenu (receives: user)
      │           │
      │           └── Avatar (receives: user.avatar, user.name)
      │
      └── Dashboard (receives: user, theme)
            │
            └── MainContent (receives: theme)
    

    Avatar只能看到UserMenu传递给它的内容,而UserMenu只能看到Navbar传递给它的内容。没有任何信息会向外或向上泄露,因此当某个值出错时,只需沿着父组件链向上查找即可。

    事件向上传递

    层级较深的组件无法直接修改其父组件的状态,但可以调用父组件传递下来的函数。状态由父组件掌控:

    // Parent owns the state and passes down both the value and the updater
    function App() {
      const [searchQuery, setSearchQuery] = useState("");
    

    父组件会将值和设置函数同时传递给子组件;搜索框会在输入内容发生变化时调用该设置函数:

      return (
        <div>
          <SearchBar
            query={searchQuery}
            onChange={setSearchQuery}   {/* passes the setter as a prop */}
          />
          <Results query={searchQuery} />
        </div>
      );
    }// Child receives the updater and calls it on user input
    function SearchBar({ query, onChange }) {
      return (
        <input
          value={query}
          onChange={e => onChange(e.target.value)}  {/* calls parent's setter */}
          placeholder="Search..."
        />
      );
    }
    

    查询仅存在于 App 中。SearchBar 不存储任何数据,它会通过 onChange 属性上报每次按键操作。Results 通过属性获取更新后的查询内容并重新渲染。数据以属性的形式向下传递,事件则通过函数调用向上发送。(开头标签内的解释性注释仅用于阅读;在真实的 JSX 中,大括号形式的注释应放在子元素中,而在标签内部则应使用普通的 /* */ 注释形式或直接省略。)

    导致 React 更新失效的错误

    直接修改状态值

    人们很容易产生直接修改状态对象后再将其传回的冲动:

    // WRONG — mutating state directly
    const [user, setUser] = useState({ name: "Priya", age: 28 });
    

    下面的第一个 birthday 示例修改了对象并将同一个引用传递给设置器,因此界面不会更新。第二个示例则使用展开运算符创建了新对象,React 会将其视为一次变化:

    function birthday() {
      user.age = 29;        // ← directly modifying the object
      setUser(user);        // ← same object reference — React sees no change
    }                       //   UI does NOT update// CORRECT — create a new object
    function birthday() {
      setUser({ ...user, age: user.age + 1 }); // ← new object, React detects change
    }
    

    React 是通过使用 Object.is 比较引用来判断状态是否发生变化的,而非检查内容。如果直接修改对象或数组并传回相同的引用,React 会认为没有发生任何变化,从而可能跳过渲染。

    数组也遵循同样的规则。向现有数组添加元素的操作会失败:

    // WRONG — mutating the array
    const [items, setItems] = useState([1, 2, 3]);
    items.push(4);       // ← modifies in place
    setItems(items);     // ← same reference — no re-render
    

    而展开为新数组的操作则可以正常工作:

    // CORRECT — create a new array
    setItems([...items, 4]);  // ← spread creates a new array
    

    混淆属性与状态

    一个简单的测试就能区分二者。如果某个值来自父组件,那它就是属性且为只读的;如果组件自己拥有该值并会随时间改变它,那它就是状态。将永远不会变化的值放入 useState 中,正是混淆的体现:

    // Wrong: using state for something that should be a prop
    function UserCard() {
      const [userName] = useState("Priya"); // ← why is this state? It never changes here
      return <p>{userName}</p>;
    }
    

    如果数据由父组件控制,就应将其作为属性接收:

    // Correct: static data the parent controls comes as a prop
    function UserCard({ name }) {
      return <p>{name}</p>;
    }
    

    存储本可计算出的值

    并非所有发生变化的内容都需要独立的状态。在列表旁保留计数会重复存储信息:

    // Wrong: storing derived data in state
    const [items, setItems] = useState([...]);
    const [itemCount, setItemCount] = useState(0); // ← why is this state?
    

    现在,对items的任何修改都需要同步更新itemCount,一旦遗漏某次更新,两者就会不一致。在渲染时计算该值可以从结构上确保二者保持同步:

    // Every time items changes, you have to remember to also update itemCount
    // And if you forget once, they're out of sync// Correct: derive it during render
    const [items, setItems] = useState([...]);
    const itemCount = items.length; // ← just a variable — always in sync
    

    功能过重的组件

    一个组件同时负责渲染侧边栏、表格、表单以及多个模态框,这样的设计难以理解、测试和复用。一个实用的准则是:如果无法用一句话概括某个组件的功能,那就说明它的职责过多。

    // Hard to maintain — does everything
    function UserDashboard() {
      // manages user state
      // fetches orders
      // handles filters
      // manages pagination
      // controls modal open/close
      // renders sidebar
      // renders order table
      // renders filter controls
      // renders pagination
      // renders modal
      return ( /* 200 lines of JSX */ );
    }
    

    按功能划分后,每个部分都会有明确的名称和职责:

    // Better — clear single responsibilities
    function UserDashboard() {
      return (
        <DashboardLayout>
          <OrderFilters />
          <OrderTable />
          <OrderPagination />
          <OrderDetailModal />
        </DashboardLayout>
      );
    }
    

    用组件构建应用

    在编写代码前先确定边界

    从设计开始,标记出各个组件。任何重复出现的元素都是组件;任何具有单一明确功能的元素也是组件。一起变化的元素应归为一组,独立变化的元素则应分开。对于产品列表页面,其设计结构如下:

    Looking at the design:
      [ Filter Bar                          ]
      [ Product Card ][ Product Card ][ Product Card ]
      [ Product Card ][ Product Card ][ Product Card ]
      [ Pagination                          ]
    

    可分解为以下树形结构:

    Components:
      ProductPage
      ├── FilterBar
      │   ├── FilterGroup (×3)
      │   └── SortDropdown
      ├── ProductGrid
      │   └── ProductCard (×N)
      └── Pagination
    

    将状态放在最低层的公共父组件中

    对于每个状态,找到树形结构中需要它的最低层组件,并将状态保存在该组件中:

    If only ProductCard needs the "expanded" state → put it in ProductCard
    If FilterBar and ProductGrid both need filters → put filters in ProductPage
    If Pagination and ProductGrid both need currentPage → put it in ProductPage
    

    当两个兄弟组件需要相同的数据时,将该状态移到它们最近的共享父组件中并向下传递。这被称为“向上提升状态”。将状态保持在尽可能低的位置,还能减少状态变化时需要重新渲染的树形部分范围。

    先构建静态版本,再添加状态

    从硬编码数据开始:无需使用 useState,也无需处理函数,仅通过属性驱动标记。确定布局后,找出哪些值会随时间变化,并仅为这些值添加状态。第一步是创建一个完全静态的卡片:

    // Step 1: static — no state, hardcoded data
    function ProductCard() {
      return (
        <div className="card">
          <img src="/headphones.jpg" alt="Headphones" />
          <h3>Wireless Headphones</h3>
          <p>₹2,499</p>
          <button>Add to Cart</button>
        </div>
      );
    }
    

    第二步用 product 属性替换硬编码的内容,第三步则为“已添加”确认信息添加少量状态,该状态会在两秒后自动重置:

    // Step 2: accept props — still no state
    function ProductCard({ product }) {
      return (
        <div className="card">
          <img src={product.image} alt={product.name} />
          <h3>{product.name}</h3>
          <p>₹{product.price.toLocaleString()}</p>
          <button>Add to Cart</button>
        </div>
      );
    }// Step 3: add state for interactivity
    function ProductCard({ product, onAddToCart }) {
      const [added, setAdded] = useState(false);  function handleAdd() {
        setAdded(true);
        onAddToCart(product.id);
        setTimeout(() => setAdded(false), 2000);
      }  return (
        <div className="card">
          <img src={product.image} alt={product.name} />
          <h3>{product.name}</h3>
          <p>₹{product.price.toLocaleString()}</p>
          <button onClick={handleAdd} disabled={added}>
            {added ? "Added ✓" : "Add to Cart"}
          </button>
        </div>
      );
    }
    

    由于结构与交互性是分离的,每一步都可以单独轻松验证。

    核心要点

    整个模型的简述。React存在的理由:

    Why React:
      Plain JS DOM manipulation is hard to scale and maintain
      React: describe the UI, let React handle DOM updates
    

    以及上述每个概念的要点,从JSX到常见错误:

    JSX:
      HTML-like syntax in JavaScript — compiled to React.createElement()
      {} embeds any JavaScript expression
      Use className (not class), onClick (not onclick)Components:
      Functions that return JSX
      Capital letter names (Button, not button)
      Reusable, composable building blocksProps:
      Data passed from parent to child (like function arguments)
      Read-only — a component never modifies its own props
      Can be strings, numbers, objects, arrays, functionsState:
      const [value, setValue] = useState(initialValue)
      Data owned by a component that changes over time
      Calling the setter triggers a re-render
      Each component instance has its own stateRe-rendering:
      Happens when state changes, props change, or parent re-renders
      React diffs the virtual DOM and updates only what changed
      Not expensive — surgical DOM updatesDeclarative:
      Describe what the UI should look like
      React figures out what changed and how to update the DOM
      UI = f(state, props) — predictable, testableData flow:
      Props flow down (parent → child)
      Events flow up (child calls parent's function)
      One-way data flow keeps the application predictableCommon mistakes:
      Mutating state directly (use spread / new objects)
      Storing derived values in state (compute them during render)
      Overloading one component (split by responsibility)
    
    • 组件即函数,属性是其参数,状态则是其内存。UI始终是当前状态的呈现。
    • 重新渲染在设计上成本很低;React帮您省去的DOM操作才是耗时部分。在担心渲染次数之前,先妥善设计状态结构。
    • 始终为设置函数提供新的对象和数组;React比较的是引用而非内容。
    • 将状态放在最需要它的组件中,在渲染时推导出其他所有内容,并通过回调属性让事件向上传递。

    上下文、效应、自定义钩子以及性能优化都是建立在这些理念之上的。只要牢固掌握组件、属性、状态和重新渲染,React的更高级功能就会成为您已理解模型的延伸,而非需要记忆的新规则。

    相关阅读

  • 从基础概念到异步数据:Redux Toolkit 的实用思维模型 — 通过计数器、购物车和产品列表,了解 Redux Toolkit 的 store、slices、Immer 驱动的 reducer、selector 以及 createAsyncThunk 如何协同工作。