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 === null,renderWithHooks 会把 ReactCurrentDispatcher.current 切换为 HooksDispatcherOnMount(挂载版 Dispatcher),这也是首次渲染时 useState 会执行 mountState 初始化 Hook 的原因。
为什么你会误以为“首次渲染 fiber 是 null”?
大概率是混淆了两个点:
-
把
current当成了workInProgress:current首次渲染是 null,但它只是“旧 fiber 引用”,不是 Hook 依赖的“当前工作 fiber”; -
把
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,所以首次渲染时这个全局变量必然有值。
总结
- 首次渲染时,
renderWithHooks绑定的currentlyRenderingFiber(即workInProgress)不是 null,它是React 提前创建的、当前正在构建的 fiber 节点;也就是说在执行函数组件时提前创建的 - 首次渲染时
current是 null(无旧 fiber),这是切换“挂载版 Dispatcher”的判断依据; -
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)。如果不清空,旧的副作用会和本次的副作用混在一起,导致:
- 已失效的副作用被重复执行;
- 本次的副作用被旧副作用覆盖;
- 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 的「防御性编程」:
- 避免 fiber 节点复用导致的残留(比如热更新时,fiber 可能被复用);
- 保证所有渲染流程的一致性(首次/更新渲染执行相同的清空逻辑,减少分支判断)。
总结
清空 fiber 旧的 Hook 链表/副作用链表的核心目的是:
- 保证顺序一致:让新 fiber 的 Hook 链表完全匹配本次渲染的 Hook 调用顺序,避免状态错位;
- 保证副作用纯净:清除旧的副作用残留,避免重复执行、内存泄漏;
- 流程统一:首次/更新渲染执行相同逻辑,降低代码复杂度。
本质上,这是 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 中,而非 this;setCount 是绑定了当前 Hook 队列的函数,无需通过 this 调用。
步骤 3:update 阶段——从 fiber 读取 Hook 节点,闭包保存当前状态
当点击按钮调用 setCount(count + 1) 时:
-
dispatchAction会创建 update 对象({ action: count => count + 1 }),加入 Hook 的 queue; - React 触发重新渲染,再次调用
renderWithHooks,切换到HooksDispatcherOnUpdate; - 执行
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)]; } - 新状态
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') 时:
-
useRequest内部的useState/useEffect会依次调用; 这些基础 Hook 会创建对应的 Hook 节点,按顺序加入当前组件的 fiber.memoizedState 链表;hook和hook之间独立互不干扰-
由于每个组件有自己的 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 节点
第三步:关键结论(强化你的理解)
-
自定义 Hook 无“独立 fiber”:自定义 Hook 本身不会生成新的 fiber,它只是“批量调用基础 Hook 的函数”,所有内部基础 Hook 都归属于使用它的组件的 fiber; -
执行顺序 = 链表顺序:完全由代码书写顺序决定——自定义 Hook 写在组件内前面,其内部 Hook 就先执行、先入链表;写在后面则反之; -
规则不变:自定义 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)
总结
- 自定义 Hook 内的 useState/useEffect 会
插入到使用它的组件 fiber 的 Hook 链表中,而非独立存在; - 执行顺序完全遵循“代码书写顺序”:自定义 Hook 写在前面,其内部 Hook 先执行、先入链表;组件自身 Hook 后执行、后入链表;
- 这也是自定义 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,且逻辑是显式暴露的」:
- 自定义 Hook 内部的 useState 最终会变成组件 fiber 链表中的一个节点,状态是组件“自有”的,而非“外来混入”的;
自定义 Hook 把状态/方法通过返回值暴露给组件,组件必须「显式接收」——相当于“你要什么,我给什么”,而 mixin 是“我把一堆东西塞给你,你不知道有什么”;哪怕多个自定义 Hook 往组件 fiber 链表中插入节点,因为是「按调用顺序插入」,且返回值由组件命名,完全不会冲突(比如useCount1的 count 是链表第 1 位,useCount2的 count 是链表第 3 位,组件接收时可以叫count1/count2)。
而 Vue mixin 的问题在于:它是「隐式合并」到组件实例,相当于“往组件的 data/methods 里偷偷加东西”,既不知道加了什么,也容易和组件自身的逻辑冲突。
总结
- 表层相似:自定义 Hook 和 mixin 都是为了复用组件逻辑;
- 核心差异:Hook 是「显式、可控、隔离」的逻辑复用(状态归组件 fiber,返回值显式接收),mixin 是「隐式、混乱、易冲突」的逻辑混入(状态合并到组件实例,无明确边界);
- 本质原因:Hook 依托于 React 按顺序插入组件 fiber 链表的机制,天然和组件状态绑定,而 mixin 是“无边界的逻辑合并”,这也是 React 放弃 mixin 转而推 Hook 的核心原因。
简单说:你可以把自定义 Hook 理解为「升级版的 mixin」——保留了“逻辑复用”的核心价值,又解决了 mixin 所有的致命痛点。