React hook的执行顺序

React Hooks 是 React 16.8 引入的一项重要特性,它允许你在函数组件中使用状态 (state) 和其他 React 特性,而无需编写 class。理解 Hooks 的执行顺序,特别是当涉及到父子组件和 useState 的数据更新机制时,对于编写高效且可维护的 React 应用至关重要。

React Hook 的执行顺序

React 组件的渲染过程是一个自上而下(从父到子)的遍历过程。当一个组件的状态或 props 发生变化时,React 会重新渲染该组件及其所有子组件(默认情况下,除非子组件通过 React.memoshouldComponentUpdate 进行了优化)。

Hooks 的执行顺序遵循以下几个关键原则:

  1. 组件渲染时按顺序执行: 每次组件函数被调用(即组件渲染时),Hooks 都会按照它们在组件函数中定义的顺序被调用。这是 Hooks 规则之一:只在顶层调用 Hook,不要在循环、条件或嵌套函数中调用 Hook
  2. 父组件优先于子组件: 在渲染周期中,父组件的函数会先执行,然后才会执行其子组件的函数。这意味着父组件中的 Hooks 会在子组件的 Hooks 之前执行。
  3. useStateuseReducer 的更新是异步的: 它们会触发组件的重新渲染,但状态的更新本身是批处理的,通常不会立即反映在当前渲染周期中。
  4. useEffectuseLayoutEffect 的回调函数在 DOM 更新后执行:
    • useLayoutEffect 的回调函数在所有 DOM 变更后同步执行,但在浏览器绘制之前。
    • useEffect 的回调函数在所有 DOM 变更后异步执行,并且在浏览器绘制之后。
  5. 清理函数 (Cleanup Function) 在下一次 Effect 执行前或组件卸载时执行: 如果 useEffectuseLayoutEffect 返回一个函数,这个函数会在下一次 Effect 运行之前(如果依赖项发生变化)或者组件卸载时执行,用于清理副作用。

详细代码示例:父子组件与 Hooks 执行顺序

我们将创建一个父组件 ParentComponent 和两个子组件 ChildComponentAChildComponentB,并通过 console.log 观察 Hooks 的执行顺序。

项目结构:

css 复制代码
src/
├── App.js
├── components/
│   ├── ChildComponentA.js
│   └── ChildComponentB.js
└── index.js

1. src/components/ChildComponentA.js

jsx 复制代码
import React, { useState, useEffect, useLayoutEffect } from 'react';

function ChildComponentA({ parentCount }) {
  const [childACount, setChildACount] = useState(0);

  console.log('  ChildComponentA: 渲染开始');

  // useEffect 在 DOM 更新后异步执行
  useEffect(() => {
    console.log('  ChildComponentA: useEffect 回调执行 (依赖: childACount, parentCount)');
    return () => {
      console.log('  ChildComponentA: useEffect 清理函数执行');
    };
  }, [childACount, parentCount]);

  // useLayoutEffect 在 DOM 更新后同步执行,浏览器绘制前
  useLayoutEffect(() => {
    console.log('  ChildComponentA: useLayoutEffect 回调执行 (依赖: childACount)');
    return () => {
      console.log('  ChildComponentA: useLayoutEffect 清理函数执行');
    };
  }, [childACount]);

  const incrementChildACount = () => {
    setChildACount(prev => prev + 1);
    console.log('  ChildComponentA: setChildACount 调用');
  };

  console.log('  ChildComponentA: 渲染结束');

  return (
    <div style={{ border: '1px solid blue', padding: '10px', margin: '10px' }}>
      <h3>子组件 A</h3>
      <p>父组件计数: {parentCount}</p>
      <p>子组件 A 计数: {childACount}</p>
      <button onClick={incrementChildACount}>增加子组件 A 计数</button>
    </div>
  );
}

export default ChildComponentA;

2. src/components/ChildComponentB.js

jsx 复制代码
import React, { useState, useEffect } from 'react';

function ChildComponentB({ parentCount }) {
  const [childBText, setChildBText] = useState('初始文本');

  console.log('  ChildComponentB: 渲染开始');

  useEffect(() => {
    console.log('  ChildComponentB: useEffect 回调执行 (依赖: childBText, parentCount)');
    return () => {
      console.log('  ChildComponentB: useEffect 清理函数执行');
    };
  }, [childBText, parentCount]);

  const changeChildBText = () => {
    setChildBText(prev => prev === '初始文本' ? '更新后的文本' : '初始文本');
    console.log('  ChildComponentB: setChildBText 调用');
  };

  console.log('  ChildComponentB: 渲染结束');

  return (
    <div style={{ border: '1px solid green', padding: '10px', margin: '10px' }}>
      <h3>子组件 B</h3>
      <p>父组件计数: {parentCount}</p>
      <p>子组件 B 文本: {childBText}</p>
      <button onClick={changeChildBText}>改变子组件 B 文本</button>
    </div>
  );
}

export default ChildComponentB;

3. src/App.js (父组件)

jsx 复制代码
import React, { useState, useEffect, useLayoutEffect } from 'react';
import ChildComponentA from './components/ChildComponentA';
import ChildComponentB from './components/ChildComponentB';

function ParentComponent() {
  const [parentCount, setParentCount] = useState(0);
  const [showChildB, setShowChildB] = useState(true);

  console.log('ParentComponent: 渲染开始');

  useEffect(() => {
    console.log('ParentComponent: useEffect 回调执行 (依赖: parentCount)');
    return () => {
      console.log('ParentComponent: useEffect 清理函数执行');
    };
  }, [parentCount]);

  useLayoutEffect(() => {
    console.log('ParentComponent: useLayoutEffect 回调执行 (依赖: showChildB)');
    return () => {
      console.log('ParentComponent: useLayoutEffect 清理函数执行');
    };
  }, [showChildB]);

  const incrementParentCount = () => {
    setParentCount(prev => prev + 1);
    console.log('ParentComponent: setParentCount 调用');
  };

  const toggleChildB = () => {
    setShowChildB(prev => !prev);
    console.log('ParentComponent: setShowChildB 调用');
  };

  console.log('ParentComponent: 渲染结束');

  return (
    <div style={{ border: '2px solid red', padding: '20px' }}>
      <h2>父组件</h2>
      <p>父组件计数: {parentCount}</p>
      <button onClick={incrementParentCount}>增加父组件计数</button>
      <button onClick={toggleChildB}>切换子组件 B 显示</button>

      <ChildComponentA parentCount={parentCount} />
      {showChildB && <ChildComponentB parentCount={parentCount} />}
    </div>
  );
}

export default ParentComponent;

4. src/index.js

jsx 复制代码
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);

观察执行顺序:

  1. 首次渲染:

    • ParentComponent: 渲染开始
    • ParentComponent: 渲染结束
    • ChildComponentA: 渲染开始
    • ChildComponentA: 渲染结束
    • ChildComponentB: 渲染开始
    • ChildComponentB: 渲染结束
    • ParentComponent: useLayoutEffect 回调执行 (依赖: showChildB)
    • ChildComponentA: useLayoutEffect 回调执行 (依赖: childACount)
    • ParentComponent: useEffect 回调执行 (依赖: parentCount)
    • ChildComponentA: useEffect 回调执行 (依赖: childACount, parentCount)
    • ChildComponentB: useEffect 回调执行 (依赖: childBText, parentCount)

    总结: 渲染函数(包括 useState 的初始化)是自上而下执行的。useLayoutEffect 在所有组件的 DOM 更新后同步执行,然后 useEffect 在所有 useLayoutEffect 执行完毕且浏览器绘制后异步执行。

  2. 点击"增加父组件计数"按钮:

    • ParentComponent: setParentCount 调用
    • ParentComponent: useEffect 清理函数执行 (因为 parentCount 变化,旧的 useEffect 清理)
    • ParentComponent: 渲染开始
    • ParentComponent: 渲染结束
    • ChildComponentA: useEffect 清理函数执行 (因为 parentCount 变化,旧的 useEffect 清理)
    • ChildComponentA: 渲染开始
    • ChildComponentA: 渲染结束
    • ChildComponentB: useEffect 清理函数执行 (因为 parentCount 变化,旧的 useEffect 清理)
    • ChildComponentB: 渲染开始
    • ChildComponentB: 渲染结束
    • ParentComponent: useLayoutEffect 回调执行 (依赖: showChildB) (showChildB 未变,但组件重新渲染,useLayoutEffect 仍会执行)
    • ChildComponentA: useLayoutEffect 回调执行 (依赖: childACount)
    • ParentComponent: useEffect 回调执行 (依赖: parentCount)
    • ChildComponentA: useEffect 回调执行 (依赖: childACount, parentCount)
    • ChildComponentB: useEffect 回调执行 (依赖: childBText, parentCount)

    总结: 父组件状态更新会触发父组件及其所有子组件的重新渲染。渲染顺序依然是父组件先于子组件。Effect 的清理函数在下一次 Effect 执行前(即新渲染周期开始,旧 Effect 依赖变化时)执行。

  3. 点击"增加子组件 A 计数"按钮:

    • ChildComponentA: setChildACount 调用
    • ChildComponentA: useLayoutEffect 清理函数执行 (因为 childACount 变化,旧的 useLayoutEffect 清理)
    • ChildComponentA: useEffect 清理函数执行 (因为 childACount 变化,旧的 useEffect 清理)
    • ChildComponentA: 渲染开始
    • ChildComponentA: 渲染结束
    • ChildComponentA: useLayoutEffect 回调执行 (依赖: childACount)
    • ChildComponentA: useEffect 回调执行 (依赖: childACount, parentCount)

    总结: 只有 ChildComponentA 重新渲染,父组件和 ChildComponentB 不会重新渲染,因为它们的 props 和 state 都没有变化。这体现了 React 的局部更新能力。

useState 如何更新数据

useState 是 React 提供的一个 Hook,用于在函数组件中添加状态。它返回一个数组,第一个元素是当前状态值,第二个元素是更新状态的函数(setter 函数)。

javascript 复制代码
const [state, setState] = useState(initialState);

useState 的工作原理:

  1. 初始化:

    • 在组件的首次渲染时,useState(initialState) 会将 initialState 作为当前状态值,并返回 [initialState, setState]
    • React 会在内部为每个组件维护一个 Hook 链表(或数组),每个 Hook 都有一个对应的"槽位"来存储其状态。useState 的初始值就存储在这个槽位中。
  2. 更新状态:

    • 当你调用 setState(newValue)setState(prev => prev + 1) 时,React 会将这个更新操作放入一个队列中。
    • 异步批处理: React 通常会批处理多个状态更新。这意味着如果你在同一个事件处理函数中多次调用 setState,React 会将它们合并成一次重新渲染,而不是每次调用都立即重新渲染组件。这提高了性能。
    • 触发重新渲染: 状态更新被调度后,React 会标记该组件为"脏"组件,并在下一个事件循环或微任务队列中触发该组件的重新渲染。
    • 重新执行组件函数: 在重新渲染时,组件函数会再次被调用。此时,useState 会从其内部的槽位中取出最新的状态值,而不是再次使用 initialState
    • 函数式更新: setState 接受一个新值,也可以接受一个函数。当传入函数时,该函数会接收前一个状态作为参数,并返回新的状态。这在需要基于前一个状态计算新状态时非常有用,尤其是在异步更新和批处理场景下,可以避免闭包陷阱导致的状态不一致问题。
    javascript 复制代码
    // 传入新值
    setCount(count + 1); // 可能会有闭包问题,如果 count 在多次更新之间没有及时更新
    
    // 传入函数 (推荐,更安全)
    setCount(prevCount => prevCount + 1); // 确保使用最新的 prevCount

useState 数据更新的详细流程示例:

jsx 复制代码
import React, { useState } from 'react';

function Counter() {
  const [count, setCount] = useState(0);
  const [message, setMessage] = useState('初始消息');

  console.log(`Counter: 渲染开始 - count: ${count}, message: ${message}`);

  const handleIncrement = () => {
    console.log('--- 调用 handleIncrement ---');
    // 第一次更新:使用函数式更新,基于前一个状态
    setCount(prevCount => {
      console.log(`  setCount(1): prevCount = ${prevCount}`);
      return prevCount + 1;
    });
    // 第二次更新:直接设置值
    setCount(count + 1); // 注意:这里的 count 仍然是当前渲染周期的旧值

    // 第三次更新:再次使用函数式更新
    setCount(prevCount => {
      console.log(`  setCount(3): prevCount = ${prevCount}`);
      return prevCount + 1;
    });

    setMessage('计数已更新');
    console.log('--- handleIncrement 调用结束 ---');
  };

  console.log(`Counter: 渲染结束 - count: ${count}, message: ${message}`);

  return (
    <div style={{ border: '1px solid purple', padding: '15px', margin: '15px' }}>
      <h3>计数器组件</h3>
      <p>计数: {count}</p>
      <p>消息: {message}</p>
      <button onClick={handleIncrement}>增加计数</button>
    </div>
  );
}

export default Counter;

Counter 组件添加到 App.js

jsx 复制代码
// ... existing code ...
import Counter from './components/Counter'; // 导入 Counter 组件

function ParentComponent() {
  // ... existing code ...

  return (
    <div style={{ border: '2px solid red', padding: '20px' }}>
      <h2>父组件</h2>
      <p>父组件计数: {parentCount}</p>
      <button onClick={incrementParentCount}>增加父组件计数</button>
      <button onClick={toggleChildB}>切换子组件 B 显示</button>

      <ChildComponentA parentCount={parentCount} />
      {showChildB && <ChildComponentB parentCount={parentCount} />}
      <Counter /> {/* 添加 Counter 组件 */}
    </div>
  );
}

export default ParentComponent;

观察 Counter 组件的输出:

  1. 首次渲染:

    • Counter: 渲染开始 - count: 0, message: 初始消息
    • Counter: 渲染结束 - count: 0, message: 初始消息
  2. 点击"增加计数"按钮:

    • --- 调用 handleIncrement ---
    • --- handleIncrement 调用结束 ---
    • Counter: 渲染开始 - count: 0, message: 初始消息 (注意:这里 countmessage 仍然是旧值,因为 setState 是异步的)
    • setCount(1): prevCount = 0
    • setCount(3): prevCount = 2 (这里 prevCount 是 2,因为 setCount(count + 1) 已经将 count 从 0 变成了 1,然后 setCount(prevCount => prevCount + 1) 又将 1 变成了 2)
    • Counter: 渲染结束 - count: 3, message: 计数已更新 (在重新渲染时,countmessage 已经更新为最新值)

解释 useState 批处理和函数式更新:

handleIncrement 函数中:

javascript 复制代码
setCount(prevCount => {
  console.log(`  setCount(1): prevCount = ${prevCount}`);
  return prevCount + 1;
});
setCount(count + 1); // 这里的 count 仍然是事件发生时的旧值 (0)
setCount(prevCount => {
  console.log(`  setCount(3): prevCount = ${prevCount}`);
  return prevCount + 1;
});
setMessage('计数已更新');
  • handleIncrement 被调用时,count 的值是 0
  • 第一个 setCount(prevCount => prevCount + 1):将 count 的更新操作加入队列,预期 count 变为 0 + 1 = 1
  • 第二个 setCount(count + 1):这里的 count 仍然是 0(因为 setState 是异步的,当前函数执行时 count 尚未更新),所以这个操作是 setCount(0 + 1),预期 count 变为 1注意: React 会将这个更新与前一个更新合并。如果前一个更新将 count 变为 1,那么这个更新会基于 1 再加 1,变成 2
  • 第三个 setCount(prevCount => prevCount + 1):这个函数式更新会基于前一个更新的结果。如果前一个更新将 count 变为 2,那么这个操作是 setCount(2 + 1),预期 count 变为 3
  • setMessage('计数已更新'):将 message 的更新操作加入队列。

最终,React 会将这些更新批处理,只进行一次重新渲染。在重新渲染时,count 的最终值是 3message 的最终值是 '计数已更新'

这就是为什么在 handleIncrement 函数内部,console.log 打印的 count 仍然是旧值,而 setCount 的函数式更新能够拿到最新的 prevCount,因为 React 在处理更新队列时会按顺序应用这些更新。

总结

  • Hooks 执行顺序: 组件函数在渲染时自上而下执行,Hooks 严格按照定义顺序执行。父组件的 Hooks 优先于子组件的 Hooks。useLayoutEffect 在 DOM 更新后同步执行,useEffect 在 DOM 更新后异步执行。
  • useState 更新机制: useState 返回当前状态和更新函数。更新是异步且批处理的,会触发组件重新渲染。推荐使用函数式更新 setState(prev => newState) 来确保基于最新状态进行更新,避免闭包问题。

理解这些原理有助于你更好地调试 React 应用,并编写出性能更优、行为更可预测的组件。

相关推荐
奕辰杰4 小时前
关于npm前端项目编译时栈溢出 Maximum call stack size exceeded的处理方案
前端·npm·node.js
JiaLin_Denny5 小时前
如何在NPM上发布自己的React组件(包)
前端·react.js·npm·npm包·npm发布组件·npm发布包
路光.6 小时前
触发事件,按钮loading状态,封装hooks
前端·typescript·vue3hooks
我爱996!6 小时前
SpringMVC——响应
java·服务器·前端
咔咔一顿操作7 小时前
Vue 3 入门教程7 - 状态管理工具 Pinia
前端·javascript·vue.js·vue3
kk爱闹7 小时前
用el-table实现的可编辑的动态表格组件
前端·vue.js
漂流瓶jz8 小时前
JavaScript语法树简介:AST/CST/词法/语法分析/ESTree/生成工具
前端·javascript·编译原理
换日线°8 小时前
css 不错的按钮动画
前端·css·微信小程序
风象南8 小时前
前端渲染三国杀:SSR、SPA、SSG
前端
90后的晨仔8 小时前
表单输入绑定详解:Vue 中的 v-model 实践指南
前端·vue.js