React 核心原理与虚拟 DOM

中等 🟡React 生态
6 个标签
预计阅读时间:75 分钟
React虚拟 DOM组件状态管理Diff单向数据流

React 核心原理与虚拟 DOM

React 是一个用于构建用户界面的 JavaScript 库,其核心原理包括虚拟 DOM、组件化和单向数据流。理解这三块基石,就能理解 React 为什么这样设计、以及大部分性能问题和 bug 的根源。

一句话概括 React 的心智模型:UI = f(state)。界面是状态的函数——你不再手动去"操作 DOM 让它变成什么样",而是"描述在某个状态下界面应该长什么样",剩下的对比与更新交给 React。这种从"命令式"到"声明式"的转变,是 React 最核心的思想。

命令式 vs 声明式

传统 jQuery 时代是命令式:你要亲自找到元素、亲自改它的文本、亲自增删节点,一步步告诉浏览器"怎么做"。当交互一复杂,这些 DOM 操作散落各处、相互纠缠,极易出 bug。React 是声明式:你只描述"结果应该是什么",状态一变,React 自动算出差异并更新。

javascriptCode
// 命令式(jQuery 风格):手把手指挥每一步
const btn = document.querySelector('#btn');
let count = 0;
btn.addEventListener('click', () => {
  count++;
  document.querySelector('#label').textContent = 'Count: ' + count; // 手动更新 DOM
});

// 声明式(React):只描述"状态是什么,界面就是什么"
function Counter() {
  const [count, setCount] = React.useState(0);
  return (
    <>
      <span id="label">Count: {count}</span>
      <button onClick={() => setCount(count + 1)}>+1</button>
    </>
  );
}

⚛️ 虚拟 DOM 原理

虚拟 DOM 是 React 性能优化的关键。它是一个轻量级的 JavaScript 对象,用来描述真实 DOM 的结构。当组件状态变化时,React 会先在内存里构建新的虚拟 DOM,然后通过 diff 算法计算出与旧虚拟 DOM 的最小差异,最后把这些差异批量应用到真实 DOM 上。

为什么要多一层虚拟 DOM?因为直接操作真实 DOM 很昂贵。真实 DOM 节点是重量级对象,一次操作可能触发浏览器的样式重算、回流(reflow)、重绘(repaint)。频繁、零散地操作真实 DOM 会造成大量无谓的回流。虚拟 DOM 就像一张"设计草图":先在草图上把所有改动画好、对比出真正变化的部分,再一次性照着草图去改真实的墙面,避免反复涂改。

需要澄清一个常见误解:虚拟 DOM 不一定比手写的精细命令式操作更快,它换来的是"可维护性 + 足够好的性能"。React 团队自己也说过,虚拟 DOM 的价值在于让你"无需手动优化也能获得可接受的性能",而不是"绝对最快"。

虚拟 DOM 的优势:

减少直接操作 DOM 的次数,把多次零散更新合并为一次批量更新
提供跨平台能力(同一套虚拟 DOM 可渲染到 Web、React Native、Canvas 等)
简化状态管理和组件更新逻辑(开发者只关心状态,不关心 DOM 操作细节)

💻 代码示例:虚拟 DOM 对比

javascriptCode
// 真实 DOM 操作:命令式,逐步指挥
const element = document.createElement('div');
element.className = 'container';
element.textContent = 'Hello, World!';
document.body.appendChild(element);

// 虚拟 DOM(React.createElement 返回一个描述对象)
const element = React.createElement(
  'div',
  { className: 'container' },
  'Hello, World!'
);
// element 实际上就是一个普通对象,大致长这样:
// { type: 'div', props: { className: 'container', children: 'Hello, World!' } }

// JSX 语法(本质上会被 Babel 编译为 React.createElement 调用)
const element = (
  <div className="container">
    Hello, World!
  </div>
);

🔄 Diff 算法:三条启发式规则

完整比较两棵树的传统算法复杂度是 O(n³),对上千节点的界面完全不可用。React 通过三条启发式(heuristic)假设把复杂度降到 O(n):

1.不同类型的元素产生不同的树:如果元素类型变了(div 变成 span,或组件 A 变成组件 B),React 直接销毁旧树、重建新树,不再深入比较。
2.同层比较,不跨层移动:React 只在同一层级做对比,不会尝试把一个节点从深处移动到别处。
3.用 key 标识列表项的稳定身份:列表中通过 key 判断哪些项是"同一个",从而复用、移动而非重建。
javascriptCode
// key 的重要性:没有 key(或用 index 当 key)在列表头部插入会出问题
// 假设列表从 [B, C] 变为 [A, B, C]

// 用 index 当 key(反模式):React 认为 0 号位从 B 变成了 A、1 号位从 C 变成了 B...
// 结果几乎每个节点都被"更新",还可能导致输入框内容错位
{items.map((item, index) => <Row key={index} data={item} />)}

// 用稳定唯一 id 当 key(正确):React 识别出 A 是新增,B/C 原样复用
{items.map((item) => <Row key={item.id} data={item} />)}

🔄 React 渲染流程示例

javascriptCode
// 1. 初始渲染
function App() {
  const [count, setCount] = React.useState(0);

  return (
    <div>
      <h1>Count: {count}</h1>
      <button onClick={() => setCount(count + 1)}>
        Increment
      </button>
    </div>
  );
}

// 2. 状态变化:用户点击按钮,setCount(count + 1) 被调用,触发重新渲染
// 3. 创建新的虚拟 DOM:React 用新状态再次调用 App,得到新的虚拟 DOM 树
// 4. Diff 算法:React 比较新旧虚拟 DOM 树,发现只有 <h1> 里的文本变了
// 5. 最小化 DOM 操作:React 只更新那个文本节点,不碰 <button> 和 <div>
// 6. 批量更新:同一事件里的多次 setState 会被合并为一次渲染与一次 DOM 提交

🧩 组件化思想

React 的组件化思想让代码更加模块化和可维护,就像用乐高积木搭建复杂结构——每块积木(组件)独立、可复用、可组合:

组件是独立的、可复用的代码单元
每个组件有自己的状态(state)和属性(props)
组件通过 props 传递数据(对外接口),通过 state 管理内部状态

组件设计的黄金原则是单一职责:一个组件只做一件事。区分两类组件很有帮助——"展示型组件"只负责渲染 UI、不含业务逻辑(易复用、易测试);"容器型组件"负责取数据、管状态,把数据喂给展示型组件。

💻 代码示例:组件化实践

javascriptCode
// 函数组件(现代 React 首选)
function Welcome(props) {
  return <h1>Hello, {props.name}!</h1>;
}

// 使用组件:像 HTML 标签一样复用
function App() {
  return (
    <div>
      <Welcome name="Alice" />
      <Welcome name="Bob" />
    </div>
  );
}

// 类组件(旧写法,理解遗留代码时需要)
class WelcomeClass extends React.Component {
  render() {
    return <h1>Hello, {this.props.name}!</h1>;
  }
}

// 组合优于继承:用 props(包括 children)灵活拼装
function Card({ title, content, footer }) {
  return (
    <div className="card">
      <div className="card-header">{title}</div>
      <div className="card-body">{content}</div>
      <div className="card-footer">{footer}</div>
    </div>
  );
}

function CardExample() {
  return (
    <Card
      title="Card Title"
      content={<p>Card content goes here.</p>}
      footer={<button>Action</button>}
    />
  );
}
javascriptCode
// 展示型组件 vs 容器型组件(关注点分离)
// 展示型:纯 UI,无业务逻辑,易复用易测试
function UserList({ users, loading }) {
  if (loading) return <Spinner />;
  return <ul>{users.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}

// 容器型:负责取数据、管状态,把结果传给展示型
function UserListContainer() {
  const [users, setUsers] = React.useState([]);
  const [loading, setLoading] = React.useState(true);
  React.useEffect(() => {
    fetch('/api/users')
      .then(r => r.json())
      .then(data => { setUsers(data); setLoading(false); });
  }, []);
  return <UserList users={users} loading={loading} />;
}

单向数据流

React 采用单向数据流,数据从父组件流向子组件,像水流只能从高处(父)流向低处(子):

父组件通过 props 向子组件传递数据(数据向下流)
子组件通过调用父组件传下来的回调函数与父组件通信(事件向上传)
状态变化路径清晰、可预测、易调试

单向数据流的好处是"数据的来源永远可追溯"。当界面出现异常,你总能顺着 props 往上找到数据的源头,而不像双向绑定那样数据可能被多处同时修改、难以定位。

💻 代码示例:单向数据流与状态提升

javascriptCode
// 父组件持有状态,通过 props 下发数据和回调
function Parent() {
  const [count, setCount] = React.useState(0);
  const increment = () => setCount(prev => prev + 1);

  return (
    <div>
      <h1>Parent Count: {count}</h1>
      <Child count={count} onIncrement={increment} />
    </div>
  );
}

// 子组件:数据来自 props,通过回调"通知"父组件
function Child({ count, onIncrement }) {
  return (
    <div>
      <h2>Child Count: {count}</h2>
      <button onClick={onIncrement}>Increment</button>
    </div>
  );
}

// 状态提升:多个子组件需要共享同一份状态时,把状态提到最近的共同父组件
function Form() {
  const [value, setValue] = React.useState('');
  return (
    <div>
      <Input value={value} onChange={setValue} />
      <Display value={value} />
    </div>
  );
}

function Input({ value, onChange }) {
  return <input value={value} onChange={(e) => onChange(e.target.value)} />;
}

function Display({ value }) {
  return <p>Value: {value}</p>;
}

当"状态提升"导致 props 需要层层透传(prop drilling)时,就该考虑 Context 或状态管理库了。

javascriptCode
// 用 Context 解决跨层级传递(避免 prop drilling)
const ThemeContext = React.createContext('light');

function App() {
  return (
    <ThemeContext.Provider value="dark">
      <Toolbar />
    </ThemeContext.Provider>
  );
}

function Toolbar() {
  return <ThemedButton />; // 中间层无需关心 theme
}

function ThemedButton() {
  const theme = React.useContext(ThemeContext); // 直接消费,跨越中间层
  return <button className={theme}>Click</button>;
}

生命周期

React 组件的生命周期包括挂载、更新和卸载三个阶段:

挂载阶段:constructor → render → componentDidMount。constructor 是组件创建时调用的构造函数,用于初始化 state 和绑定方法;render 方法负责返回 JSX 描述的 UI 结构;componentDidMount 在组件挂载到 DOM 后立即调用,是进行 DOM 操作、网络请求、订阅事件等副作用操作的最佳时机。挂载阶段只执行一次,是组件生命周期的起点。
更新阶段:shouldComponentUpdate → render → componentDidUpdate。更新在 props 或 state 变化时触发;shouldComponentUpdate 用于决定是否需要重新渲染,返回 false 可跳过渲染以优化性能;componentDidUpdate 在组件更新后调用,可访问更新前的 props 和 state,是根据变化再次发请求等副作用的时机。更新阶段可能执行多次。
卸载阶段:componentWillUnmount。在组件从 DOM 中移除前调用,是清理操作的最佳时机,如清除定时器、取消网络请求、移除事件监听器、取消订阅等。清理不当会导致内存泄漏。

在函数组件中,这三个阶段统一由 useEffect 表达:依赖数组为空时相当于 componentDidMount + componentWillUnmount;有依赖时相当于 componentDidUpdate 的条件更新;返回的清理函数则对应卸载与下一次执行前的清理。

💻 代码示例:类组件生命周期

javascriptCode
class LifecycleComponent extends React.Component {
  constructor(props) {
    super(props);
    this.state = { count: 0 };
    console.log('Constructor');
  }

  componentDidMount() {
    console.log('Component did mount');
    this.fetchData(); // 发起网络请求
    this.timer = setInterval(() => { // 设置定时器
      this.setState({ count: this.state.count + 1 });
    }, 1000);
  }

  componentDidUpdate(prevProps, prevState) {
    console.log('Component did update');
    if (prevProps.id !== this.props.id) { // 依赖变化时重新取数
      this.fetchData();
    }
  }

  componentWillUnmount() {
    console.log('Component will unmount');
    clearInterval(this.timer); // 清理定时器
    if (this.abortController) this.abortController.abort(); // 取消请求
  }

  fetchData() {
    this.abortController = new AbortController();
    fetch(`/api/data/${this.props.id}`, { signal: this.abortController.signal })
      .then(response => response.json())
      .then(data => this.setState({ data }))
      .catch(error => console.error('Error:', error));
  }

  render() {
    return (
      <div>
        <h1>Count: {this.state.count}</h1>
        <p>Props: {this.props.id}</p>
      </div>
    );
  }
}

💻 代码示例:Hooks 生命周期

javascriptCode
function LifecycleComponentHooks({ id }) {
  const [count, setCount] = React.useState(0);
  const [data, setData] = React.useState(null);

  // 对应 componentDidMount + componentDidUpdate(依赖 id) + componentWillUnmount
  React.useEffect(() => {
    console.log('Effect ran');
    const abortController = new AbortController();
    fetch(`/api/data/${id}`, { signal: abortController.signal })
      .then(response => response.json())
      .then(data => setData(data))
      .catch(error => console.error('Error:', error));

    return () => { // 清理函数:卸载或 id 变化前执行
      console.log('Cleanup');
      abortController.abort();
    };
  }, [id]);

  // 只在挂载时执行一次(空依赖数组)
  React.useEffect(() => {
    const timer = setInterval(() => setCount(prev => prev + 1), 1000);
    return () => clearInterval(timer);
  }, []);

  return (
    <div>
      <h1>Count: {count}</h1>
      <p>Props: {id}</p>
      {data && <pre>{JSON.stringify(data, null, 2)}</pre>}
    </div>
  );
}

真实案例:从直接操作 DOM 到声明式重构

某仪表盘页面最初用原生 DOM 操作实时更新几十个指标卡片:每秒轮询数据后,代码手动 querySelector 找到每个卡片、逐个改文本和颜色。随着卡片增多,出现两个问题:一是每次全量更新导致频繁回流、页面掉帧;二是 DOM 操作逻辑散落各处,改需求时经常漏改、错位。

用 React 重构后,数据存于状态,界面由 UI = f(state) 声明式描述。React 的 diff 只更新真正变化的卡片文本节点,回流大幅减少;代码从"几十处零散 DOM 操作"收敛为"一份状态 + 一段渲染描述",可维护性显著提升。这正体现了虚拟 DOM 的核心价值:不是绝对最快,而是让你以极低的心智成本获得足够好的性能与可维护性。

性能与对比数据

| 对比项 | 直接操作真实 DOM | 虚拟 DOM(React) |

| --- | --- | --- |

| 编程范式 | 命令式,手动指挥每一步 | 声明式,描述结果 |

| 更新粒度 | 易全量更新、易漏改 | diff 后最小化更新 |

| 回流/重绘 | 频繁、零散触发 | 批量合并,减少触发 |

| 可维护性 | 逻辑分散,难维护 | 状态集中,易维护 |

| 跨平台 | 仅限浏览器 DOM | Web / Native / Canvas 等 |

| 极限性能 | 精细手写可能更快 | 足够好,无需手动优化 |

常见坑

用数组 index 作为列表 key:列表增删或排序时会导致复用错乱、输入框内容错位。用稳定唯一 id。
直接修改 state:`state.list.push(x)` 不会触发渲染,必须用不可变方式产生新引用。
在 render 里创建新对象/函数:每次渲染都是新引用,会破坏 React.memo、useMemo 的缓存效果。
忘记清理副作用:定时器、订阅、事件监听不清理会内存泄漏并引发"卸载后 setState"警告。
把所有状态都放顶层:过度状态提升导致大范围重渲染,应就近管理、按需提升。
误以为虚拟 DOM 永远最快:它是可维护性与性能的折中,不是银弹。

最佳实践

优先使用函数组件和 Hooks,代码更简洁、逻辑更聚合。
合理拆分组件,遵循单一职责,区分展示型与容器型。
列表渲染务必使用稳定唯一的 key。
用不可变方式更新 state,保持数据可预测。
用 React.memo、useMemo、useCallback 有针对性地优化(先测量再优化,避免过早优化)。
避免在渲染中创建新对象和函数破坏缓存。
跨层级共享状态用 Context 或状态库,避免 prop drilling。

深入 JSX 与 React.createElement

JSX 不是 HTML,也不是字符串模板,而是 JavaScript 的语法扩展。浏览器并不认识 JSX,它必须先被 Babel(或 TypeScript、SWC)编译成普通的 JavaScript 函数调用后才能运行。理解这层编译关系,是理解 React 一切行为的起点。

JSX 到底编译成什么

在 React 17 之前的"经典 transform"下,每一段 JSX 都会被编译为 `React.createElement(...)` 调用。这就是为什么早期每个使用 JSX 的文件都必须 `import React from 'react'`——即使代码里没直接写 React,编译产物里也有 `React.createElement`。

tsxCode
// 你写的 JSX
const el = (
  <div className="box" id="main">
    <h1>标题</h1>
    <p>正文 {name}</p>
  </div>
);

// 经典 transform 编译后(React 17 之前)
const el = React.createElement(
  'div',
  { className: 'box', id: 'main' },
  React.createElement('h1', null, '标题'),
  React.createElement('p', null, '正文 ', name)
);

新的 JSX Transform(React 17+)

React 17 推出了新的 JSX transform:编译器不再生成 `React.createElement`,而是从 `react/jsx-runtime` 自动引入 `jsx` / `jsxs` 函数并调用它们。带来的直接好处是:使用 JSX 的文件不再需要手动 import React(编译器会自动注入所需引入),同时产物略有性能与体积优化。

tsxCode
// 新 transform 编译后(React 17+,简化示意)
import { jsx as _jsx, jsxs as _jsxs } from 'react/jsx-runtime';

const el = _jsxs('div', {
  className: 'box',
  id: 'main',
  children: [
    _jsx('h1', { children: '标题' }),
    _jsxs('p', { children: ['正文 ', name] }),
  ],
});
// jsx 用于单个子节点,jsxs 用于多个子节点(数组),key 被单独提取为第三参数

JSX 的几条语法规则

必须有单一根节点:多个平级节点要用 `
` 或 Fragment `<>...` 包裹,因为函数只能返回一个值。
属性用驼峰命名:`class` 写成 `className`,`for` 写成 `htmlFor`,`onclick` 写成 `onClick`。
表达式用花括号:`{expression}` 内嵌任意 JS 表达式,但不能放语句(if/for)。
组件名首字母大写:小写标签被当作原生 DOM 元素(字符串 type),大写才被当作组件(引用 type)。
tsxCode
// Fragment:不产生额外 DOM 节点地包裹多个子节点
function List() {
  return (
    <>
      <li>A</li>
      <li>B</li>
    </>
  );
}

虚拟 DOM 的结构与手写 createElement

React 元素(虚拟 DOM 节点)本质是一个不可变的普通 JS 对象,描述"屏幕上应该出现什么"。它绝不是真实 DOM,创建它极其廉价。

javascriptCode
// 一个 React 元素大致长这样
const element = {
  $$typeof: Symbol.for('react.element'), // 标记它是合法 React 元素,防 XSS
  type: 'div',                            // 字符串=原生标签;函数/类=组件
  key: null,                              // 列表 diff 用的身份标识
  ref: null,                              // 引用真实 DOM 或组件实例
  props: {                                // 属性与 children
    className: 'container',
    children: 'Hello, World!',
  },
};

手写一个极简 createElement 和 render

下面用不到 40 行代码,实现虚拟 DOM 的核心:用 `createElement` 生成描述对象,用 `render` 把描述对象变成真实 DOM。这能彻底揭开虚拟 DOM 的神秘面纱。

javascriptCode
// 1. 手写 createElement:返回描述对象
function createElement(type, props, ...children) {
  return {
    type,
    props: {
      ...props,
      children: children.map(child =>
        typeof child === 'object' ? child : createTextElement(child)
      ),
    },
  };
}

function createTextElement(text) {
  return {
    type: 'TEXT_ELEMENT',
    props: { nodeValue: text, children: [] },
  };
}

// 2. 手写 render:把虚拟 DOM 变成真实 DOM
function render(element, container) {
  const dom =
    element.type === 'TEXT_ELEMENT'
      ? document.createTextNode('')
      : document.createElement(element.type);

  // 把非 children 的 props 赋到真实 DOM 上
  Object.keys(element.props)
    .filter(key => key !== 'children')
    .forEach(name => {
      dom[name] = element.props[name];
    });

  // 递归渲染子节点
  element.props.children.forEach(child => render(child, dom));
  container.appendChild(dom);
}

// 3. 使用:与 React 心智完全一致
const vdom = createElement('div', { id: 'app' },
  createElement('h1', null, 'Hello'),
  createElement('p', null, '手写虚拟 DOM')
);
render(vdom, document.getElementById('root'));

这段代码说明了两件事:虚拟 DOM 就是普通对象;render 就是"遍历对象、造真实节点"。真正的 React 在此之上加了 diff、Fiber 调度、事件系统等,但内核思路一致。

Diff 算法三大策略详解

传统的两棵树完全比较算法是 O(n³)(n 为节点数),1000 个节点就是十亿级操作,不可用。React 基于两条现实假设做启发式,把复杂度降到 O(n)

两个不同类型的元素会产生不同的树;
开发者可以用 key 来暗示哪些子元素在不同渲染中保持稳定。

基于这两条,React 的 diff(协调)落实为三个层面的比较策略。

策略一:Tree Diff(逐层比较)

React 只做同层级比较,不跨层级移动节点。它按层级从上到下遍历,同层节点两两对比。如果一个节点在新树里"跨层移动"了,React 不会去追踪这个移动,而是删除旧位置的整棵子树、在新位置重建

textCode
旧树              新树
  A                 A
 / \                 \
B   C                 C
                     /
                    B
// B 从 A 的直接子节点变成了 C 的子节点(跨层移动)
// React 不会"移动 B",而是:删除旧 B 子树 → 在 C 下新建 B

启示:不要做会导致 DOM 节点跨层级移动的结构变化(如条件性地整体升降一层包裹),代价是整棵子树重建。

策略二:Component Diff(组件级比较)

同类型组件:继续比较其虚拟 DOM 子树(进入 element diff)。
不同类型组件:直接判定整棵子树不同,销毁旧组件(触发卸载/清理)、创建新组件(触发挂载),不再深入比较。
tsxCode
// 类型从 ArticleList 变成 Grid,React 直接卸载前者、挂载后者,不复用内部 DOM
{layout === 'list' ? <ArticleList data={data} /> : <Grid data={data} />}

这也解释了一个坑:不要在渲染函数内部再定义组件。每次渲染都会生成一个"新类型"的组件函数,React 认为类型变了,于是整棵子树反复卸载重建,状态丢失且性能极差。

tsxCode
// 反模式:每次 Parent 渲染,Inner 都是"新类型",导致 Inner 状态每次被重置
function Parent() {
  function Inner() { return <input />; }  // 别这么写!
  return <Inner />;
}

// 正确:把组件定义提到外层,类型稳定
function Inner() { return <input />; }
function Parent() {
  return <Inner />;
}

策略三:Element Diff(同层子节点比较)与 key

对同一层级的一组子节点,React 有三种操作:插入(INSERT)、移动(MOVE)、删除(REMOVE)。没有 key 时,React 按位置索引逐个对比;有 key 时,按 key 匹配身份,从而识别出"同一个节点只是换了位置",用移动代替删除重建。

textCode
// 列表从 [A, B, C, D] 变为 [D, A, B, C](把 D 移到最前)

// 无 key(按位置比较):
//   位置0: A→D 更新,位置1: B→A 更新,位置2: C→B 更新,位置3: D→C 更新
//   结果:4 个节点全部更新,开销大

// 有稳定 key:
//   React 识别出还是 A B C D 这四个,只是 D 移动到了最前
//   结果:一次移动操作,其余复用,开销小

key 用 index 的错误案例对比

tsxCode
// 场景:可勾选的待办列表,在头部插入一项
// 用 index 当 key —— 灾难
function BadList() {
  const [todos, setTodos] = React.useState([
    { id: 'a', text: '任务A' },
    { id: 'b', text: '任务B' },
  ]);
  const prepend = () =>
    setTodos([{ id: 'c', text: '任务C' }, ...todos]);
  return (
    <>
      <button onClick={prepend}>头部插入</button>
      {todos.map((t, index) => (
        // key=index:插入后 index=0 的位置从"任务A"变成"任务C"
        // React 认为 key=0 只是内容更新,把已勾选状态、input 焦点留在了原位置
        <label key={index}>
          <input type="checkbox" /> {t.text}
        </label>
      ))}
    </>
  );
}

// 用稳定 id 当 key —— 正确
function GoodList() {
  /* ...同上... */
  return (
    <>
      {/* ...button... */}
      {todos.map(t => (
        // key=t.id:React 知道"任务C"是新增,A/B 原样复用,勾选与焦点不错位
        <label key={t.id}>
          <input type="checkbox" /> {t.text}
        </label>
      ))}
    </>
  );
}

| diff 策略 | 比较对象 | 核心规则 | 典型陷阱 |

| --- | --- | --- | --- |

| Tree Diff | 层级 | 只同层比较,不跨层移动 | 跨层结构变化导致整树重建 |

| Component Diff | 组件类型 | 同类型深比较,异类型卸载重建 | 在 render 内定义组件 |

| Element Diff | 同层子节点 | 有 key 按身份,无 key 按位置 | 用 index 当 key |

合成事件系统与事件委托

React 并不给每个元素单独绑定原生 DOM 事件,而是实现了一套合成事件系统(SyntheticEvent)。你在 JSX 里写的 `onClick`、`onChange` 等,注册的都是合成事件,而非直接的原生事件。

事件委托:从 document 到根容器

React 17 之前,所有事件被统一委托到 `document` 上;从 React 17 起,改为委托到当前 React 树挂载的根 DOM 容器(`createRoot` 传入的那个节点)。这个改动让同页面多版本 React 共存、以及嵌套渲染的事件冒泡更可控。

事件委托的原理:React 只在根容器上为每种事件类型注册一个监听器;当原生事件冒泡到根容器时,React 根据事件目标和 Fiber 树,模拟出一套自己的捕获/冒泡流程,依次调用沿途组件注册的合成事件处理器。

tsxCode
// 你写的是合成事件,React 内部并不会给这个 button 单独 addEventListener
function Button() {
  const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
    // e 是 SyntheticEvent,不是原生 MouseEvent
    console.log(e.type);            // 'click'
    console.log(e.nativeEvent);     // 需要原生事件时通过这个属性拿
    e.stopPropagation();            // 阻止的是合成事件的冒泡
    e.preventDefault();             // 阻止默认行为
  };
  return <button onClick={handleClick}>Click</button>;
}

SyntheticEvent 的好处

1.跨浏览器一致性:合成事件抹平了不同浏览器原生事件的差异,你拿到的接口在各浏览器表现一致。
2.性能:无论页面有多少个 `onClick`,根容器上每种事件类型只有一个真实监听器,大幅减少内存占用与绑定开销。
3.统一调度:合成事件的回调运行在 React 的批处理上下文里,多次 setState 会被自动合并。

合成事件的注意点

tsxCode
// 坑 1:React 17+ 事件委托到根容器(而非 document),
// 若用原生 addEventListener 在 document 上 stopPropagation,
// 可能拦不到 React 的合成事件,反之亦然——混用原生与合成事件要小心。

// 坑 2:想在原生事件里阻止 React 合成事件,用 e.stopImmediatePropagation()
useEffect(() => {
  const handler = (e: Event) => {
    e.stopImmediatePropagation();
  };
  document.addEventListener('click', handler);
  return () => document.removeEventListener('click', handler);
}, []);

在 React 16 及更早版本,合成事件对象会被事件池(event pool)复用,异步访问 `e` 需先调用 `e.persist()`。React 17 起已移除事件池,这个坑不再存在,但阅读老代码时会遇到。

组件通信全景

React 组件间通信有一套完整的手段,按"通信方向"和"距离"选择合适的方案。

1. 父传子:props

tsxCode
function Parent() {
  return <Child title="标题" count={3} />;
}
function Child({ title, count }: { title: string; count: number }) {
  return <h2>{title} - {count}</h2>;
}

2. 子传父:回调函数

tsxCode
function Parent() {
  const [msg, setMsg] = React.useState('');
  return <Child onSend={setMsg} />;  // 把"改状态的能力"以回调下发
}
function Child({ onSend }: { onSend: (v: string) => void }) {
  return <button onClick={() => onSend('来自子组件')}>发送</button>;
}

3. 兄弟通信:状态提升

把共享状态提到最近的共同父组件,父再通过 props 分发给两个兄弟。

4. 跨层级:Context

避免 prop drilling,适合主题、语言、当前用户等"全局但不频繁变"的数据。

tsxCode
const UserContext = React.createContext<User | null>(null);

function App() {
  const [user] = React.useState<User>({ id: 1, name: 'Alice' });
  return (
    <UserContext.Provider value={user}>
      <DeepChild />
    </UserContext.Provider>
  );
}
function DeepChild() {
  const user = React.useContext(UserContext); // 跨任意层级直接读
  return <span>{user?.name}</span>;
}

5. 父调子:ref + useImperativeHandle

大多数情况数据向下、事件向上就够了;但有时父组件需要"命令式地"调用子组件方法(如让子组件的 input 聚焦)。

tsxCode
type InputHandle = { focus: () => void };

const FancyInput = React.forwardRef<InputHandle, {}>(function FancyInput(_, ref) {
  const inputRef = React.useRef<HTMLInputElement>(null);
  React.useImperativeHandle(ref, () => ({
    focus: () => inputRef.current?.focus(),
  }), []);
  return <input ref={inputRef} />;
});

function Form() {
  const ref = React.useRef<InputHandle>(null);
  return (
    <div>
      <FancyInput ref={ref} />
      <button onClick={() => ref.current?.focus()}>聚焦输入框</button>
    </div>
  );
}

| 通信场景 | 推荐方案 | 说明 |

| --- | --- | --- |

| 父到子 | props | 最基本 |

| 子到父 | 回调函数 | 事件向上 |

| 兄弟之间 | 状态提升 | 提到共同父 |

| 跨多层 | Context | 避免 prop drilling |

| 父命令子 | ref + useImperativeHandle | 命令式,慎用 |

| 全局复杂状态 | Redux / Zustand / Jotai 等 | 大型应用 |

受控组件与非受控组件

表单是 React 里"数据流"最集中的地方,理解受控 vs 非受控是基础。

受控组件:表单元素的值由 React state 驱动,`value` 绑 state,`onChange` 更新 state。数据的唯一来源是 state,界面永远和 state 同步。
非受控组件:表单元素自己维护内部 DOM 状态,React 通过 ref 在需要时读取。用 `defaultValue` 设初值。
tsxCode
// 受控组件:value + onChange,state 是唯一数据源
function ControlledInput() {
  const [value, setValue] = React.useState('');
  return (
    <input
      value={value}
      onChange={(e) => setValue(e.target.value)}
    />
  );
}

// 非受控组件:defaultValue + ref,提交时才读值
function UncontrolledInput() {
  const inputRef = React.useRef<HTMLInputElement>(null);
  const handleSubmit = () => {
    console.log(inputRef.current?.value); // 需要时才读
  };
  return (
    <>
      <input defaultValue="初始值" ref={inputRef} />
      <button onClick={handleSubmit}>提交</button>
    </>
  );
}

| 对比项 | 受控组件 | 非受控组件 |

| --- | --- | --- |

| 数据源 | React state | DOM 自身 |

| 读取值 | 直接读 state | 通过 ref 读 |

| 实时校验 | 容易(每次 onChange) | 较难 |

| 代码量 | 略多 | 略少 |

| 适用 | 大多数受控场景、需联动 | 简单表单、集成非 React 库、文件上传 |

单向数据流与不可变更新

React 的更新建立在"引用比较"之上:无论 React.memo 的浅比较,还是 useEffect/useMemo 的依赖比较,都用 `Object.is` 比引用。因此必须用不可变(immutable)方式更新 state——产生新引用,而不是原地修改。

tsxCode
// 错误:原地修改,引用没变,React 认为没更新,界面不刷新
const [list, setList] = React.useState<number[]>([1, 2, 3]);
list.push(4);          // 突变!引用未变
setList(list);         // 不触发重渲染

// 正确:产生新数组
setList(prev => [...prev, 4]);              // 追加
setList(prev => prev.filter(x => x !== 2)); // 删除
setList(prev => prev.map(x => x === 2 ? 20 : x)); // 修改
tsxCode
// 嵌套对象的不可变更新:逐层展开
const [user, setUser] = React.useState({
  name: 'Alice',
  address: { city: '北京', zip: '100000' },
});

// 只改 city,其余保持
setUser(prev => ({
  ...prev,
  address: { ...prev.address, city: '上海' },
}));

深层嵌套手动展开很啰嗦,实践中常用 Immer 的 `produce` 以"看似可变"的写法产出不可变结果:

tsxCode
import { produce } from 'immer';
setUser(produce(draft => {
  draft.address.city = '上海'; // 写起来像可变,Immer 底层产出新引用
}));

setState 的批处理与异步性(React 18 自动批处理)

调用 `setState` 并不会立即同步改变 state 变量,也不会立即重渲染——React 会把同一批更新合并(batching),在合适时机统一处理,一次渲染、一次提交。

tsxCode
function Demo() {
  const [count, setCount] = React.useState(0);
  const handleClick = () => {
    setCount(count + 1);
    setCount(count + 1);
    setCount(count + 1);
    // count 此时仍是本次渲染的快照值 0,三次都基于 0 → 最终只 +1,结果是 1
  };
  return <button onClick={handleClick}>{count}</button>;
}
tsxCode
// 用函数式更新拿到"最新排队后的值",三次各自 +1,结果是 3
const handleClick = () => {
  setCount(prev => prev + 1);
  setCount(prev => prev + 1);
  setCount(prev => prev + 1);
};

React 18 的自动批处理

React 17 及更早,批处理只在 React 事件处理函数内生效;在 `setTimeout`、Promise 回调、原生事件监听里的多次 setState 不会合并,会触发多次渲染。React 18 引入 `createRoot` 后实现自动批处理(automatic batching):无论在哪里(定时器、Promise、原生事件),同一段同步执行里的多次 setState 都会自动合并。

tsxCode
// React 18:以下两次 setState 会被自动合并为一次渲染
function handleAsync() {
  fetch('/api').then(() => {
    setCount(c => c + 1);
    setFlag(f => !f);
    // React 17:渲染两次;React 18:只渲染一次
  });
}

// 需要退出批处理、强制立即同步渲染(罕见),可用 flushSync
import { flushSync } from 'react-dom';
flushSync(() => setCount(c => c + 1)); // 这一句立刻渲染并提交

渲染阶段与提交阶段

React 的一次更新分成两个大阶段,理解它能解释很多副作用时机问题:

1.渲染阶段(Render Phase):React 调用组件函数、生成新的虚拟 DOM、执行 diff(协调)。这个阶段可被中断、可被丢弃、可能重复执行(并发特性下),因此必须是纯净的——不能在这里执行副作用、修改 DOM、发请求。
2.提交阶段(Commit Phase):React 把 diff 得到的变更一次性应用到真实 DOM,然后同步执行 `useLayoutEffect` 和类组件的 `componentDidMount/DidUpdate`,随后浏览器绘制,再异步执行 `useEffect`。这个阶段不可中断
tsxCode
// useLayoutEffect vs useEffect 的时机差异
function Tooltip() {
  const ref = React.useRef<HTMLDivElement>(null);
  // useLayoutEffect:DOM 变更后、浏览器绘制前同步执行 —— 适合读取布局、避免闪烁
  React.useLayoutEffect(() => {
    const rect = ref.current!.getBoundingClientRect();
    // 在绘制前调整位置,用户看不到中间态
  });
  // useEffect:绘制后异步执行 —— 适合发请求、订阅等不阻塞绘制的副作用
  React.useEffect(() => {
    const id = setInterval(() => {}, 1000);
    return () => clearInterval(id);
  }, []);
  return <div ref={ref} />;
}

副作用清理的深入理解

useEffect 返回的清理函数会在两个时机执行:组件卸载前,以及下一次同一 effect 重新执行前(依赖变化时)。忘记清理是内存泄漏和竞态 bug 的头号来源。

tsxCode
// 竞态问题:快速切换 id 时,慢请求可能后到,覆盖新请求结果
function Profile({ id }: { id: number }) {
  const [data, setData] = React.useState<User | null>(null);
  React.useEffect(() => {
    let ignore = false; // 标志位守卫
    fetchUser(id).then(res => {
      if (!ignore) setData(res); // 已被清理则丢弃结果
    });
    return () => { ignore = true; }; // id 变化或卸载时,让旧请求结果作废
  }, [id]);
  return <div>{data?.name}</div>;
}

深入 Fiber 与协调(Reconciliation)

Fiber 是 React 16 起的核心架构重写。在此之前,diff 是递归且不可中断的:一旦开始,就会一口气比完整棵树,期间浏览器无法响应用户输入,大树更新时会明显卡顿。

Fiber 把渲染工作拆成一个个小单元(每个 Fiber 节点对应一个组件/元素),用链表结构(child、sibling、return 三个指针)替代递归调用栈,从而可以:

可中断、可恢复:渲染阶段的工作能在浏览器有空闲时(配合类似 requestIdleCallback 的调度)分片进行,把主线程让给高优先级任务(如用户输入)。
优先级调度:不同更新有不同优先级,紧急更新(点击、输入)插队到低优先级更新(数据加载渲染)之前,这正是 React 18 并发特性(`useTransition`、`useDeferredValue`)的基础。
双缓冲:React 同时维护 current 树(当前屏幕)和 workInProgress 树(正在构建)。构建完成后一次性切换(commit),避免用户看到半成品界面。
tsxCode
// 并发特性示例:把耗时的列表过滤标记为"过渡",保持输入框流畅
function SearchableList({ all }: { all: string[] }) {
  const [text, setText] = React.useState('');
  const [isPending, startTransition] = React.useTransition();
  const [query, setQuery] = React.useState('');

  const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setText(e.target.value);          // 高优先级:输入框立即响应
    startTransition(() => {
      setQuery(e.target.value);       // 低优先级:过滤可被打断
    });
  };

  const filtered = all.filter(x => x.includes(query));
  return (
    <>
      <input value={text} onChange={onChange} />
      {isPending && <span>更新中…</span>}
      <ul>{filtered.map(x => <li key={x}>{x}</li>)}</ul>
    </>
  );
}

React.memo / useMemo / useCallback 优化原理

这三个 API 都围绕同一个思想:缓存(memoization)——用一点内存换取重复计算或重复渲染的省略。但它们的作用对象不同,混用是新手最常见的困惑。

`React.memo`:缓存的是组件的渲染结果。它是一个高阶组件,会对新旧 props 做浅比较(Object.is 逐属性比较),props 没变就跳过该组件的重新渲染。
`useMemo`:缓存的是一个计算值。依赖数组不变时,直接返回上次算好的值,不重新执行计算函数。
`useCallback`:缓存的是一个函数引用。依赖数组不变时,返回同一个函数对象。本质上 `useCallback(fn, deps)` 等价于 `useMemo(() => fn, deps)`。

为什么需要它们:引用相等性

JavaScript 里对象、数组、函数都是引用类型,每次渲染重新创建都会得到新的引用,即使内容完全一样。React.memo 的浅比较会因此判定"props 变了",导致缓存失效。这就是为什么 memo 常常要和 useCallback / useMemo 配合。

tsxCode
// 问题演示:memo 失效
const Child = React.memo(function Child({ onClick }: { onClick: () => void }) {
  console.log('Child render');
  return <button onClick={onClick}>click</button>;
});

function Parent() {
  const [count, setCount] = React.useState(0);
  // 每次 Parent 渲染,handleClick 都是新函数引用
  const handleClick = () => console.log('clicked');
  return (
    <div>
      <span>{count}</span>
      <button onClick={() => setCount(c => c + 1)}>+1</button>
      {/* 尽管用了 memo,Child 每次仍重渲染,因为 onClick 引用每次都变 */}
      <Child onClick={handleClick} />
    </div>
  );
}
tsxCode
// 修复:useCallback 稳定函数引用,memo 才真正生效
function Parent() {
  const [count, setCount] = React.useState(0);
  // 空依赖:handleClick 引用在组件整个生命周期内保持不变
  const handleClick = React.useCallback(() => {
    console.log('clicked');
  }, []);
  return (
    <div>
      <span>{count}</span>
      <button onClick={() => setCount(c => c + 1)}>+1</button>
      <Child onClick={handleClick} /> {/* 现在 count 变化不再触发 Child 重渲染 */}
    </div>
  );
}

useMemo 缓存昂贵计算

tsxCode
function ProductList({ products, keyword }: { products: Product[]; keyword: string }) {
  // 假设过滤 + 排序对 1 万条数据要 30~50ms
  const visible = React.useMemo(() => {
    console.log('recompute filter');
    return products
      .filter(p => p.name.includes(keyword))
      .sort((a, b) => a.price - b.price);
  }, [products, keyword]); // 只有 products 或 keyword 变化才重算

  return (
    <ul>
      {visible.map(p => <li key={p.id}>{p.name} - ${p.price}</li>)}
    </ul>
  );
}

实测对比:优化前后的渲染次数

假设一个包含 500 个子项的列表,父组件里有一个与列表无关的输入框状态在频繁变化:

| 场景 | 每次父组件更新时子项渲染次数 | 单次更新耗时(示意) |

| --- | --- | --- |

| 无优化 | 500 次全部重渲染 | 约 40ms,输入卡顿 |

| 仅 React.memo(回调未稳定) | 500 次仍重渲染 | 约 40ms,无改善 |

| React.memo + useCallback | 0 次(props 未变) | 约 2ms,流畅 |

| React.memo + useMemo 缓存派生数据 | 0 次且不重算派生 | 约 1ms |

优化三原则(先测量再优化)

1.不要过早优化:memo/useMemo/useCallback 本身有比较成本和内存成本。组件很轻、渲染很快时,加了反而更慢、更啰嗦。
2.先用 Profiler 定位瓶颈:React DevTools 的 Profiler 面板能高亮"渲染耗时长"和"渲染次数多"的组件,按数据说话。
3.优化要成对出现:给子组件包 memo,就要保证传给它的对象/函数/数组 props 都是稳定引用,否则 memo 是摆设。
tsxCode
// 用 Profiler API 在代码里测量渲染耗时
import { Profiler } from 'react';

function onRender(id: string, phase: 'mount' | 'update', actualDuration: number) {
  console.log(`[${id}] ${phase} 耗时 ${actualDuration.toFixed(2)}ms`);
}

<Profiler id="ProductList" onRender={onRender}>
  <ProductList products={products} keyword={keyword} />
</Profiler>;

key 与列表渲染深入

key 是 React 用来在 diff 时识别列表项"身份"的标记。理解它必须回到 element diff 的机制:同层的子节点,React 默认按位置一一对比;有了 key,就改为按 key 匹配身份,从而正确复用、移动、增删。

为什么 index 作为 key 是反模式

tsxCode
// 反模式:用 index 当 key
function TodoListBad({ todos }: { todos: Todo[] }) {
  return (
    <ul>
      {todos.map((todo, index) => (
        <li key={index}>
          <input defaultValue={todo.text} />
        </li>
      ))}
    </ul>
  );
}
// 在头部插入一项后:原来 index=0 的项现在是 index=1...
// React 认为 key=0 的位置"内容变了",把新项的内容更新到了旧 DOM 上,
// 而 input 是非受控的,其内部 DOM 状态(用户已输入的文字)会错位到别的行。
tsxCode
// 正确:用数据自身稳定唯一 id 当 key
function TodoListGood({ todos }: { todos: Todo[] }) {
  return (
    <ul>
      {todos.map((todo) => (
        <li key={todo.id}>
          <input defaultValue={todo.text} />
        </li>
      ))}
    </ul>
  );
}

key 的三条使用规则

1.稳定:同一项在多次渲染间 key 不变。不要用 `Math.random()` 或 `Date.now()` 当 key——每次都变会导致整列重建。
2.唯一:同一层级内 key 不重复。重复 key 会触发警告并导致复用错乱。
3.就近:key 要写在 `map` 直接返回的最外层元素上,而不是它的子元素上。

什么时候 index 当 key 可以接受

当列表不会重新排序、不会在中间增删、且列表项无本地状态(纯静态展示)时,用 index 当 key 是可以接受的。但一旦有交互,就应换成稳定 id。

条件渲染的常见模式与坑

tsxCode
// 1. 三元表达式:二选一
{isLogin ? <Dashboard /> : <LoginForm />}

// 2. && 短路:满足条件才渲染
{hasError && <ErrorBanner />}

// 3. 提前 return:整个组件的分支
function Page({ loading, error, data }: PageProps) {
  if (loading) return <Spinner />;
  if (error) return <ErrorView error={error} />;
  return <Content data={data} />;
}

// 4. 映射表:多分支替代 if-else 链
const statusView = {
  idle: <Idle />,
  loading: <Spinner />,
  success: <Result />,
  error: <ErrorView />,
};
return statusView[status];

&& 短路的经典陷阱:数字 0 被渲染出来

tsxCode
// 坑:当 count 为 0 时,0 && (...) 返回 0,React 会把数字 0 渲染到页面上!
{count && <Badge count={count} />}   // 页面上出现一个孤零零的 "0"

// 正确写法:显式转布尔,或用三元
{count > 0 && <Badge count={count} />}
{count ? <Badge count={count} /> : null}

原因:`&&` 短路时返回左操作数本身,而 React 会渲染数字(0、NaN 也是),只把 `false / null / undefined / true` 当作"不渲染"。空字符串 `''` 同理不渲染,但 `0` 会。

组件复用模式的演进:HOC → render props → Hooks

React 逻辑复用的方式经历了三代演进,理解这条脉络能读懂各个时代的代码库。

第一代:高阶组件(HOC)

HOC 是"接收组件、返回增强组件"的函数,本质是装饰器模式。

tsxCode
// HOC:给任意组件注入 loading 逻辑
function withLoading<P extends object>(Component: React.ComponentType<P>) {
  return function WithLoading(props: P & { loading: boolean }) {
    const { loading, ...rest } = props;
    if (loading) return <Spinner />;
    return <Component {...(rest as P)} />;
  };
}

const UserListWithLoading = withLoading(UserList);

HOC 的问题:嵌套地狱(多个 HOC 层层包裹形成"包装地狱")、props 来源不透明(不知道某个 prop 是哪个 HOC 注入的)、命名冲突

第二代:render props

把"要渲染什么"通过一个函数类型的 prop 交给使用方决定。

tsxCode
// render props:MouseTracker 只负责追踪坐标,渲染交给使用方
function MouseTracker({ render }: { render: (pos: { x: number; y: number }) => React.ReactNode }) {
  const [pos, setPos] = React.useState({ x: 0, y: 0 });
  return (
    <div onMouseMove={(e) => setPos({ x: e.clientX, y: e.clientY })}>
      {render(pos)}
    </div>
  );
}

<MouseTracker render={({ x, y }) => <p>坐标:{x}, {y}</p>} />;

render props 解决了 props 来源不透明的问题,但大量使用同样会形成回调嵌套地狱

第三代:自定义 Hooks(现代首选)

Hooks 把"有状态的逻辑"抽成普通函数,复用逻辑而不引入额外组件层级。

tsxCode
// 自定义 Hook:把鼠标追踪逻辑抽出来,任何组件都能用
function useMousePosition() {
  const [pos, setPos] = React.useState({ x: 0, y: 0 });
  React.useEffect(() => {
    const handler = (e: MouseEvent) => setPos({ x: e.clientX, y: e.clientY });
    window.addEventListener('mousemove', handler);
    return () => window.removeEventListener('mousemove', handler);
  }, []);
  return pos;
}

// 使用:一行搞定,无嵌套、无额外组件层
function Demo() {
  const { x, y } = useMousePosition();
  return <p>坐标:{x}, {y}</p>;
}

| 复用方式 | 出现版本 | 优点 | 缺点 |

| --- | --- | --- | --- |

| HOC | 早期 | 逻辑与视图分离 | 包装地狱、props 来源不透明 |

| render props | 中期 | props 来源清晰 | 回调嵌套地狱 |

| 自定义 Hooks | 16.8+ | 无额外层级、组合灵活、类型友好 | 只能用于函数组件、有规则约束 |

Hooks 有两条铁律:只在顶层调用(不能放进条件、循环、嵌套函数),只在函数组件或自定义 Hook 中调用。原因是 React 靠"调用顺序"来把每次渲染的 Hook 一一对应,顺序一乱,状态就串位。

StrictMode 与开发环境双调用

`React.StrictMode` 是一个不渲染任何真实 DOM 的辅助组件,用于在开发环境帮你提前发现问题。它最容易让人困惑的行为是:从 React 18 起,StrictMode 下组件会被故意调用两次

tsxCode
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <App />
  </StrictMode>
);

React 18 的 StrictMode 会在开发环境下:

双调用组件函数、初始化函数、以及 useState/useMemo/useReducer 的初始化逻辑——用来暴露"渲染不纯"的副作用(渲染期间修改了外部变量等)。
双调用 useEffect(挂载 → 卸载 → 再挂载)——用来暴露"没有正确清理副作用"的问题,为未来的可复用状态特性(如 Offscreen)做准备。
tsxCode
// StrictMode 双调用会暴露的 bug:effect 没清理导致订阅重复
useEffect(() => {
  const conn = createConnection(); // 若不清理,双调用后会有两个连接
  conn.connect();
  return () => conn.disconnect(); // 正确清理,双调用也安全
}, []);

关键点:双调用只发生在开发环境,生产构建不会双调用。如果你的组件在 StrictMode 下表现异常(如请求发两次、计数加两次),那不是 React 的 bug,而是它在提醒你副作用没写"纯净/可清理"。

心智模型深化:UI = f(state) 的三层含义

1.渲染是纯函数:给定相同的 props 和 state,组件应返回相同的 JSX,且渲染过程中不产生副作用(不发请求、不改 DOM、不改外部变量)。副作用要放进事件处理器或 useEffect。
2.状态是快照:每次渲染都有属于自己的一份 state、props 和事件处理函数——它们是那一刻的"快照",不会变。这解释了很多"闭包陷阱"。
tsxCode
// 快照陷阱:点击后 3 秒弹出的是"点击那一刻"的 count,而非最新值
function Counter() {
  const [count, setCount] = React.useState(0);
  function handleClick() {
    setCount(count + 1);
    setTimeout(() => {
      alert(count); // 弹出的是本次渲染快照里的 count,不是 +1 后的值
    }, 3000);
  }
  return <button onClick={handleClick}>{count}</button>;
}
3.要更新界面就更新状态:不要试图直接改 DOM 或直接改变量让界面变化。唯一"合法"的界面驱动方式是调用 setState 触发新一轮 `f(state)`。

常见性能坑与系统化排查

| 症状 | 常见根因 | 排查手段 | 解决方向 |

| --- | --- | --- | --- |

| 输入框打字卡顿 | 每次输入触发整棵大树重渲染 | Profiler 看渲染范围 | 拆分组件、状态就近、memo |

| 列表滚动掉帧 | 一次渲染上千 DOM 节点 | 看 DOM 节点数 | 虚拟列表(react-window) |

| memo 不起作用 | 传入的对象/函数 props 每次新引用 | 打印 props 引用 | useCallback/useMemo 稳定引用 |

| 无关组件跟着重渲染 | Context value 每次新对象 | 看 Context 消费者 | 拆分 Context、value 用 useMemo |

| 点击后界面延迟 | 大量同步计算阻塞主线程 | Performance 火焰图 | useTransition、分片、Web Worker |

tsxCode
// Context 优化:value 用 useMemo,避免每次 Provider 渲染都让所有消费者更新
function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [theme, setTheme] = React.useState<'light' | 'dark'>('light');
  const value = React.useMemo(() => ({ theme, setTheme }), [theme]);
  return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}
tsxCode
// 虚拟列表思路:只渲染可视区域内的行,1 万条数据也只渲染 ~20 个 DOM 节点
import { FixedSizeList } from 'react-window';

function BigList({ items }: { items: string[] }) {
  return (
    <FixedSizeList height={400} itemCount={items.length} itemSize={40} width={300}>
      {({ index, style }) => <div style={style}>{items[index]}</div>}
    </FixedSizeList>
  );
}

类组件与函数组件全面对照

| 维度 | 类组件 | 函数组件(Hooks) |

| --- | --- | --- |

| 定义方式 | 继承 React.Component | 普通函数 |

| 状态 | this.state / this.setState | useState / useReducer |

| 副作用 | 分散在多个生命周期方法 | useEffect 按关注点聚合 |

| 生命周期 | didMount/didUpdate/willUnmount 等 | useEffect 统一表达 |

| this 指向 | 需手动绑定,易出错 | 无 this,无绑定问题 |

| 逻辑复用 | HOC / render props | 自定义 Hooks |

| 代码量 | 相对啰嗦 | 更简洁 |

| 性能优化 | shouldComponentUpdate / PureComponent | React.memo + useMemo/useCallback |

| 官方方向 | 维护旧代码 | 新项目首选 |

tsxCode
// 同一功能两种写法对照
// 类组件
class ClassCounter extends React.Component<{}, { count: number }> {
  state = { count: 0 };
  increment = () => this.setState(s => ({ count: s.count + 1 }));
  render() {
    return <button onClick={this.increment}>{this.state.count}</button>;
  }
}

// 函数组件
function HookCounter() {
  const [count, setCount] = React.useState(0);
  return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}

本部分要点小结

memo/useMemo/useCallback 分别缓存组件渲染、计算值、函数引用,需成对使用、先测量再优化。
key 要稳定、唯一、就近;index 作 key 仅适用于纯静态无状态列表。
`0 && ` 会把 0 渲染出来,条件渲染要显式转布尔。
逻辑复用从 HOC → render props → 自定义 Hooks 演进,现代首选 Hooks。
StrictMode 开发环境双调用是特性不是 bug,用于暴露不纯渲染与未清理的副作用。
UI = f(state):渲染是纯函数、状态是快照、更新界面靠更新状态。

总结

React 的三大基石——虚拟 DOM、组件化、单向数据流——共同服务于一个理念:UI = f(state)。你只描述状态对应的界面,React 负责用 diff 算法高效地把差异落到真实 DOM。理解声明式思想、diff 的三条启发式规则、以及生命周期/副作用的清理,就掌握了 React 大部分性能优化和 bug 排查的钥匙。

| 核心概念 | 一句话解释 | 带来的好处 |

| --- | --- | --- |

| 虚拟 DOM | 描述 UI 的轻量 JS 对象 | 批量更新、最小化回流 |

| Diff 算法 | 三条启发式把 O(n³) 降到 O(n) | 高效计算差异 |

| 组件化 | 独立可复用的 UI 单元 | 模块化、可维护 |

| 单向数据流 | 数据向下、事件向上 | 可预测、易调试 |

| UI = f(state) | 界面是状态的函数 | 声明式、心智负担低 |

| 生命周期/副作用 | 挂载/更新/卸载三阶段 | 正确管理资源,防泄漏 |