阅读视图

发现新文章,点击刷新页面。

Hooks如何实现 去this and 去组件耦合

背景

上篇文章我们聊了Hooks诞生初心是为了解决什么问题,今天我们继续探讨 Hooks的 具体执行方案

前置:先建立「函数组件 + Hook」的全局执行流程

Hook 的所有操作都嵌套在 React 处理函数组件的完整流程中,先把这个“大框架”讲清楚,后续每个问题的解决过程都能套进这个框架里:

graph TD
    A[触发函数组件渲染] -->|比如首次挂载/setState/父组件更新| B[React 调用 updateFunctionComponent]
    B --> C[调用 renderWithHooks:搭建 Hook 运行环境]
    C --> C1[绑定当前 fiber 到全局变量]
    C --> C2[清空 fiber 旧的 Hook 链表/副作用链表]
    C --> C3[切换 Dispatcher:mount->初始化 Hook,update->更新 Hook]
    C --> D[执行函数组件本身,触发内部所有 Hook 调用包括useState/useEffect/自定义 Hook]
    D --> D1[mount 阶段:创建 Hook 节点,存入 fiber.memoizedState 链表]
    D --> D2[update 阶段:从 fiber 读取旧 Hook 节点,计算新状态/判断副作用是否执行]
    D --> E[renderWithHooks 重置 Dispatcher,返回虚拟 DOM]
    E --> F[React 进入 commit 阶段:更新真实 DOM]
    F --> F1[执行 useLayoutEffect 回调-同步]
    F --> F2[浏览器绘制页面]
    F --> F3[异步执行 useEffect 回调]
    F --> G[组件卸载时:执行 useEffect/useLayoutEffect 的 cleanup 函数] 

这个流程是 Hook 解决所有问题的“载体”,下面每个问题的解决过程,都是这个流程中某个环节的具体实现。


一、解决类组件 this 指向混乱:从“依赖实例”到“依赖 fiber + 闭包”

1. 类组件的具体痛点(带代码例子)

类组件的状态、方法都绑定在 this 上,稍不注意就会出问题,比如:

// 类组件的 this 坑
class ClassApp extends React.Component {
  constructor(props) {
    super(props);
    this.state = { count: 0 };
    // 必须手动 bind this,否则点击时 this 为 undefined
    this.handleClick = this.handleClick.bind(this);
  }

  handleClick() {
    // 异步 setState 中,this.state 可能拿到旧值
    this.setState({ count: this.state.count + 1 });
    console.log(this.state.count); // 输出旧值,而非更新后的值
  }
 
  render() {
    return <button onClick={this.handleClick}>{this.state.count}</button>;
  }
}

痛点本质:this 是动态的,状态依赖 this 访问,异步更新+绑定错误会导致状态读取/修改异常。

2. Hook 的解决思路 + 完整实现过程

核心逻辑:彻底抛弃 this,把状态存在 fiber 节点(持久化),用闭包保存当前渲染的状态(无需 this 访问)

步骤 1:renderWithHooks 搭建环境(绑定 fiber)

当执行 renderWithHooks 时,会把当前组件的 fiber 绑定到全局变量 currentlyRenderingFiber

// 简化版 renderWithHooks 核心代码
let currentlyRenderingFiber; // 全局:标记当前渲染的 fiber
function renderWithHooks(workInProgress, Component) {
  currentlyRenderingFiber = workInProgress; // 绑定 fiber
  workInProgress.memoizedState = null; // 清空旧 Hook 链表
  ReactCurrentDispatcher.current = HooksDispatcherOnMount; // 切换到 mount 模式
  const children = Component(); // 执行函数组件
  ReactCurrentDispatcher.current = ContextOnlyDispatcher; // 重置
  return children;
}

这里有几个概念需要跟大家科普一下

什么是 workInProgress?它是何时被创建出来的?

先厘清两个核心 fiber 变量(renderWithHooks 的入参)

renderWithHooks 的调用逻辑里,它的入参是这样的(回顾之前的简化代码):

function renderWithHooks(current, workInProgress, Component, props) {
  currentlyRenderingFiber = workInProgress; // 绑定的是 workInProgress
  // ... 其他逻辑
}

这两个 fiber 的含义和首次渲染时的状态完全不同:

fiber 变量 核心含义 首次渲染时的值 非首次渲染时的值
current 已提交到真实 DOM 的「旧 fiber」(稳定版) null 上一次渲染完成后挂载到 DOM 的 fiber
workInProgress 本次渲染正在构建的「新 fiber」(工作版) 有值(非 null) 基于 current 复制/更新的新 fiber

第一次渲染时的 fiber 完整创建流程(为什么 workInProgress 不是 null)

当你执行 ReactDOM.render(<App />, root) 触发首次渲染时,React 会先完成「workInProgress fiber 的创建」,再调用 renderWithHooks,整个流程是:

步骤 1:React 初始化根 fiber

React 会先为根节点(root)创建一个「根 fiber」,然后为 <App /> 组件创建对应的 workInProgress fiber 节点,这个节点会包含基础属性:

// 首次渲染时,React 先创建的 App 组件 workInProgress fiber
const workInProgress = {
  memoizedState: null, // 初始为空,后续存 Hook 链表
  stateNode: App,      // 指向函数组件本身(App 函数)
  type: App,           // 组件类型
  // 其他核心属性(比如 props、effectTag 等)
};

此时 workInProgress 是一个完整的 fiber 对象,绝对不是 null——如果它是 null,Hook 就没有“挂载状态的载体”,整个 Hook 机制会直接崩溃。

步骤 2:调用 renderWithHooks,绑定 workInProgress 到全局

React 调用 updateFunctionComponent(处理函数组件的核心函数),然后在内部调用 renderWithHooks,入参为:

  • current: null(首次渲染没有已提交的旧 fiber);
  • workInProgress: 步骤1创建的 fiber 对象(非 null);
  • Component: App(你的函数组件)。

所以执行 currentlyRenderingFiber = workInProgress 后,这个全局变量的值是步骤1创建的 fiber 对象,而非 null。

步骤 3:current 为 null 的影响(切换 Dispatcher)

正因为 current === nullrenderWithHooks 会把 ReactCurrentDispatcher.current 切换为 HooksDispatcherOnMount(挂载版 Dispatcher),这也是首次渲染时 useState 会执行 mountState 初始化 Hook 的原因。

为什么你会误以为“首次渲染 fiber 是 null”?

大概率是混淆了两个点:

  1. current 当成了 workInProgresscurrent 首次渲染是 null,但它只是“旧 fiber 引用”,不是 Hook 依赖的“当前工作 fiber”;
  2. workInProgress.memoizedState 当成了 fiber 本身:首次渲染时 workInProgress.memoizedState 是 null(因为还没创建 Hook 链表),但 fiber 节点本身是完整的,memoizedState 只是 fiber 的一个属性。

验证:如果 workInProgress 是 null 会怎样?

我们可以反推——如果 currentlyRenderingFiber 是 null,当你首次渲染调用 useState 时,mountState 里的这段代码会直接出错:

function mountState(initialState) {
  const hook = { /* ... */ };
  // 如果 currentlyRenderingFiber 是 null,这里会报“无法读取 memoizedState 的 undefined”
  if (!currentlyRenderingFiber.memoizedState) {
    currentlyRenderingFiber.memoizedState = hook;
  }
  // ...
}

而 React 源码中做了严格的边界处理:只有当 workInProgress 存在时,才会执行 renderWithHooks,所以首次渲染时这个全局变量必然有值。

总结

  1. 首次渲染时,renderWithHooks 绑定的 currentlyRenderingFiber(即 workInProgress不是 null,它是 React 提前创建的、当前正在构建的 fiber 节点;也就是说在执行函数组件时提前创建的
  2. 首次渲染时 current 是 null(无旧 fiber),这是切换“挂载版 Dispatcher”的判断依据;
  3. workInProgress.memoizedState 初始为 null(无 Hook 链表),但 fiber 节点本身是完整的,后续会被 Hook 节点填充。

这个细节也印证了之前的核心结论:Hook 的状态最终挂载在 workInProgress(后续会变成 current)fiber 上,首次渲染时 React 已经为函数组件准备好了这个“状态载体”,所以 Hook 才能正常初始化。

为什么每次渲染前需要 清空hook链表?

先明确:“清空”的不是「旧状态」,是「本次渲染新 fiber 的临时链表」

首先要纠正一个认知: renderWithHooks 里清空的是 workInProgress.memoizedState(本次渲染的新 fiber),而不是 current.memoizedState(已提交的旧 fiber)——旧状态依然保存在 current fiber 中,清空只是让新 fiber 从“空白”开始重新构建链表

简化代码再强调这一点:

function renderWithHooks(current, workInProgress, Component) {
  currentlyRenderingFiber = workInProgress;
  
  // 清空的是「本次渲染的新 fiber」的链表,不是旧 fiber 的
  workInProgress.memoizedState = null; // 清空 Hook 链表(新 fiber)
  workInProgress.updateQueue = null;   // 清空副作用链表(新 fiber)
  
  // 切换 Dispatcher:mount/update
  ReactCurrentDispatcher.current = current === null ? HooksDispatcherOnMount : HooksDispatcherOnUpdate;
  
  const children = Component(); // 执行组件,重新构建新链表
  // ...
}

核心原因 1:保证 Hook 链表与「本次渲染的 Hook 调用顺序」严格一致

函数组件的核心特性是「每次渲染都会重新执行整个函数」,Hook 的调用顺序可能因逻辑变化(比如条件 Hook,虽然不推荐,但 React 要兼容)发生改变。清空旧链表,才能让新链表完全匹配本次的调用顺序。

场景:更新渲染时 Hook 调用数量变化(反例:不清空会错位)

假设首次渲染组件有 2 个 useState:

// 首次渲染(mount)
function App() {
  const [a, setA] = useState(1); // Hook1
  const [b, setB] = useState(2); // Hook2
  return <div>{a + b}</div>;
}

此时 current.memoizedState(旧 fiber)的 Hook 链表是:Hook1 → Hook2

如果更新渲染时,组件逻辑变了,只调用 1 个 useState:

// 更新渲染(update)
function App() {
  const [a, setA] = useState(1); // 仅 Hook1
  if (false) { // 条件不满足,跳过 Hook2
    const [b, setB] = useState(2);
  }
  return <div>{a}</div>;
}
情况 1:清空 workInProgress 的旧链表(正确逻辑)
  • workInProgress.memoizedState 被置为 null;
  • 执行组件,只调用 Hook1,新链表为 Hook1
  • React 从 current 读取旧 Hook1 的状态(1),赋值给新 Hook1;
  • 最终新链表与本次调用顺序一致,状态正确。
情况 2:不清空 workInProgress 的旧链表(错误逻辑)
  • workInProgress.memoizedState 还保留着上次的 Hook1 → Hook2
  • 执行组件,只调用 Hook1,React 会把新 Hook1 拼在旧链表后,新链表变成 Hook1(旧)→ Hook2(旧)→ Hook1(新)
  • 此时 Hook 顺序完全混乱,新 Hook1 会读取旧 Hook2 的状态(2),导致状态错位。

👉 本质:Hook 依赖「链表顺序」而非“变量名”关联状态,清空旧链表是为了让新链表“从零开始”,100% 匹配本次的调用顺序,避免旧链表的残留节点干扰。

核心原因 2:避免旧副作用残留,保证副作用执行逻辑符合本次渲染

副作用链表(updateQueue)存储的是 useEffect/useLayoutEffect 的执行信息(比如 callback、deps、cleanup)。如果不清空,旧的副作用会和本次的副作用混在一起,导致:

  1. 已失效的副作用被重复执行;
  2. 本次的副作用被旧副作用覆盖;
  3. cleanup 函数执行异常。
场景:useEffect 的 deps 变化(反例:不清空会重复执行旧副作用)

首次渲染的 useEffect:

// 首次渲染
function App() {
  useEffect(() => {
    console.log('旧副作用:监听 resize');
    window.addEventListener('resize', handleResize);
    return () => window.removeEventListener('resize', handleResize);
  }, []); // 空 deps
  return <div>App</div>;
}

此时 workInProgress.updateQueue 存储的是“监听 resize”的副作用。

更新渲染时,useEffect 逻辑变了:

// 更新渲染
function App() {
  useEffect(() => {
    console.log('新副作用:监听 scroll');
    window.addEventListener('scroll', handleScroll);
    return () => window.removeEventListener('scroll', handleScroll);
  }, []);
  return <div>App</div>;
}
情况 1:清空副作用链表(正确逻辑)
  • workInProgress.updateQueue 被置为 null;
  • 执行组件,收集新的“监听 scroll”副作用;
  • commit 阶段只执行新副作用,旧副作用的 cleanup 被执行(移除 resize 监听),新副作用生效。
情况 2:不清空副作用链表(错误逻辑)
  • workInProgress.updateQueue 保留旧的“resize 监听”副作用;
  • 执行组件,收集新的“scroll 监听”副作用,链表变成「旧副作用 → 新副作用」;
  • commit 阶段会同时执行两个副作用:既监听 resize 又监听 scroll,且旧副作用的 cleanup 不会被触发,导致内存泄漏。

补充:首次渲染时清空的意义(兜底)

首次渲染时,workInProgress.memoizedState 本来就是 null,清空操作看似“多余”,但其实是 React 的「防御性编程」:

  1. 避免 fiber 节点复用导致的残留(比如热更新时,fiber 可能被复用);
  2. 保证所有渲染流程的一致性(首次/更新渲染执行相同的清空逻辑,减少分支判断)。

总结

清空 fiber 旧的 Hook 链表/副作用链表的核心目的是:

  1. 保证顺序一致:让新 fiber 的 Hook 链表完全匹配本次渲染的 Hook 调用顺序,避免状态错位;
  2. 保证副作用纯净:清除旧的副作用残留,避免重复执行、内存泄漏;
  3. 流程统一:首次/更新渲染执行相同逻辑,降低代码复杂度。

本质上,这是 React 为了适配“函数组件每次重新执行”的特性,确保 Hook 状态始终和「当前渲染的逻辑」绑定,而非和「历史渲染的逻辑」绑定。

步骤 2:mount 阶段——创建 Hook 节点,存在 fiber 中(无 this)

当函数组件执行 useState(0) 时,调用的是 HooksDispatcherOnMount 中的 mountState

// 简化版 mountState:初始化状态,存在 fiber 中
function mountState(initialState) {
  // 创建 Hook 节点
  const hook = {
    memoizedState: initialState, // 保存状态值(比如 0)
    queue: { pending: null }, // 保存更新队列(setCount 触发的更新)
    next: null // 指向下一个 Hook
  };
  // 把 Hook 节点加入当前 fiber 的 memoizedState 链表
  if (!currentlyRenderingFiber.memoizedState) {
    currentlyRenderingFiber.memoizedState = hook; // 第一个 Hook,作为链表头
  } else {
    workInProgressHook.next = hook; // 后续 Hook,接在链表后面
  }
  workInProgressHook = hook; // 移动指针到下一个 Hook
  // 返回 [状态, setter],setter 绑定当前 Hook 的 queue,无需 this
  return [hook.memoizedState, dispatchAction.bind(null, hook.queue)];
}

此时,状态 0 被存在 fiber.memoizedState 中,而非 thissetCount 是绑定了当前 Hook 队列的函数,无需通过 this 调用。

步骤 3:update 阶段——从 fiber 读取 Hook 节点,闭包保存当前状态

当点击按钮调用 setCount(count + 1) 时:

  1. dispatchAction 会创建 update 对象({ action: count => count + 1 }),加入 Hook 的 queue;
  2. React 触发重新渲染,再次调用 renderWithHooks,切换到 HooksDispatcherOnUpdate
  3. 执行 updateState,从 fiber 的 Hook 链表中读取旧 Hook 节点,执行 update 队列中的 action,计算新状态:
    // 简化版 updateState:更新状态,无 this
    function updateState() {
      const hook = workInProgressHook; // 从 fiber 读取当前 Hook 节点
      workInProgressHook = hook.next; // 移动指针
      const queue = hook.queue;
      let baseState = hook.memoizedState;
      // 执行所有 update,计算新状态
      if (queue.pending) {
        const firstUpdate = queue.pending.next;
        do {
          baseState = firstUpdate.action(baseState); // 比如 0 → 1
          firstUpdate = firstUpdate.next;
        } while (firstUpdate !== queue.pending);
        queue.pending = null;
      }
      hook.memoizedState = baseState; // 更新 Hook 节点的状态
      // 返回 [新状态, setter],新状态存在当前函数作用域(闭包)
      return [baseState, dispatchAction.bind(null, queue)];
    }
    
  4. 新状态 1 被返回,存在函数组件的作用域中(const [count, setCount] = useState(0)),通过闭包直接访问,无需 this.count
步骤 4:最终效果(对比类组件)
// Hook 写法:无 this,状态存在 fiber + 闭包
function HookApp() {
  const [count, setCount] = useState(0); // count 是当前作用域的变量
  return (
    <button onClick={() => setCount(count + 1)}>
      {count} {/* 直接访问,无需 this */}
    </button>
  );
}
  • 状态存储:从“类实例 this” → “fiber 节点的 Hook 链表”;
  • 状态访问:从“动态 this.state” → “函数作用域的闭包变量”;
  • 根本解决:全程无 this,自然规避 this 绑定、异步更新导致的所有问题。

二、解决生命周期混乱:从“分散生命周期”到“Effect 收敛 + deps 控制”

1. 类组件的具体痛点(带代码例子)

一个“监听窗口大小”的逻辑,要拆在 3 个生命周期里,逻辑被割裂:

// 类组件:生命周期分散逻辑
class ClassApp extends React.Component {
  componentDidMount() {
    // 挂载时加监听
    window.addEventListener('resize', this.handleResize);
  }

  componentDidUpdate() {
    // 更新时可能需要重新监听(比如依赖 props 变化)
    window.removeEventListener('resize', this.handleResize);
    window.addEventListener('resize', this.handleResize);
  }

  componentWillUnmount() {
    // 卸载时删监听
    window.removeEventListener('resize', this.handleResize);
  }

  handleResize = () => {
    this.setState({ width: window.innerWidth });
  };

  render() {
    return <div>{this.state.width}</div>;
  }
}

痛点本质:生命周期函数是“按时机划分”,而业务逻辑是“按功能划分”,导致一个功能的代码被拆得支离破碎。

2. Hook 的解决思路 + 完整实现过程

核心逻辑:用 useEffect 把“一个功能的所有生命周期逻辑”收敛到一个函数中,通过 deps 控制执行时机,cleanup 处理卸载/更新前的清理

步骤 1:mount 阶段——收集 Effect,标记执行

函数组件执行 useEffect 时,调用 mountEffect 创建 EffectHook 节点,存在 fiber 中:

// 简化版 mountEffect:收集副作用
function mountEffect(create, deps) {
  const hook = {
    tag: 'PassiveEffect', // 标记是 useEffect(异步执行)
    create, // 你的业务逻辑(比如加监听)
    deps, // 依赖数组(比如 [])
    cleanup: null, // 清理函数(create 返回的函数)
    next: null
  };
  // 把 EffectHook 加入 fiber 的 Hook 链表(和 useState 共用一个链表)
  if (!currentlyRenderingFiber.memoizedState) {
    currentlyRenderingFiber.memoizedState = hook;
  } else {
    workInProgressHook.next = hook;
  }
  workInProgressHook = hook;
}

此时,create(加监听)、deps(空数组)被存在 Hook 节点中,标记为“需要执行”。

步骤 2:render 阶段——对比 deps,标记是否执行(update 阶段)

当组件更新时,调用 updateEffect 对比新旧 deps,判断是否需要重新执行:

// 简化版 updateEffect:对比 deps
function updateEffect(create, deps) {
  const hook = workInProgressHook; // 读取旧 Hook 节点
  workInProgressHook = hook.next;
  const prevDeps = hook.deps;
  // 浅比较 deps:如果 deps 相同,标记为无需执行
  if (deps !== null && areHookInputsEqual(deps, prevDeps)) {
    hook.tag = 'NoEffect'; // 无需执行
    return;
  }
  // deps 不同,更新 Hook 节点,标记需要执行
  hook.create = create;
  hook.deps = deps;
  hook.tag = 'PassiveEffect';
}

// 简化版 deps 浅比较
function areHookInputsEqual(nextDeps, prevDeps) {
  for (let i = 0; i < prevDeps.length; i++) {
    if (!Object.is(nextDeps[i], prevDeps[i])) {
      return false; // 有一项不同,返回 false
    }
  }
  return true;
}

比如 deps 是 [],更新时对比发现 deps 没变,就标记为 NoEffect,跳过执行。

步骤 3:commit 阶段——执行 Effect/cleanup(核心!)

React 更新完真实 DOM 后,进入 commit 阶段的 commitPassiveEffects 步骤,执行 useEffect:

// 简化版 commitPassiveEffects:执行 useEffect 回调/cleanup
function commitPassiveEffects(fiber) {
  let hook = fiber.memoizedState;
  while (hook) {
    if (hook.tag === 'PassiveEffect') {
      // 第一步:执行上一次的 cleanup(如果有)
      if (hook.cleanup) {
        hook.cleanup(); // 比如卸载前删监听
      }
      // 第二步:执行当前的 create,保存 cleanup
      hook.cleanup = hook.create(); // create 返回的函数(比如删监听)
    }
    hook = hook.next;
  }
}

执行时机对应:

  • mount 时无旧 cleanup,直接执行 create(加监听),保存 cleanup(删监听);
  • update 且 deps 变化时先执行旧 cleanup(删监听)→ 再执行新 create(加监听)→ 保存新 cleanup;
  • unmount 时遍历所有 EffectHook,执行 cleanup(删监听)。
  • 所以都是先执行useEffect,拿到返回值保存cleanup不执行,然后再下一次先执行cleanup(),然后再执行useEffect
步骤 4:最终效果(对比类组件)
// Hook 写法:逻辑完全收敛
function HookApp() {
  const [width, setWidth] = useState(window.innerWidth);
  
  useEffect(() => {
    // create:挂载/更新时执行(deps 变化)
    const handleResize = () => setWidth(window.innerWidth);
    window.addEventListener('resize', handleResize);
    // cleanup:卸载/更新前执行
    return () => window.removeEventListener('resize', handleResize);
  }, []); // 空 deps → 仅 mount/unmount 执行

  return <div>{width}</div>;
}
  • 逻辑收敛:加监听、删监听的逻辑都在一个 useEffect 里,按功能聚合;
  • 时机控制:deps 决定执行时机([]=仅 mount/unmount,[width]=width 变化时执行);
  • 自动清理:React 帮你管理 cleanup 的执行,无需手动在多个生命周期中处理。

三、解决逻辑耦合:从“分散在类中”到“自定义 Hook 封装 + fiber 隔离”

1. 类组件的具体痛点(带代码例子)

一个“数据请求+加载状态+取消请求”的逻辑,和其他逻辑挤在同一个类中,耦合严重:

// 类组件:多个逻辑耦合在生命周期中
class ClassApp extends React.Component {
  state = { data: null, loading: true, error: null };

  componentDidMount() {
    // 逻辑1:数据请求
    this.controller = new AbortController();
    fetch('/api/data', { signal: this.controller.signal })
      .then(res => res.json())
      .then(data => this.setState({ data, loading: false }))
      .catch(err => this.setState({ error, loading: false }));

    // 逻辑2:埋点统计(无关逻辑,却和请求挤在一起)
    trackPageView();
  }

  componentWillUnmount() {
    // 逻辑1的清理:取消请求
    this.controller.abort();
  }

  render() {
    // 逻辑1的 UI:加载/数据/错误展示
    if (this.state.loading) return <div>加载中</div>;
    if (this.state.error) return <div>错误</div>;
    return <div>{this.state.data.name}</div>;
  }
}

痛点本质:不同功能的逻辑都挤在同一个生命周期/类中,没有边界,复用困难。

2. Hook 的解决思路 + 完整实现过程

核心逻辑:用自定义 Hook 把“一个独立功能的所有逻辑(状态+副作用)”封装成一个函数,遵循 Hook 顺序规则,通过 fiber 保证不同组件的 Hook 状态隔离

步骤 1:封装自定义 Hook——聚合独立逻辑

把“数据请求”逻辑封装成 useRequest,内部调用基础 Hook:

// 自定义 Hook:封装请求逻辑(独立、内聚)
function useRequest(url) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    // 请求逻辑
    const controller = new AbortController();
    fetch(url, { signal: controller.signal })
      .then(res => res.json())
      .then(setData)
      .catch(setError)
      .finally(() => setLoading(false));

    // 清理逻辑
    return () => controller.abort();
  }, [url]); // 依赖 url,url 变了重新请求

  // 返回状态/方法,供组件使用
  return { data, loading, error };
}
步骤 2:组件执行自定义 Hook——Hook 节点加入当前 fiber

当函数组件执行 const { data, loading } = useRequest('/api/data') 时:

  1. useRequest 内部的 useState/useEffect 会依次调用;
  2. 这些基础 Hook 会创建对应的 Hook 节点,按顺序加入当前组件的 fiber.memoizedState 链表;hook和hook之间独立互不干扰
  3. 由于每个组件有自己的 fiber,useRequest 在组件 A 中调用时,Hook 节点存在 A 的 fiber 中;在组件 B 中调用时,存在 B 的 fiber 中——状态完全隔离
步骤 3:其他逻辑单独封装——彻底解耦

把“埋点统计”也封装成自定义 Hook,和请求逻辑完全分离:

// 自定义 Hook:封装埋点逻辑
function usePageTrack() {
  useEffect(() => {
    trackPageView();
  }, []);
}

// 组件:按需引入不同的自定义 Hook,逻辑无耦合
function HookApp() {
  const { data, loading, error } = useRequest('/api/data');
  usePageTrack(); // 埋点逻辑

  if (loading) return <div>加载中</div>;
  if (error) return <div>错误</div>;
  return <div>{data.name}</div>;
}
  • 逻辑边界:请求逻辑在 useRequest,埋点逻辑在 usePageTrack,互不干扰
  • 复用性:useRequest 可以复用到任意需要请求数据的组件,只需传不同的 url
  • 隔离性:不同组件调用 useRequest,状态存在各自的 fiber 中,不会互相影响。

四、解决逻辑与 UI 耦合:从“混在类中”到“Hook 管逻辑,组件管 UI”

1. 类组件的具体痛点(带代码例子)

表单验证逻辑和 UI 渲染混在同一个类中,复用验证逻辑需要套高阶组件(HOC),增加层级:

// 类组件:验证逻辑 + UI 渲染耦合
class ClassForm extends React.Component {
  state = { name: '', phone: '', errors: {} };

  // 验证逻辑(和 UI 混在一起)
  validate = () => {
    const errors = {};
    if (!this.state.name) errors.name = '必填';
    if (!this.state.phone) errors.phone = '必填';
    this.setState({ errors });
    return Object.keys(errors).length === 0;
  };

  handleChange = (e) => {
    this.setState({ [e.target.name]: e.target.value });
  };

  handleSubmit = (e) => {
    e.preventDefault();
    if (this.validate()) alert('提交成功');
  };

  // UI 渲染(和验证逻辑在同一个类)
  render() {
    return (
      <form onSubmit={this.handleSubmit}>
        <input name="name" onChange={this.handleChange} />
        {this.state.errors.name && <span>{this.state.errors.name}</span>}
        <input name="phone" onChange={this.handleChange} />
        {this.state.errors.phone && <span>{this.state.errors.phone}</span>}
        <button type="submit">提交</button>
      </form>
    );
  }
}

// 复用验证逻辑:需要写 HOC,增加组件层级
function withFormValidate(WrappedComponent) {
  return class extends React.Component {
    // 复制上面的 validate/handleChange 逻辑...
    render() {
      return <WrappedComponent {...this.props} {...this.state} validate={this.validate} />;
    }
  };
}

痛点本质:类组件是“一个容器”,既装逻辑又装 UI,复用逻辑必须带 UI 一起,或用复杂的 HOC/Render Props 绕开。

2. Hook 的解决思路 + 完整实现过程

核心逻辑:自定义 Hook 只负责“逻辑+状态”(无 UI),组件只负责“接收状态+渲染 UI”,通过函数返回值传递状态/方法,renderWithHooks 保证逻辑与组件的绑定

步骤 1:自定义 Hook——纯逻辑,无 UI

把表单验证逻辑封装成 useFormValidate,只返回状态/方法,不返回 JSX:

// 自定义 Hook:仅处理验证逻辑(无任何 UI)
function useFormValidate(initialValues) {
  const [values, setValues] = useState(initialValues);
  const [errors, setErrors] = useState({});

  // 验证逻辑
  const validate = () => {
    const newErrors = {};
    if (!values.name) newErrors.name = '必填';
    if (!values.phone) newErrors.phone = '必填';
    setErrors(newErrors);
    return Object.keys(newErrors).length === 0;
  };

  // 输入变更逻辑
  const handleChange = (e) => {
    setValues({ ...values, [e.target.name]: e.target.value });
  };

  // 只返回状态/方法,不返回 UI
  return { values, errors, handleChange, validate };
}
步骤 2:组件——纯 UI,无核心逻辑

组件引入自定义 Hook,接收状态/方法,只负责渲染 UI:

// 组件:仅处理 UI 渲染,逻辑全靠 Hook
function HookForm() {
  // 引入验证逻辑,拿到状态/方法
  const { values, errors, handleChange, validate } = useFormValidate({
    name: '',
    phone: ''
  });

  const handleSubmit = (e) => {
    e.preventDefault();
    if (validate()) alert('提交成功');
  };

  // 只关心 UI 怎么渲染,无验证逻辑
  return (
    <form onSubmit={handleSubmit}>
      <input name="name" onChange={handleChange} value={values.name} />
      {errors.name && <span>{errors.name}</span>}
      <input name="phone" onChange={handleChange} value={values.phone} />
      {errors.phone && <span>{errors.phone}</span>}
      <button type="submit">提交</button>
    </form>
  );
}
步骤 3:底层绑定——逻辑属于组件,却不耦合 UI

HookForm 执行时,renderWithHooks 会把 HookForm 的 fiber 绑定到全局,useFormValidate 内部的 useState/useEffect 会自动关联到这个 fiber:

  • 逻辑归属:useFormValidate 的状态是 HookForm 的状态,不是全局状态;
  • 逻辑复用:把 useFormValidate 引入 RegisterForm 组件,只需改 UI 渲染部分,验证逻辑完全复用;
  • 无额外层级:相比 HOC,自定义 Hook 不会增加组件树层级,UI 结构更清晰。

自定义hook调用顺序

我用具体例子拆解,让你看得更直观:

第一步:先看代码结构(自定义 Hook 写在组件前面)

// 自定义 Hook:封装表单验证逻辑
function useFormValidate(initialValues) {
  // 自定义 Hook 内部的基础 Hook 1
  const [values, setValues] = useState(initialValues);
  // 自定义 Hook 内部的基础 Hook 2
  const [errors, setErrors] = useState({});
  // 自定义 Hook 内部的基础 Hook 3
  useEffect(() => {
    console.log("验证规则初始化");
  }, []);

  const validate = () => { /* 验证逻辑 */ };
  const handleChange = () => { /* 输入变更逻辑 */ };

  return { values, errors, validate, handleChange };
}

// 组件:使用自定义 Hook(写在自身 Hook 前面)
function HookForm() {
  // 第一步:执行自定义 Hook(内部的基础 Hook 先执行)
  const { values, errors, validate, handleChange } = useFormValidate({
    name: "",
    phone: ""
  });

  // 第二步:执行组件自身的基础 Hook(后执行)
  const [submitCount, setSubmitCount] = useState(0); // 组件自身 Hook 1
  const [isSubmitting, setIsSubmitting] = useState(false); // 组件自身 Hook 2

  const handleSubmit = () => { /* 提交逻辑 */ };

  return <form>{/* 渲染逻辑 */}</form>;
}

第二步:拆解执行顺序 + Hook 链表插入过程

HookForm 执行、触发 renderWithHooks 并绑定其 fiber 到全局后,整个 Hook 执行/链表插入流程是:

执行步骤 执行的 Hook 插入到 HookForm fiber 链表的位置
1 useFormValidate 内的 useState(values) 链表第 1 位
2 useFormValidate 内的 useState(errors) 链表第 2 位
3 useFormValidate 内的 useEffect 链表第 3 位(副作用链表同步插入)
4 HookForm 自身的 useState(submitCount) 链表第 4 位
5 HookForm 自身的 useState(isSubmitting) 链表第 5 位

最终,HookForm 的 fiber 中 memoizedState(Hook 链表)结构是:

HookForm fiber.memoizedState
  └─ 第1位:values 的 Hook 节点
  └─ 第2位:errors 的 Hook 节点
  └─ 第3位:useEffect 的 Hook 节点
  └─ 第4位:submitCount 的 Hook 节点
  └─ 第5位:isSubmitting 的 Hook 节点

第三步:关键结论(强化你的理解)

  1. 自定义 Hook 无“独立 fiber”:自定义 Hook 本身不会生成新的 fiber,它只是“批量调用基础 Hook 的函数”,所有内部基础 Hook 都归属于使用它的组件的 fiber
  2. 执行顺序 = 链表顺序:完全由代码书写顺序决定——自定义 Hook 写在组件内前面,其内部 Hook 就先执行、先入链表;写在后面则反之;
  3. 规则不变:自定义 Hook 内部的基础 Hook,依然要遵守“不能在条件/循环中调用”的规则(因为最终会融入组件的 Hook 链表,顺序乱了会导致状态错位)。

反例验证(自定义 Hook 写在组件 Hook 后面)

如果把代码改成这样:

function HookForm() {
  // 第一步:组件自身 Hook 先执行
  const [submitCount, setSubmitCount] = useState(0);
  
  // 第二步:自定义 Hook 后执行
  const { values, errors } = useFormValidate({ name: "", phone: "" });
  
  // 第三步:组件自身另一个 Hook
  const [isSubmitting, setIsSubmitting] = useState(false);
}

则链表顺序变成: submitCount(1)→ values(2)→ errors(3)→ useEffect(4)→ isSubmitting(5)

总结

  1. 自定义 Hook 内的 useState/useEffect 会 插入到使用它的组件 fiber 的 Hook 链表中,而非独立存在;
  2. 执行顺序完全遵循“代码书写顺序”:自定义 Hook 写在前面,其内部 Hook 先执行、先入链表;组件自身 Hook 后执行、后入链表;
  3. 这也是自定义 Hook 能“复用逻辑但不隔离状态”的原因——所有状态最终都存在组件的 fiber 中,不同组件调用同一个自定义 Hook,会在各自的 fiber 链表中生成独立的 Hook 节点,状态互不干扰。

简单说:自定义 Hook 就是“把组件内的一段 Hook 逻辑抽成函数”,调用它等价于把这段 Hook 逻辑“粘贴”到组件的对应位置,执行/插入顺序完全不变。

自定义hook 是不是和 vue 的mixin有点像?

一、先承认相似性:表层都是“逻辑复用”

特性 自定义 Hook Vue mixin
核心目的 复用组件中的 Hook 逻辑(比如表单验证、防抖) 复用组件中的选项逻辑(比如 data、methods、生命周期)
接入方式 组件内直接调用(const { validate } = useForm() 组件内通过 mixins: [formMixin] 混入
状态归属 最终归属于使用它的组件(Hook 插入组件 fiber 链表) 最终归属于使用它的组件(mixin 的 data/methods 合并到组件实例)

简单说:二者都是“把通用逻辑抽离出来,让多个组件能复用”,这是你能直观感受到的相似性。

二、核心差异:Hook 是“显式复用”,mixin 是“隐式混入”(天差地别)

这是最关键的区别,也是 Hook 能替代 mixin 这类方案的核心原因:

维度 自定义 Hook Vue mixin
作用域 & 命名冲突 完全无冲突:
1. 自定义 Hook 内部的 useState/useEffect 按顺序插入组件 fiber 链表,状态是「显式返回」给组件(比如 const { count } = useCount());
2. 变量名由组件自己决定,哪怕多个 Hook 返回同名变量,组件可以重命名(const { count: aCount } = useCount())。
极易冲突:
1. mixin 的 data/methods 会「隐式合并」到组件实例,比如两个 mixin 都定义了 count,后混入的会覆盖先混入的;
2. 组件无法提前预知 mixin 定义了什么,调试时不知道 count 来自哪个 mixin。
逻辑溯源 清晰可追溯:
组件里调用 useForm() 的位置就是逻辑入口,点进 useForm 就能看到所有相关逻辑,Hook 之间的依赖也是显式的(比如传参)。
混乱难溯源:
多个 mixin 混入后,组件的 created 生命周期可能由 3 个 mixin + 组件自身共同构成,无法直观知道某段逻辑来自哪个 mixin。
状态隔离 天然隔离:
不同组件调用同一个自定义 Hook,会在各自的 fiber 链表中生成独立的 Hook 节点,状态互不干扰(比如 A 组件的 count 和 B 组件的 count 是两个独立的 Hook 节点)。
需手动隔离:
mixin 的 data 是「共享模板」,如果 mixin 定义了 count: 0,多个组件使用时,若不手动处理(比如用函数式 data),可能出现状态污染(极少数场景,但易踩坑)。
逻辑组合 灵活组合:
自定义 Hook 可以嵌套调用其他 Hook(useForm 里调用 useValidate),逻辑分层清晰,像“搭积木”。
组合混乱:
多个 mixin 混入后,逻辑是“平铺式”的,mixin 之间无法直接通信(需通过组件实例),复杂场景下会变成“mixin 地狱”。

三、结合你学的 Hook 底层,理解差异的本质

自定义 Hook 之所以能解决 mixin 的痛点,核心是「Hook 的状态归属于组件 fiber,且逻辑是显式暴露的」:

  1. 自定义 Hook 内部的 useState 最终会变成组件 fiber 链表中的一个节点,状态是组件“自有”的,而非“外来混入”的;
  2. 自定义 Hook 把状态/方法通过返回值暴露给组件,组件必须「显式接收」——相当于“你要什么,我给什么”,而 mixin 是“我把一堆东西塞给你,你不知道有什么”;
  3. 哪怕多个自定义 Hook 往组件 fiber 链表中插入节点,因为是「按调用顺序插入」,且返回值由组件命名,完全不会冲突(比如 useCount1 的 count 是链表第 1 位,useCount2的 count 是链表第 3 位,组件接收时可以叫count1/count2)。

而 Vue mixin 的问题在于:它是「隐式合并」到组件实例,相当于“往组件的 data/methods 里偷偷加东西”,既不知道加了什么,也容易和组件自身的逻辑冲突。

总结

  1. 表层相似:自定义 Hook 和 mixin 都是为了复用组件逻辑;
  2. 核心差异:Hook 是「显式、可控、隔离」的逻辑复用(状态归组件 fiber,返回值显式接收),mixin 是「隐式、混乱、易冲突」的逻辑混入(状态合并到组件实例,无明确边界);
  3. 本质原因:Hook 依托于 React 按顺序插入组件 fiber 链表的机制,天然和组件状态绑定,而 mixin 是“无边界的逻辑合并”,这也是 React 放弃 mixin 转而推 Hook 的核心原因。

简单说:你可以把自定义 Hook 理解为「升级版的 mixin」——保留了“逻辑复用”的核心价值,又解决了 mixin 所有的致命痛点。

回归初心,React为啥当初要设计Hook?

背景

用了这么久 React,已经默认用函数写组件了,都快忘了怎么写类组件了,经历过写类组件的时代,现在用函数组件,一开始确实有上手时间成本,但是现在用过之后也是觉得真香,到底香在哪?到底React为啥推崇Hook呢?我们一起探究一下吧

对「类组件痛点」的直接回应

让我们回到 类组件开发时代,看看到底类组件的诟病是什么?

类组件痛点1: this 指向混乱

类组件this丢失问题

类组件的方法默认不会绑定 this,导致我们必须手动处理 this 指向,稍不注意就会出错。这是为什么

你想弄明白为什么类组件的方法默认不绑定 this,以及为什么这会导致指向混乱的问题,核心原因要从 JavaScript 中 this 的绑定规则React 类组件的设计方式 两个层面来解释,我会用通俗的语言+实例讲清楚。

一、先搞懂:JavaScript 中 this 指向的核心规则

this 是 JavaScript 中一个「动态绑定」的关键字——它的指向不是在定义函数时确定的,而是在调用函数时确定的。这是理解所有 this 问题的根本。

关键规则:不同调用方式,this 指向不同

我们用一个普通的 JavaScript 类来举例(React 类组件本质就是 JavaScript 类):

class Person {
  constructor(name) {
    this.name = name; // 这里的 this 指向 Person 的实例
  }

  sayHi() {
    console.log(`你好,我是${this.name}`); // this 指向谁?看调用方式
  }
}

const p = new Person('张三');

// 方式1:通过实例调用 → this 指向实例 p
p.sayHi(); // 输出:你好,我是张三

// 方式2:把方法单独抽出来调用 → this 丢失(非严格模式下指向 window,严格模式下是 undefined)
const sayHi = p.sayHi;
sayHi(); // 非严格模式:你好,我是undefined;严格模式:Uncaught TypeError: Cannot read property 'name' of undefined

核心结论:

  • 当你通过 实例.方法() 调用时,this 绑定到这个实例;
  • 当你把方法「单独提取」后调用(比如赋值给变量、作为回调函数),this 就会丢失原本的绑定,指向全局对象(浏览器中是 window)或 undefined(严格模式)。

二、React 类组件中 this 丢失的具体场景

React 类组件的方法丢失 this,本质就是上面的「方式2」——React 在处理事件回调时,会把你的方法「单独提取」后调用,导致 this 不再指向组件实例。

场景还原:React 类组件的点击事件
class Counter extends React.Component {
  constructor(props) {
    super(props);
    this.state = { count: 0 };
  }

  handleClick() {
    // 这里的 this 本该指向 Counter 实例,但实际是 undefined(React 默认开启严格模式)
    this.setState({ count: this.state.count + 1 });
  }

  render() {
    // 问题出在这里:你把 this.handleClick 作为 onClick 的回调 → 相当于把方法抽离了
    return <button onClick={this.handleClick}>+1</button>;
  }
}

为什么会这样?

  1. render 函数执行时,你传给 onClick 的是 this.handleClick——这只是把方法的「引用」传了过去,并没有立即调用
  2. 当用户点击按钮时,React 内部会调用这个方法(类似 const callback = this.handleClick; callback());
  3. 此时调用的是「孤立的方法」,不是通过 实例.handleClick() 调用,所以 this 丢失,指向 undefined(React 严格模式下)。

三、为什么 React 不默认帮我们绑定 this

你可能会问:React 为什么不直接把类组件的方法都自动绑定到实例上?核心原因是:

  1. 遵循 JavaScript 原生规则:React 是基于 JavaScript 构建的库,不会刻意修改 JS 原生的 this 绑定逻辑,否则会增加学习成本和潜在的兼容性问题;
  2. 性能与灵活性:如果默认绑定所有方法,会为每个组件实例创建额外的函数引用,增加内存开销;而让开发者手动处理,能根据实际需求选择绑定方式(比如只绑定需要的方法)。

我用大白话来解释下,其实就是 :放手让开发者去设置this,我不掺合了

React 「不主动替你做绑定,只遵循 JS 原生规则,把控制权完全交给你」。

用更通俗的话讲:

✅ React 的态度:「我不掺合 this 的绑定逻辑,你按 JavaScript 原生的规矩来就行——想让 this 指向实例,你就自己绑;想让 this 指向别的(比如子组件、全局),你也可以自己改。我只负责把你写的方法『原样调用』,不替你做任何额外的绑定操作。」

对比一下如果 React 主动掺合的情况:

❌ 要是 React 自动把所有方法的 this 绑到组件实例,相当于「我替你做了决定」——你想改 this 指向都改不了,还得额外学 React 这套「特殊规则」,反而更麻烦。

最终核心总结
  1. React 对类组件 this 的态度:不干预、不修改、只遵循原生 JS 规则
  2. 把「this 该指向哪」的决定权,完全交给开发者;
  3. 这么做既避免了学习成本翻倍,也兼顾了性能(按需绑定)和灵活性(可自定义 this 指向)。

你这个「放手让开发者去设置,我不掺合」的总结,精准抓住了 React 设计的核心——尊重原生 JS,把控制权还给开发者

四、类组件中解决 this 丢失的 3 种常用方式

知道了原因,再看解决方案就很清晰了,本质都是「强制把 this 绑定到组件实例」:

方式 1:在构造函数中手动 bind(官方早期推荐)
constructor(props) {
  super(props);
  this.state = { count: 0 };
  // 核心:把 handleClick 的 this 强制绑定到当前实例
  this.handleClick = this.handleClick.bind(this);
}
方式 2:使用箭头函数定义方法(最简洁)
// 箭头函数没有自己的 this,会继承外层作用域的 this(即组件实例)
handleClick = () => {
  this.setState({ count: this.state.count + 1 });
};
方式 3:在调用时用箭头函数包裹(不推荐,每次渲染创建新函数)
// 每次 render 都会创建一个新的箭头函数,可能导致子组件不必要的重渲染
<button onClick={() => this.handleClick()}>+1</button>;

总结

  1. 根本原因:JavaScript 中 this 是「调用时绑定」的,React 类组件把方法作为事件回调时,方法会被孤立调用,导致 this 丢失(不再指向组件实例);
  2. 核心矛盾:React 遵循 JS 原生规则,没有默认绑定 this,而开发者容易忽略「调用方式」对 this 的影响;
  3. 解决方案:通过 bind、箭头函数等方式,强制把方法的 this 绑定到组件实例上。

函数组件: 彻底抛弃this,就不会有this指向问题了

核心原因

  1. 核心差异:类组件的状态/方法是「实例的属性」,必须通过 this 这个「中介」访问,中介指错了就出错;函数组件的状态/方法是「函数内的局部变量/函数」,直接访问即可,无需中介;
  2. 为什么无混乱:函数组件的局部变量/函数受「作用域」保护,只属于当前函数执行,不管怎么传递方法(比如作为事件回调),内部访问的变量都是确定的,不会像 this 那样动态变化;
  3. 最终结果:函数组件从根源上抛弃了 this,自然就避开了所有和 this 指向相关的坑,逻辑更简单、更安全。

简单来说,函数组件的写法「更符合普通人写 JS 的直觉」——定义的变量就在当前作用域,直接用就行,不用额外考虑「谁是 this」「this 指向谁」。

实质:用静态绑定 代替 动态绑定

这个总结精准到核心了!只需要再把「静态词法作用域」和「动态 this 绑定」的对应关系说透,就能彻底理解两者的本质区别。

先肯定你的结论:完全正确!

  • 类组件的 this调用时动态绑定 → 指向谁,取决于「怎么调用方法」,而非「方法在哪定义」;
  • 函数组件的变量/方法:定义时静态绑定(词法作用域) → 能访问哪些变量,取决于「代码写在哪」,而非「怎么调用函数」。

用最直白的例子+对比,讲透这个核心区别

1. 类组件:动态 this(调用时确定)
class Counter extends React.Component {
  state = { count: 0 };
  
  handleClick() {
    console.log(this.state.count); // this 指向「调用时的对象」
  }

  render() {
    // 场景1:直接传方法引用 → 调用时 this 是 undefined(丢失)
    return <button onClick={this.handleClick}>按钮1</button>;

    // 场景2:通过实例调用 → 调用时 this 是组件实例(正确)
    // return <button onClick={() => this.handleClick()}>按钮2</button>;
  }
}

关键:handleClick 里的 this不是定义时确定的——写代码时你不知道它指向谁,只有点击按钮、方法被调用的那一刻,才知道 this 是啥。

  • 按钮1:调用方式是「孤立调用」→ this = undefined;
  • 按钮2:调用方式是「实例.方法()」→ this = 组件实例。
2. 函数组件:静态词法作用域(定义时确定)
function Counter() {
  const [count, setCount] = useState(0);
  
  const handleClick = () => {
    console.log(count); // count 指向「定义时所在的作用域」
  };

  return <button onClick={handleClick}>按钮</button>;
}

关键:handleClick 里的 count定义时就确定了——它属于 Counter 函数执行时的局部作用域,不管 handleClick 被传到哪、怎么调用,它内部访问的 count 永远是当前 Counter 作用域里的变量。

  • 哪怕你把 handleClick 传给子组件、甚至全局调用,它依然能拿到 Counter 里的 count(因为词法作用域「锁死」了变量的查找路径);
  • 全程没有「动态绑定」,只有「静态的作用域查找」,所以永远不会找错变量。

再用「找路」的例子通俗解释

类组件(动态 this) 函数组件(词法作用域)
你告诉朋友:「到我家后,问主人在哪,然后跟主人走」→ 朋友到了之后,可能遇到假主人(this 指向错)、没主人(this=undefined),走丢; 你告诉朋友:「沿着XX路直走,到3号楼2单元」→ 路线是固定的(定义时就确定),不管朋友什么时候来、怎么来,按路线走都能到,不会错;

核心总结

  1. 类组件的坑:this动态绑定,指向由「调用方式」决定,写代码时无法确定,容易出错;
  2. 函数组件的优势:利用 JS 的静态词法作用域,变量的查找路径在「定义时就固定」,不管怎么调用函数,都能精准找到对应的变量;
  3. 最终结果:函数组件从根源上避开了「动态绑定」的不确定性,不用再纠结「this 指向谁」,逻辑更稳定。

精准抓到「动态调用绑定 vs 静态词法作用域」这个核心,说明我们已经完全理解了 Hooks 函数组件避开 this 坑的底层逻辑!

类组件痛点2: 业务相关逻辑碎片化

一个业务逻辑(比如「请求数据并渲染」)往往需要拆分到多个生命周期里,导致相关逻辑被分散在不同函数中,阅读和维护成本极高。

class UserList extends React.Component {
 state = { users: [], loading: true };

  // 1. 组件挂载时请求数据 -->
 componentDidMount() {
   this.fetchUsers();
   this.timer = setInterval(() => console.log('定时器'), 1000);
 }

  // 2. 组件更新时(比如 props 变化)重新请求数据 -->
 componentDidUpdate(prevProps) {
   if (prevProps.id !== this.props.id) {
     this.fetchUsers();
   }
 }

  // 3. 组件卸载时清理定时器 -->
 componentWillUnmount() {
   clearInterval(this.timer);
 }

 // 业务逻辑:请求用户数据
 fetchUsers() {
   fetch('/api/users')
     .then(res => res.json())
     .then(users => this.setState({ users, loading: false }));
 }

 render() { /* 渲染逻辑 */ }
}

你看:「请求数据 + 清理定时器」 这两个相关的逻辑,被拆到了 componentDidMount/componentDidUpdate/componentWillUnmount 三个生命周期里,代码跳来跳去,很难一眼看懂。

也就是说 业务逻辑强制和 react生命周期耦合到一起去了

类组件痛点3: 状态逻辑复用难、陷入嵌套地狱

先明确核心概念

  • 状态逻辑复用:比如「跟踪鼠标位置」「表单校验」「登录状态管理」这些逻辑,多个组件都需要用,想抽出来复用;
  • 嵌套地狱:为了复用逻辑,类组件只能用「高阶组件(HOC)」或「Render Props」,导致组件代码一层套一层,像剥洋葱一样难读。

第一步:看一个真实场景——复用「鼠标位置跟踪」逻辑

假设你有两个组件:MouseShow(显示鼠标位置)、MouseFollowBtn(按钮跟着鼠标动),都需要「跟踪鼠标位置」这个逻辑。

先写类组件的复用方案:Render Props(最典型的嵌套来源)

首先,把「鼠标跟踪」逻辑封装成一个通用组件(Render Props 模式):

// 通用的鼠标跟踪组件(Render Props 核心:把状态传给 children 函数)
class MouseTracker extends React.Component {
  state = { x: 0, y: 0 };

  // 监听鼠标移动,更新状态
  componentDidMount() {
    window.addEventListener('mousemove', this.handleMouseMove);
  }
  componentWillUnmount() {
    window.removeEventListener('mousemove', this.handleMouseMove);
  }
  handleMouseMove = (e) => {
    this.setState({ x: e.clientX, y: e.clientY });
  };

  // 核心:把状态传给子组件(通过 children 函数)
  render() {
    return this.props.children(this.state);
  }
}

然后,用这个组件实现「显示鼠标位置」:

// 第一个组件:显示鼠标位置
function MouseShow() {
  return (
    <div>
      <h2>鼠标位置:</h2>
      {/* 第一层嵌套:MouseTracker */}
      <MouseTracker>
        {/* children 是函数,接收鼠标状态 */}
        {({ x, y }) => (
          <p>X: {x}, Y: {y}</p>
        )}
      </MouseTracker>
    </div>
  );
}

再实现「按钮跟着鼠标动」:

// 第二个组件:按钮跟着鼠标动
function MouseFollowBtn() {
  return (
    <div>
      {/* 第一层嵌套:MouseTracker */}
      <MouseTracker>
        {({ x, y }) => (
          {/* 按钮样式绑定鼠标位置 */}
          <button style={{ position: 'absolute', left: x, top: y }}>
            跟着鼠标跑
          </button>
        )}
      </MouseTracker>
    </div>
  );
}
问题来了:如果要复用多个逻辑,嵌套直接「地狱化」

现在需求升级:这两个组件不仅要「跟踪鼠标」,还要「复用主题样式」(比如深色/浅色模式)。

先封装「主题复用」的 Render Props 组件:

// 通用的主题组件
class ThemeProvider extends React.Component {
  state = { theme: 'dark', color: 'white', bg: 'black' };

  render() {
    return this.props.children(this.state);
  }
}

现在,MouseShow 要同时复用「鼠标+主题」逻辑,代码变成这样:

function MouseShow() {
  return (
    <div>
      <h2>鼠标位置:</h2>
      {/* 第一层嵌套:ThemeProvider */}
      <ThemeProvider>
        {/* 接收主题状态 */}
        {({ theme, color, bg }) => (
          {/* 第二层嵌套:MouseTracker */}
          <MouseTracker>
            {/* 接收鼠标状态 */}
            {({ x, y }) => (
              <p style={{ color, backgroundColor: bg }}>
                【{theme}主题】X: {x}, Y: {y}
              </p>
            )}
          </MouseTracker>
        )}
      </ThemeProvider>
    </div>
  );
}

如果再要加一个「用户登录状态」的复用逻辑,就会出现第三层嵌套

<UserProvider>
  {({ user }) => (
    <ThemeProvider>
      {({ theme, color, bg }) => (
        <MouseTracker>
          {({ x, y }) => (
            <p>【{user.name}】【{theme}】X: {x}, Y: {y}</p>
          )}
        </MouseTracker>
      )}
    </ThemeProvider>
  )}
</UserProvider>

这就是嵌套地狱

  • 代码层层缩进,一眼看不到头;
  • 逻辑越复用,嵌套越深;
  • 想改某个逻辑(比如换主题),要在嵌套里找半天,维护成本极高。
补充:高阶组件(HOC)的复用方式,同样逃不开嵌套

如果用 HOC 实现复用(比如 withMousewithTheme),代码是这样的:

// 用 HOC 包装组件:一层套一层
const MouseShowWithTheme = withTheme(withMouse(MouseShow));

// 渲染时看似没有嵌套,但 HOC 本质是「组件套组件」,调试时 DevTools 里全是 HOC 包装层
// 比如 DevTools 里会显示:WithTheme(WithMouse(MouseShow))

调试时要一层层点开包装组件,才能找到真正的业务组件,同样痛苦。

函数组件说:没有hook,就别指望我了

千万别以为 函数组件是救星,React 早就有函数组件了,但是只是个纯展示的配角

先明确:Hooks 出现前,函数组件的「纯展示」本质

在 React 16.8(Hooks 诞生)之前,函数组件的官方定位就是 「无状态组件(Stateless Functional Component,SFC)」,核心特点:

  1. 没有自己的状态(this.state);
  2. 没有生命周期(componentDidMount/render 等);
  3. 本质就是「输入 props → 输出 JSX」的纯函数——输入不变,输出就不变,没有任何副作用。

举个 Hooks 前的函数组件例子:

// 典型的「纯展示组件」:只接收 props,渲染 UI,无任何状态/副作用
function UserCard(props) {
  const { name, avatar, age } = props;
  return (
    <div className="card">
      <img src={avatar} alt={name} />
      <h3>{name}</h3>
      <p>年龄:{age}</p>
    </div>
  );
}

// 使用时:状态全靠父组件传递
class UserList extends React.Component {
  state = {
    users: [{ name: '张三', avatar: 'xxx.png', age: 20 }]
  };

  render() {
    return (
      <div>
        {this.state.users.map(user => (
          <UserCard key={user.name} {...user} />
        ))}
      </div>
    );
  }
}

这个例子里:

  • UserCard 是函数组件,只负责「展示」,没有任何自己的逻辑;
  • 所有状态(users)、数据请求、生命周期逻辑,都必须写在父类组件 UserList 里;
  • 如果 UserCard 想加个「点击头像放大」的交互(需要状态 isZoom),对不起——函数组件做不到,必须把它改成类组件,或者把 isZoom 状态提到父组件里(增加父组件复杂度)。

为什么当时函数组件只能是「纯展示」?

核心原因是 React 的设计规则:

  • 状态、生命周期、副作用这些「动态能力」,当时只开放给类组件;
  • 函数组件被设计成「轻量、高效、无副作用」的最小渲染单元,目的是简化「纯展示场景」的代码(不用写 class/constructor 等冗余代码)。

Hooks说:函数组件你别灰心,我让你从配角变主角

先拆清楚:函数组件 vs Hooks 的分工

1. 光有「函数组件」,解决不了任何问题

在 Hooks 出现之前,React 早就有函数组件了,但那时的函数组件是「纯展示组件」—— 没有状态、没有生命周期,只能接收 props 渲染 UI。

如果想在旧版函数组件中复用状态逻辑,依然只能用「Render Props/HOC」的嵌套方式:

// Hooks 出现前的函数组件:想复用逻辑,还是得嵌套
function MouseShow() {
  return (
    <MouseTracker>
      {({x,y}) => <p>X:{x}, Y:{y}</p>}
    </MouseTracker>
  );
}

你看,哪怕是函数组件,没有 Hooks,依然逃不开嵌套 —— 因为没有「抽离状态逻辑」的工具。

2. Hooks 出现后:函数组件从「纯展示」→「全能选手」-- 痛点1迎刃而解

还是上面的 UserCard,Hooks 后可以直接加状态/副作用,不用依赖父组件:

import { useState } from 'react';

// 函数组件拥有了自己的状态,不再是「纯展示」
function UserCard(props) {
  const { name, avatar, age } = props;
  // 自己的状态:控制头像是否放大
  const [isZoom, setIsZoom] = useState(false);

  return (
    <div className="card">
      <img 
        src={avatar} 
        alt={name}
        style={{ width: isZoom ? '200px' : '100px' }}
        onClick={() => setIsZoom(!isZoom)} // 自己的交互逻辑
      />
      <h3>{name}</h3>
      <p>年龄:{age}</p>
    </div>
  );
}

此时函数组件的定位完全变了:

  • 既能做「纯展示」(简单场景),也能做「有状态、有副作用、有复杂逻辑」的完整组件;
  • 彻底替代了类组件的大部分场景,成为 React 官方推荐的写法。
  • 是不是 彻底解决了 痛点1

3. 相关逻辑「聚在一起」,告别碎片化 -- 痛点2 再见

useEffect 一个 Hook 就能覆盖挂载、更新、卸载三个阶段的逻辑,让「请求数据 + 清理定时器」这样的相关逻辑写在同一个地方。

import { useState, useEffect } from 'react';

function UserList({ id }) {
 const [users, setUsers] = useState([]);
 const [loading, setLoading] = useState(true);

 // 核心:请求数据 + 清理定时器 写在同一个 useEffect 里
 useEffect(() => {
   // 1. 挂载/更新时请求数据
   const fetchUsers = async () => {
     const res = await fetch(`/api/users?id=${id}`);
     const data = await res.json();
     setUsers(data);
     setLoading(false);
   };
   fetchUsers();

   // 2. 挂载时启动定时器
   const timer = setInterval(() => console.log('定时器'), 1000);

   // 3. 卸载/更新时清理副作用
   return () => clearInterval(timer);
 }, [id]); // 只有 id 变化时,才重新执行

 return <div>{loading ? '加载中' : users.map(u => <div key={u.id}>{u.name}</div>)}</div>;
}

对比类组件的写法:所有相关逻辑都在一个 useEffect,不用在多个生命周期函数之间跳来跳去,可读性直接拉满。

4. Hooks 才是「解决嵌套问题的核心」

Hooks(尤其是自定义 Hooks)的核心价值,是「把状态逻辑从组件渲染流程中抽离出来,变成可调用的纯逻辑函数」。

Hooks 的核心思路是:把「状态逻辑」从「组件嵌套」中抽离出来,变成独立的「函数」,组件直接调用函数即可,没有任何嵌套毕竟状态逻辑本来就跟 UI组件无关,为啥非要掺合在一起呢

还是上面的场景,用自定义 Hook 实现:

1. 抽离「鼠标跟踪」的自定义 Hook
import { useState, useEffect } from 'react';

// 自定义 Hook:抽离鼠标跟踪逻辑,返回鼠标位置
function useMousePosition() {
  const [position, setPosition] = useState({ x: 0, y: 0 });

  useEffect(() => {
    const handleMouseMove = (e) => setPosition({ x: e.clientX, y: e.clientY });
    window.addEventListener('mousemove', handleMouseMove);
    return () => window.removeEventListener('mousemove', handleMouseMove);
  }, []);

  return position;
}
2. 抽离「主题」的自定义 Hook
// 自定义 Hook:抽离主题逻辑,返回主题状态
function useTheme() {
  const [theme, setTheme] = useState({ mode: 'dark', color: 'white', bg: 'black' });
  return theme;
}
3. 组件中直接调用 Hook,没有任何嵌套
function MouseShow() {
  // 调用自定义 Hook:平铺写法,没有嵌套
  const { x, y } = useMousePosition(); // 复用鼠标逻辑
  const { mode, color, bg } = useTheme(); // 复用主题逻辑
  const { user } = useUser(); // 再复用用户逻辑,也只是多一行代码

  return (
    <p style={{ color, backgroundColor: bg }}>
      【{user.name}】【{mode}主题】X: {x}, Y: {y}
    </p>
  );
}
  • useState/useEffect 等内置 Hooks:让函数组件拥有了「状态」和「副作用处理能力」(这是抽离逻辑的基础);
  • 自定义 Hooks:把复用逻辑(比如鼠标跟踪、主题管理)封装成独立函数,让函数组件能「平铺调用」,而非「嵌套组件」。

对比类组件的嵌套地狱:

  • Hooks 是**平铺式复用**:不管复用多少逻辑,都是「调用函数 → 用状态」,代码没有任何缩进嵌套;
  • 逻辑和组件解耦:useMousePosition 可以在任何组件里调用,不用套任何包装组件;
  • 调试简单:DevTools 里直接看到 MouseShow 组件,没有层层包装的 HOC/Render Props 组件。

核心总结(痛点+解决方案)

类组件复用逻辑(HOC/Render Props) Hooks 复用逻辑(自定义 Hook)
必须通过「组件嵌套」实现 直接调用「函数」,无嵌套
逻辑越多,嵌套越深(地狱化) 逻辑越多,只是多几行函数调用
调试时要拆包装组件,成本高 调试直接看业务组件,逻辑清晰

简单来说,类组件的复用是「用组件套组件」,自然会嵌套;而 Hooks 的复用是「用函数抽逻辑」,组件只需要调用函数,从根源上消灭了嵌套地狱。 这也是 Hooks 最核心的价值之一——让状态逻辑复用变得简单、平铺、可维护

最后总结

  1. 函数组件是「容器」:提供了「平铺写代码」的基础形式,但本身没有复用状态逻辑的能力;
  2. Hooks 是「核心能力」
    • 内置 Hooks(useState/useEffect)让函数组件能「持有状态、处理副作用」给函数组件「加手脚」;
    • 自定义 Hooks 让状态逻辑能「脱离组件嵌套,以函数形式被平铺调用」「状态与 UI 解耦」
  3. 最终结论:不是函数的功劳,也不是单纯 Hook 的功劳 —— 是「函数组件作为载体」+「Hooks 作为逻辑复用工具」的组合,才解决了一系列问题

Hooks 是「矛」,函数组件是「握矛的手」—— 少了任何一个,都刺不穿嵌套地狱的盾。

❌