React 组件进化论:从状态混乱到 UI = fn(props) 的三次重构

React 组件进化论:从状态混乱到 UI = fn(props) 的三次重构

同一个"修改用户名"功能,我写了三个版本。从把 event 对象丢给父组件,到子组件持有私有状态,再到状态完全提升------这三次重构的背后,是 React 单向数据流思想的层层递进。本文结合 TypeScript 类型约束、useEffect 生命周期、localStorage 持久化,带你彻底搞懂 React 组件设计的核心法则。


一、React + TypeScript:为什么企业级开发离不开类型约束?

React 本身就是用 TypeScript 写的,源码中内置了大量类型声明。在企业级开发中,TypeScript 提供的类型约束静态编译检查丰富的语言特性,让组件间的协作像签合同一样可靠。

1.1 React.FC:函数组件的"身份证"

typescript 复制代码
// React 源码中的类型声明
type FC<P = {}> = FunctionComponent<P>;

React.FC 是 React 函数组件的类型别名,P 是泛型参数,用来约束 props 的类型。不传时默认为 {},传了就用你定义的类型来约束。

1.2 interface vs type:组件 Props 该用哪个?

typescript 复制代码
// 方式一:interface
interface Props {
  userName: string;
}

// 方式二:type
type Props = {
  userName: string;
};

// 两者都可以,但 interface 更适合定义"对象需要满足的属性和方法"
// 组件的 props 本质上是一个接口契约,推荐使用 interface
const HelloComponent: React.FC<Props> = (props) => {
  return <h2>Hello {props.userName}</h2>;
};

选择建议:

特性 interface type
对象形状描述 ✅ 推荐 ✅ 可以
联合类型/交叉类型 ❌ 不支持 ✅ 支持
声明合并 ✅ 自动合并 ❌ 覆盖
组件 Props ✅ 推荐 ✅ 可以
自定义事件类型 ✅ 推荐 ✅ 可以

二、三次重构:一个"修改用户名"功能的进化史

我们要实现的功能很简单:一个 Hello 组件显示用户名,一个 NameEdit 组件修改用户名。但"状态放在哪里"这个问题,经历了三次演进。

2.1 V1 版本:把 event 对象传给父组件

csharp 复制代码
┌─────────────────────────────────────────┐
│              App(父组件)                │
│  ┌─────────────┐   ┌─────────────────┐  │
│  │   Hello      │   │  NameEdit       │  │
│  │  显示用户名   │   │  input + event  │  │
│  └─────────────┘   └────────┬────────┘  │
│                             │ event      │
│  state: username ◀──────────┘            │
└─────────────────────────────────────────┘

V1 的代码:

tsx 复制代码
// App.tsx (V1)
import * as React from 'react';
import Hello from './components/Hello';
import NameEditComponent from './components/NameEditComponent';

const App: React.FC = () => {
  const [username, setUserName] = React.useState("initialName");

  // 父组件直接接收 event 对象
  const setUsernameState = (event: React.ChangeEvent<HTMLInputElement>) => {
    setUserName(event.target.value);
  };

  return (
    <div>
      <Hello userName={username} />
      <NameEditComponent
        username={username}
        onChange={setUsernameState}
      />
    </div>
  );
};

export default App;
tsx 复制代码
// NameEditComponent.tsx (V1)
import * as React from 'react';

interface Props {
  username: string;
  onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
}

const NameEditComponent: React.FC<Props> = (props) => {
  return (
    <div>
      <label>Update name:</label>
      <input value={props.username} onChange={props.onChange} />
    </div>
  );
};

export default NameEditComponent;

V1 的问题:

  • 子组件把 event 对象直接传给父组件,父组件必须关心 ChangeEvent<HTMLInputElement> 类型
  • 父组件的职责被污染------本应只负责"持有状态和修改状态",现在还要处理 DOM 事件细节
  • 每输入一个字符,username 立即变化,Hello 组件也立即更新------没有"提交"概念

2.2 V2 版本:子组件持有私有状态

lua 复制代码
┌──────────────────────────────────────────────┐
│                App(父组件)                    │
│  state: username                              │
│  ┌─────────────┐   ┌─────────────────────┐   │
│  │   Hello      │   │  NameEdit           │   │
│  │  显示用户名   │   │  私有状态: editingName│   │
│  └─────────────┘   │  input 自己管 onChange │   │
│                     │  点击按钮才提交给父组件  │   │
│                     └──────────┬──────────┘   │
│                                │ newName:string │
│  setUserName(newName) ◀────────┘              │
└──────────────────────────────────────────────┘

V2 的代码:

tsx 复制代码
// App.tsx (V2)
import * as React from 'react';
import Hello from './components/Hello';
import NameEditComponent from './components/NameEditComponent';

const App: React.FC = () => {
  const [username, setUserName] = React.useState("initialName");

  return (
    <div>
      <Hello userName={username} />
      <NameEditComponent
        initialUserName={username}
        onNameUpdated={setUserName}
      />
    </div>
  );
};

export default App;
tsx 复制代码
// NameEditComponent.tsx (V2)
import * as React from 'react';

interface Props {
  initialUserName: string;
  onNameUpdated: (newName: string) => void;
}

const NameEditComponent: React.FC<Props> = (props) => {
  // 子组件持有私有状态
  const [editingName, setEditingName] = React.useState(props.initialUserName);

  const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setEditingName(e.target.value);  // 自己修改自己的状态
  };

  const onNameSubmit = () => {
    props.onNameUpdated(editingName);  // 提交时只传值,不传 event
  };

  return (
    <>
      <label>Update name:</label>
      <input value={editingName} onChange={onChange} />
      <button onClick={onNameSubmit}>Change</button>
    </>
  );
};

export default NameEditComponent;

V2 的改进:

  • 父组件不再需要处理 ChangeEvent,只接收 string 类型的值------接口更干净
  • 子组件引入"编辑中"概念:输入时不影响 Hello 组件,点击按钮才提交
  • ChangeEvent 的复杂性被封装在子组件内部

V2 的问题:

  • editingName 是子组件的私有状态,父组件无法感知和控制
  • 如果父组件的 username 变了(比如异步请求返回),子组件的 editingName 不会同步更新
  • 按钮没有禁用逻辑,用户可以提交空名字或未修改的名字

2.3 V3 版本:状态提升到父组件(最终版)

yaml 复制代码
┌──────────────────────────────────────────────────┐
│                  App(父组件)                      │
│  state: name          state: editingName          │
│  ┌─────────────┐   ┌─────────────────────────┐   │
│  │   Hello      │   │  NameEdit (无状态)       │   │
│  │  显示 name   │   │  props: editingName      │   │
│  └─────────────┘   │  props: onEditingName..  │   │
│                     │  props: onNameUpdated    │   │
│                     │  props: disabled          │   │
│                     └─────────────────────────┘   │
│  UI = fn(props)  子组件纯展示,零状态              │
└──────────────────────────────────────────────────┘

V3 的代码:

tsx 复制代码
// App.tsx (V3 - 最终版)
import * as React from 'react';
import HelloComponent from './components/Hello';
import NameEditComponent from './components/NameEditingComponent';

const App = () => {
  const [name, setName] = React.useState<string>("defaultUserName");
  // 编辑中的名字也由父组件持有
  const [editingName, setEditingName] = React.useState("defaultUserName");

  const loadUsername = () => {
    setTimeout(() => {
      const newName = "name from async call";
      setName(newName);
      setEditingName(newName);
    }, 2000);
  };

  // 副作用:组件挂载后异步加载数据
  React.useEffect(() => {
    loadUsername();
  }, []);

  const setUserNameState = () => {
    setName(editingName);
  };

  return (
    <>
      名字: {name}
      <HelloComponent userName={editingName} />
      <NameEditComponent
        editingName={editingName}
        onNameUpdated={setUserNameState}
        onEditingNameUpdated={setEditingName}
        disabled={editingName === "" || editingName === name}
      />
    </>
  );
};

export default App;
tsx 复制代码
// NameEditingComponent.tsx (V3 - 无状态子组件)
import * as React from 'react';

interface Props {
  editingName: string;
  onNameUpdated: () => void;
  onEditingNameUpdated: (newEditingName: string) => void;
  disabled: boolean;
}

const NameEditingComponent: React.FC<Props> = (props) => {
  const {
    editingName,
    onEditingNameUpdated,
    onNameUpdated,
    disabled
  } = props;

  const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    onEditingNameUpdated(e.target.value);
  };

  const onNameSubmit = () => {
    onNameUpdated();
  };

  return (
    <>
      <label>Update name:</label>
      <input
        value={editingName}
        onChange={onChange}
      />
      <button
        disabled={disabled}
        onClick={onNameSubmit}
      >Change</button>
    </>
  );
};

export default NameEditingComponent;

V3 的核心改进:

改进点 说明
状态完全提升 editingName 由父组件持有,子组件零状态
子组件纯展示 UI = fn(props),子组件职责单一,只负责渲染
disabled 逻辑 名字为空或未修改时禁用按钮,防无效提交
异步同步 loadUsername 同时更新 nameeditingName,子组件自动同步

三、三次重构的对比总结

3.1 架构对比

vbnet 复制代码
V1: event 穿透          V2: 子组件私有状态        V3: 状态完全提升
┌──────────┐            ┌──────────┐             ┌──────────┐
│  App     │            │  App     │             │  App     │
│  username│            │  username│             │  name    │
│           │            │           │             │  editing │
│  ┌─────┐ │            │  ┌─────┐ │             │  ┌─────┐ │
│  │Edit │ │            │  │Edit │ │             │  │Edit │ │
│  │     │ │            │  │editing│ │             │  │     │ │
│  │event│ │            │  │Name  │ │             │  │纯展示│ │
│  └──┬──┘ │            │  └──┬──┘ │             │  └─────┘ │
│     │event            │     │value             │  UI=fn() │
│  ◀──┘                 │  ◀──┘                 │           │
└──────────┘            └──────────┘             └──────────┘
 父组件处理DOM事件        子组件自管状态            父组件统管状态

3.2 核心差异对比

维度 V1 V2 V3
状态归属 父组件 父+子各有 全在父组件
事件传递 传 event 对象 传 string 值 传 string 值
子组件职责 透传 event 编辑+提交 纯展示
父组件复杂度 高(处理DOM) 中(多一个state)
异步同步 ❌ 不同步
按钮禁用
可测试性

3.3 为什么 V3 最好?

React 的核心公式:UI = fn(props)

diff 复制代码
组件函数 = 纯函数
输入: props(属性)
输出: ReactNode(UI)

纯函数特性:
- 相同输入,相同输出
- 无副作用
- 不依赖外部状态

V3 中子组件 NameEditingComponent 完全符合这个公式------给定相同的 props,渲染出相同的 UI,没有任何私有状态。这带来了三个好处:

  1. 可预测性:UI 完全由 props 决定,调试时只需检查 props
  2. 性能优化 :纯展示组件可以用 React.memo 轻松优化
  3. 可复用性:不依赖任何外部状态,可以在任何地方使用

四、useEffect:副作用与生命周期管理

4.1 useEffect 的四种用法

tsx 复制代码
// 用法一:只挂载后执行(依赖项为空数组)
useEffect(() => {
  console.log('只会在挂载后执行一次');
}, []);

// 用法二:挂载后 + 特定依赖更新后执行
useEffect(() => {
  console.log('挂载后执行,todos 更新后也会执行');
  localStorage.setItem('todos', JSON.stringify(todos));
}, [todos]);

// 用法三:挂载后 + 每次更新都执行(不传依赖项)
useEffect(() => {
  console.log('挂载后执行,每次更新都执行');
});

// 用法四:卸载前执行清理(返回一个函数)
useEffect(() => {
  const interval = setInterval(() => {
    console.log('interval is here');
  }, 1000);

  // 返回的函数在组件卸载前执行
  return () => {
    console.log('组件卸载前执行,清理定时器');
    clearInterval(interval);
  };
}, []);

4.2 依赖项与执行时机对照表

依赖项 挂载后 依赖更新后 每次更新 卸载前
[] ✅(如有return)
[state] ✅(state变化时) ✅(如有return)
不传 ✅(如有return)

4.3 清理函数:防止内存泄漏

tsx 复制代码
const Demo = () => {
  useEffect(() => {
    const interval = setInterval(() => {
      console.log('interval is here');
    }, 1000);

    // 如果不清理,组件卸载后定时器仍在运行
    // 这就是内存泄漏------这块内存永远无法回收
    return () => {
      console.log('组件卸载前执行,做内存清理工作');
      clearInterval(interval);
    };
  }, []);

  return (
    <>
      Demo
    </>
  );
};

// 父组件中条件渲染 Demo
const App = () => {
  const [count, setCount] = useState(0);

  return (
    <div>
      Count: {count}
      <button onClick={() => setCount(count + 1)}>count++</button>
      {count % 2 === 0 && <Demo />}  {/* 偶数显示,奇数卸载 */}
    </div>
  );
};

为什么必须清理?

javascript 复制代码
组件挂载 ──▶ setInterval 启动 ──▶ 每秒执行
                │
                ▼
组件卸载 ──▶ 如果不 clearInterval:
                │
                ▼
          定时器仍在运行,但组件已不存在
          ╳ 内存泄漏:这块内存永远无法回收
          ╳ 性能下降:无用的定时器持续消耗资源

五、localStorage:前端本地持久化

5.1 三种前端存储方案

方案 容量 类型 适用场景
localStorage ~5MB key-value 字符串 配置信息、关键数据
sessionStorage ~5MB key-value 字符串 会话级临时数据
IndexedDB 远大于5MB 类数据库结构化存储 大量数据、离线应用

5.2 在 React 中使用 localStorage

tsx 复制代码
const App = () => {
  // 惰性初始化:从 localStorage 读取已有数据
  const [todos, setTodos] = useState(() => {
    try {
      const stored = localStorage.getItem('todos');
      return stored ? JSON.parse(stored) : [];
    } catch (err) {
      // JSON.parse 可能失败(数据损坏),需兜底处理
      return [];
    }
  });

  // todos 变化时自动同步到 localStorage
  useEffect(() => {
    localStorage.setItem('todos', JSON.stringify(todos));
  }, [todos]);

  const addTodo = (text) => {
    if (text.trim() === '') return;
    setTodos([
      { id: Date.now(), text, completed: false },
      ...todos
    ]);
    // 注意:不需要在这里手动 setItem
    // useEffect 会自动同步
  };

  // ...其他操作方法
};

关键设计:

scss 复制代码
用户操作 ──▶ setTodos(新状态) ──▶ 组件重新渲染
                                        │
                                        ▼
                              useEffect([todos]) 触发
                                        │
                                        ▼
                              localStorage.setItem(...)
                                        │
                                        ▼
                              数据持久化到本地

为什么不在 addTodo 里手动 setItem

tsx 复制代码
// ❌ 错误做法:手动同步
const addTodo = (text) => {
  setTodos([...todos, { id: Date.now(), text, completed: false }]);
  localStorage.setItem('todos', JSON.stringify(todos)); // 这里 todos 还是旧值!
};

// ✅ 正确做法:用 useEffect 自动同步
useEffect(() => {
  localStorage.setItem('todos', JSON.stringify(todos));
}, [todos]);

setTodos 是异步的,调用后 todos 变量在当前执行上下文中仍是旧值。手动 setItem 会存入旧数据。而 useEffect 在渲染完成后执行,此时 todos 已经是最新的。


六、TypeScript 在组件通信中的类型约束

6.1 自定义事件的类型声明

tsx 复制代码
interface Props {
  // 普通属性
  editingName: string;
  disabled: boolean;

  // 自定义事件:只传值
  onNameUpdated: () => void;
  onEditingNameUpdated: (newEditingName: string) => void;
}

6.2 React 合成事件类型

tsx 复制代码
interface Props {
  // 如果必须传 event,用 React 的合成事件类型
  onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
}

// React.ChangeEvent 是泛型,T 指定事件发生的元素类型
// <HTMLInputElement>  → input 元素的 change 事件
// <HTMLSelectElement>  → select 元素的 change 事件
// <HTMLTextAreaElement> → textarea 元素的 change 事件

6.3 设计原则:事件接口应该传什么?

tsx 复制代码
// ❌ V1 的做法:传 event 对象
interface Props {
  onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
}
// 问题:父组件被迫关心 DOM 事件细节

// ✅ V2/V3 的做法:只传值
interface Props {
  onNameUpdated: (newName: string) => void;
}
// 优势:父组件只需处理业务逻辑,不耦合 DOM

核心原则:子组件负责处理 DOM 事件,父组件只负责处理业务数据。


七、完整实战:TodoList + useEffect + localStorage

将前面所有知识点融合,实现一个带本地持久化的 TodoList:

tsx 复制代码
import { useState, useEffect } from 'react';
import TodoInput from "./components/TodoInput";
import TodoList from "./components/TodoList";
import TodoStats from "./components/TodoStats";
import './App.css';

// 演示 useEffect 卸载清理
const Demo = () => {
  useEffect(() => {
    const interval = setInterval(() => {
      console.log('interval is here');
    }, 1000);
    return () => {
      console.log('组件卸载前执行,做内存清理工作');
      clearInterval(interval);
    };
  }, []);

  return <><Demo /></>;
};

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

  // 惰性初始化:从 localStorage 读取
  const [todos, setTodos] = useState(() => {
    try {
      const stored = localStorage.getItem('todos');
      return stored ? JSON.parse(stored) : [];
    } catch (err) {
      return [];
    }
  });

  // 只在 count 变化时执行
  useEffect(() => {
    console.log('count 改变也会执行');
  }, [count]);

  // 每次更新都执行(不推荐,仅演示)
  useEffect(() => {
    console.log('每次更新都执行');
  });

  // 只在挂载后执行一次
  useEffect(() => {
    console.log('只会在挂载后执行');
  }, []);

  // todos 变化时同步到 localStorage
  useEffect(() => {
    localStorage.setItem('todos', JSON.stringify(todos));
  }, [todos]);

  const countBy = () => {
    setCount(count + 1);
  };

  const addTodo = (text) => {
    if (text.trim() === '') return;
    setTodos([
      { id: Date.now(), text, completed: false },
      ...todos
    ]);
  };

  const toggleTodo = (id) => {
    setTodos(todos.map(todo =>
      todo.id === id
        ? { ...todo, completed: !todo.completed }
        : todo
    ));
  };

  const deleteTodo = (id) => {
    setTodos(todos.filter(todo => todo.id !== id));
  };

  const clearCompleted = () => {
    setTodos(todos.filter(todo => !todo.completed));
  };

  const activeCount = todos.filter(t => !t.completed).length;
  const completedCount = todos.length - activeCount;

  return (
    <div>
      Count: {count}
      <button onClick={countBy}>count++</button>
      {count % 2 === 0 && <Demo />}
      <h1>My Todo List</h1>
      <TodoInput onAdd={addTodo} />
      <TodoList
        todos={todos}
        onToggle={toggleTodo}
        onDelete={deleteTodo}
      />
      <TodoStats
        total={todos.length}
        active={activeCount}
        completed={completedCount}
        onClearCompleted={clearCompleted}
      />
    </div>
  );
};

export default App;

7.1 组件通信全景图

scss 复制代码
                    ┌─────────────┐
                    │    App       │
                    │  (状态中心)   │
                    │  todos state │
                    └──────┬──────┘
                           │
           ┌───────────────┼───────────────┐
           │               │               │
           ▼               ▼               ▼
    ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
    │  TodoInput  │ │  TodoList   │ │  TodoStats  │
    │             │ │             │ │             │
    │ props:      │ │ props:      │ │ props:      │
    │  onAdd()    │ │  todos      │ │  total      │
    │             │ │  onToggle() │ │  active     │
    │ 子组件私有   │ │  onDelete() │ │  completed  │
    │ inputValue  │ │             │ │  onClear()  │
    │ state       │ │ 纯展示      │ │             │
    └─────────────┘ └─────────────┘ └─────────────┘

通信法则:

  • 父组件通过 props 向下传递数据
  • 子组件通过自定义事件向上通知
  • 共享状态放在父组件,私有状态放子组件
  • 数据流向始终单向:父 → 子

八、总结

8.1 知识体系图

kotlin 复制代码
React + TypeScript 企业级开发
├── 类型约束
│   ├── React.FC<P>          函数组件类型
│   ├── interface Props      组件接口契约
│   └── ChangeEvent<T>       合成事件类型
├── 组件通信
│   ├── 单向数据流             父→子 传 props
│   ├── 自定义事件             子→父 传值
│   └── 状态提升               共享状态放父组件
├── useEffect
│   ├── []  挂载后执行         异步请求、初始化
│   ├── [dep] 依赖更新执行      数据同步
│   ├── 不传  每次更新执行      谨慎使用
│   └── return  卸载前清理     防内存泄漏
└── 本地存储
    ├── useState 惰性初始化    从 localStorage 读取
    ├── useEffect 自动同步      状态变化时写入
    └── try-catch 兜底          JSON.parse 防错

8.2 组件设计三原则

原则 说明 对应版本
单一职责 每个组件只做一件事 V3 子组件纯展示
状态提升 共享状态放在最近的共同父组件 V3 父组件持有
UI = fn(props) 纯函数组件,相同 props 相同 UI V3 最终形态

8.3 三次重构的本质

makefile 复制代码
V1 → V2 → V3 的本质是:状态归属的不断优化

V1: 状态在父,但事件处理耦合到父  →  职责不清
V2: 状态分散在父子两处           →  同步困难
V3: 状态全部在父,子组件纯展示    →  UI = fn(props)

React 的设计哲学就藏在这三次重构里:把状态放到合适的位置,让数据单向流动,让组件成为纯函数。


如果这篇文章对你有帮助,欢迎点赞收藏

相关推荐
无人生还1 小时前
从 Vue3 到 React · 快速上手系列第 10 篇:路由
前端·vue.js·react.js
deepThinking1 小时前
从项目里回看 React 生态:React 18/19 新特性、我们踩过的坑,以及那些"用旧写法写出的新思想
react.js
FogLetter1 小时前
我真的写了个“诈尸式”缓存组件:手撕React KeepAlive
前端·react.js·面试
LaughingZhu3 小时前
Product Hunt 每日热榜 | 2026-08-02
前端·神经网络·react.js·搜索引擎·前端框架
无人生还1 天前
从 Vue3 到 React · 快速上手系列第 9 篇:自定义 Hook(对标 Composables)
前端·vue.js·react.js
张元清1 天前
React useInfiniteScroll Hook:无限滚动轻松实现(2026)
javascript·react.js
浮生望1 天前
React组件化实战:从Todo应用洞悉useState状态管理与组件通信
react.js
先吃饱再说2 天前
编写一个颜色选择器之后,我理解了 React 工程化的三层架构
react.js·前端框架·前端工程化
先吃饱再说2 天前
别说你懂 useEffect:从底层机制到生命周期管理,这篇全讲透了
react.js·前端框架