主题
hook函数的使用原则与自定义hook(Day7 2/3)
本页对应 React + TypeScript + 医通项目,共 7 段视频,约 1 小时 20 分。
学习目标
- 能够用图示或文字准确说明 props 在父传子、子传父、祖先传后代、兄弟组件通信中的传递路径,并指出其逐级传递的局限。
- 能够使用
createContext、Provider和useContext完成祖先组件向任意后代组件跨级传值,并验证中间组件无需中转。 - 能够使用 pubsub 的订阅、发布、按消息 ID 取消订阅、按消息名取消订阅和清空订阅 API,完成任意组件间通信并在组件卸载前正确取消订阅。
- 能够使用 pubsub 改写 TodoList 的添加和删除功能,并说明在
useEffect中直接使用状态导致闭包问题时,如何用setState函数式更新解决。 - 能够完成 axios 的
baseURL、timeout、请求/响应拦截器基础配置,并正确使用函数式与对象式语法发送 GET、POST、PUT、DELETE 等请求。
知识点
先分清:Hook 函数与普通函数的命名边界
为什么需要: React 要在函数组件渲染过程中识别“哪些函数是 Hook、哪些只是普通工具函数”。如果命名混乱,开发者容易把 useState、useRef 这类 Hook 放进普通函数里调用,最后得到“Invalid hook call”或 ESLint 报错。
怎么想: 笔记里说得很直接:Hook 和普通函数的区别首先在函数名——以 use 开头,后面接首字母大写的单词,React 就按 Hook 函数来识别。例如 usePosition 是自定义 Hook;App、Father 是函数组件;fn、save 是普通函数。命名不是装饰,而是告诉 React 和开发者:这个函数内部可能调用其他 Hook。
怎么做: 自定义 Hook 写成 useXxx,内部可以调用 useState、useRef 等 Hook;函数组件名首字母大写;普通工具函数不要随意用 use 开头,除非它确实是自定义 Hook。
易错点: 不要以为“名字写成 useSave 就自动安全”。名字只是约定,真正合法与否还取决于调用位置。转写稿里反复强调“先看报错再改”,本节调试也应如此:把 useState 放进 save,控制台会提示它既不是 React 函数组件,也不是自定义 Hook;改成 useSave 后,才符合“自定义 Hook 内部调用其他 Hook”的写法。
原则一:Hook 只能在函数组件中使用
为什么需要: Hook 依赖函数组件的渲染顺序来保存状态。类组件用的是 this.state、生命周期和实例机制,和 Hook 不是一套模型。
怎么想: 看到 class Xxx extends React.Component,就不要在里面写 useState、useEffect。类组件继续用 setState 和生命周期;函数组件才用 Hook。
怎么做: 把需要状态的 UI 写成函数组件,在组件顶层调用 Hook;类组件不要混用 Hook。
易错点: 不要因为“类组件里也能写函数”就把 Hook 塞进类方法。Hook 不是普通工具函数,它必须挂在 React 的函数组件渲染链路上。
原则二:Hook 只能在其他 Hook 中使用
为什么需要: 普通函数不一定在 React 渲染期间执行,React 无法把其中的 useState 关联到某个组件实例。笔记代码里注释掉的 save 就是反例:save 是普通函数,内部调用 useState 会报错。
怎么想: 如果一段逻辑需要复用 Hook,就把这段逻辑封装成自定义 Hook,而不是封装成普通函数。笔记里把 save 改成 useSave,就是为了让“普通函数”变成“自定义 Hook”。
怎么做: 写成 function useSave(){ let [count, setCount] = useState(100) },再在函数组件或其他自定义 Hook 的顶层调用它。
易错点: 自定义 Hook 也不能在事件回调里随便调用。笔记代码最后写了 onClick={useSave},这只能用于展示命名,不是正确用法:点击事件是普通回调,在里面调用 useSave 仍然违反 Hook 规则。正确思路是把 useState 放在组件顶层,事件里只调用普通函数。
原则三:Hook 数量必须确定,不能写在 if 或 for 中
为什么需要: React 靠 Hook 的调用顺序来匹配状态。如果这次渲染调用了 1 个 useState,下次因为 if 变成 0 个,或因为 for 变成 100 个,顺序就乱了。
怎么想: Hook 调用必须稳定:每次渲染都按同样顺序、同样数量执行。条件逻辑应放在 Hook 内部,或放在 Hook 返回值之后,而不是决定“要不要调用 Hook”。
怎么做: 把 useState、useRef、useEffect 都放在组件顶层。笔记里的 const usernameRef = useRef() 是合法的;if(true){ useState(...) } 和 for(...){ useState(...) } 都是反例。
易错点: 不要用“条件成立才创建状态”的写法。需要条件时,可以创建状态后再根据条件渲染;不要用 if、for、while 包裹 Hook。调试时把笔记里的 if、for 取消注释,控制台会提示 Hook 调用不符合规则;恢复注释后,组件顶层只保留稳定的 Hook 调用。
代码逐段解读与调试
以下代码保留笔记原样,其中注释掉的报错代码用于对比:
jsx
import React, { useRef, useState } from 'react'
export default function FunTest() {
const usernameRef = useRef();
// function save(){
// // 报错 :React Hook "useState" is called in function "save" that is neither a React function component nor a custom React Hook function.
// // React Hook“useState”在函数“save”中调用,该函数既不是React函数组件,也不是自定义React Hook函数。
// let [count, setCount] = useState(100)
// }
function useSave(){ // useSave 是自定义的hook函数,内部可以使用 其他hook
let [count, setCount] = useState(100)
}
// if(true){
// let [msg, setMsg] = useState('atguigu')
// }
for(let i=0; i<100;i++){
let [msg, setMsg] = useState('atguigu')
}
return (
<div>
<button onClick={useSave}>点击</button>
</div>
)
}这段代码要分三层看:useRef 在组件顶层,合法;save 是普通函数,内部调用 useState 会报错;useSave 是自定义 Hook,定义本身符合“以 use 开头”的约定。但 for 循环里的 useState 和 onClick={useSave} 都是易错点,实际项目里应把 Hook 调用移到组件顶层,事件回调里只做普通逻辑。转写稿里讲 useEffect 时强调“组件挂载完成后订阅、卸载前取消订阅”,本质也是要求 Hook 调用位置稳定;转写稿中“医保”是同音误写,应为“闭包”,本节先不展开。
一、自定义 Hook 的定位:复用逻辑,不是复用 UI
为什么需要:FuMing 和 Lili 都需要“鼠标移动时拿到坐标”的逻辑。如果每个组件各写一遍 useState + useEffect + addEventListener,代码重复,改一处要改多处。转写稿里反复强调 props 逐级传递的弊端,本质也是“相同数据/逻辑不要到处转交”;自定义 Hook 解决的是另一类重复:把组件内的状态逻辑抽出来复用。
怎么想:React 组件复用 UI,Hook 复用“带状态逻辑”。只要函数名以 use 开头,内部可以调用 useState、useEffect,就可以把坐标状态和鼠标监听封装起来。
怎么做:新建 hook/usePosition.js。注意笔记标题里的 usePostion 是拼写笔误,代码中实际是 usePosition,导入路径也要一致。
易错点:Hook 必须在组件或另一个 Hook 的顶层调用,不能放在 if、循环或普通回调里;函数名必须以 use 开头,否则 React 无法识别。
二、拆解 usePosition:状态、副作用与清理
js
import { useEffect, useState } from "react";
function usePosition() {
let [x, setX] = useState(110)
let [y, setY] = useState(110)
useEffect(() => {// componentDidMount
function move(e) {
console.log(e.clientX, e.clientY);
setX(e.clientX)
setY(e.clientY)
}
window.addEventListener('mousemove', move)
return () => {// componentWillUnmount
window.removeEventListener('mousemove', move)
}
}, [])
return {
x,y
}
}
export default usePosition;为什么需要:鼠标坐标来自浏览器事件,不是组件内部纯计算,所以要用 useEffect 建立外部事件监听;坐标会变化,所以要用 useState 保存。
怎么想:useEffect 的空依赖 [] 表示只在挂载时执行一次绑定;返回的函数在卸载时执行,用来移除监听。转写稿在 pubsub 部分强调“组件挂载时订阅、卸载时取消订阅”,这里的“绑定/移除 mousemove”是同一思路。
怎么做:在 move 中读取 e.clientX/e.clientY 并分别 setX/setY;把 move 定义在 effect 内部,保证绑定和移除使用同一个函数引用。
易错点:不要漏掉清理函数,否则组件卸载后监听仍在,反复挂载会重复绑定;依赖数组不要随意写 [x,y],否则每次坐标变化都会重新绑定监听;调试时先看控制台打印的坐标是否随鼠标变化,再检查页面定位。
三、FuMing:消费 Hook 返回值
jsx
import React, { useState } from 'react'
import { useEffect } from 'react'
import usePosition from '../hook/usePosition'
export default function FuMing() {
let {x,y} = usePosition()
return (
<div style={{position:'absolute',left:x,top:y,width:100,height:100,border:'2px solid green'}}>
FuMing
</div>
)
}为什么需要:组件只关心“当前坐标是多少”,不关心监听怎么绑定、状态怎么更新,所以调用 Hook 后解构 {x,y} 即可。
怎么想:usePosition 返回对象,组件用解构拿到坐标;left:x, top:y 直接驱动绝对定位。
怎么做:在组件顶层调用 usePosition(),把返回值用于 style。这里多导入的 useState/useEffect 不影响运行,但可删掉保持整洁。
易错点:不要在事件回调里调用 Hook;不要直接修改 Hook 返回的 x/y 来“改状态”,它们只是本次渲染的值。
四、Lili:同一 Hook 的第二次复用与偏移
jsx
import React, { useEffect, useState } from 'react'
import usePosition from '../hook/usePosition'
export default function Lili() {
let {x,y} = usePosition();
x -= 110
y -= 110
return (
<div style={{ position: 'absolute', left: x, top: y, width: 100, height: 100, border: '2px solid red' }}>Lili</div>
)
}为什么需要:Lili 也要跟随鼠标,但它的显示位置相对鼠标有偏移,所以复用坐标逻辑后,在组件内做业务偏移。
怎么想:Hook 负责“取坐标”,组件负责“怎么展示”。x -= 110 是对本次渲染的局部变量重新赋值,不会反向修改 Hook 内部状态。
怎么做:调用 usePosition() 后用 let 接收,再计算偏移并渲染。两个组件各自调用 Hook,会各自维护独立的 x/y 状态。
易错点:如果用 const {x,y} 再 x -= 110 会报错;偏移逻辑应放在渲染计算处,不要试图在 Hook 外改 Hook 状态;确认两个组件都导入同一个 usePosition 路径。
五、调试与规则检查
为什么需要:自定义 Hook 出错时,表面看是“坐标不动”,实际可能是监听没绑上、清理没执行、Hook 调用位置不对。
怎么想:按“事件是否触发 → 状态是否更新 → 返回值是否被消费 → 组件是否重复挂载”排查。
怎么做:保留 console.log(e.clientX, e.clientY) 看事件;移动鼠标观察两个框是否都动;切换页面/卸载组件后确认没有残留监听;检查两个组件是否都从 ../hook/usePosition 导入。
易错点:Hook 只能在顶层调用;effect 清理函数要移除同一个 move;不要把 usePosition 写成普通函数却在条件分支里调用。按这些点检查,FuMing 与 Lili 就能共享同一套鼠标坐标逻辑,而各自保留自己的展示偏移。
作业目标:把类组件版 TodoList 改成函数组件版
为什么需要: 类组件里的 state、生命周期和 this 写法,在函数组件中要换成 useState、useEffect 等 Hook。本节作业就是用函数组件重写 TodoList,并借 pubsub-js 完成跨组件通信。
怎么想: 先保留 TodoList 的数据源和列表渲染,再把“添加”“删除”从逐级 props 传递改成“谁发布消息、谁订阅消息”。转写稿里明确:Header 负责发布添加消息,TodoList 订阅后更新数据;Item 发布删除消息,TodoList 订阅后按 id 过滤。
怎么做: 组件拆成 TodoList、Header、Item。TodoList 用 useState 管理 todos,用 useEffect 在挂载后订阅消息,并在卸载时取消订阅。
易错点: 消息名必须前后一致;订阅产生的 id 要保存,卸载时用它取消订阅;列表 key 用 todo.id,不要用索引。
用 useState 管理 todos 并渲染列表
为什么需要: 函数组件没有 this.state,数据变化必须通过 useState 触发重新渲染。
怎么想: 初始数据放在 TodoList 里,结构包含 id、title、isDone。转写稿中初始值类似 { id: nanoid(), title: '吃饭', isDone: false }。
怎么做:
jsx
import { useState } from 'react'
import { nanoid } from 'nanoid'
import Header from './Header'
import Item from './Item'
export default function TodoList() {
const [todos, setTodos] = useState([
{ id: nanoid(), title: '吃饭', isDone: false }
])
return (
<div>
<Header />
<ul>
{todos.map(todo => (
<Item key={todo.id} todo={todo} />
))}
</ul>
</div>
)
}易错点: nanoid 要正确导入;map 必须有返回值;Item 接收的是整个 todo,不是只传标题。
用 pubsub-js 实现 Header 添加待办
为什么需要: 如果 Header 和 TodoList 不是直接父子关系,用 props 要逐级传递,比较绕。pubsub-js 可以实现任意组件通信。
怎么想: 谁产生数据谁发布:Header 在回车时发布 ADD_TODO,数据是输入框的 title;谁维护数据谁订阅:TodoList 订阅 ADD_TODO,收到 title 后追加一条 todo。
怎么做:
jsx
// Header.jsx
import PubSub from 'pubsub-js'
export default function Header() {
function handleKeyUp(e) {
if (e.key !== 'Enter') return
const title = e.target.value.trim()
if (!title) return
PubSub.publish('ADD_TODO', title)
e.target.value = ''
}
return <input placeholder="请输入待办" onKeyUp={handleKeyUp} />
}jsx
// TodoList.jsx 中补充订阅
import { useEffect } from 'react'
import PubSub from 'pubsub-js'
useEffect(() => {
const addId = PubSub.subscribe('ADD_TODO', (msg, title) => {
const newTodo = { id: nanoid(), title, isDone: false }
setTodos(prev => [...prev, newTodo])
})
return () => {
PubSub.unsubscribe(addId)
}
}, [])易错点: 不要在 useEffect 里直接写 setTodos([...todos, newTodo])。转写稿调试时发现“永远只能添加一条”,原因就是 useEffect 回调形成闭包,拿到的 todos 永远是初始值。必须用 setTodos(prev => ...) 的函数式更新。
用 pubsub-js 实现 Item 删除待办
为什么需要: 删除按钮在 Item 里,但真正要修改的是 TodoList 的 todos,同样适合用发布订阅。
怎么想: Item 点击删除时发布 DELETE_TODO,把 todo.id 作为数据传出去;TodoList 订阅后按 id 过滤。
怎么做:
jsx
// Item.jsx
import PubSub from 'pubsub-js'
export default function Item({ todo }) {
function handleDelete() {
PubSub.publish('DELETE_TODO', todo.id)
}
return (
<li>
<span>{todo.title}</span>
<button onClick={handleDelete}>删除</button>
</li>
)
}jsx
// TodoList.jsx 中再订阅删除消息
useEffect(() => {
const deleteId = PubSub.subscribe('DELETE_TODO', (msg, id) => {
setTodos(prev => prev.filter(todo => todo.id !== id))
})
return () => {
PubSub.unsubscribe(deleteId)
}
}, [])易错点: 删除也不能直接写 setTodos(todos.filter(...)),否则同样会受闭包影响,只能拿到初始 todos。转写稿里删除第二个待办时验证:按 id 过滤后只删对应项,不能把列表整体清空。
调试过程与检查清单
为什么需要: 发布订阅是“先订阅、后发布”,顺序和取消订阅容易漏。
怎么想: 先刷新页面让 TodoList 完成订阅,再在 Header 回车发布;点击删除时确认消息名、id 参数、过滤条件一致。
怎么做: 检查 PubSub.subscribe 的返回值是否保存;useEffect 返回函数里是否 unsubscribe;添加用 [...prev, newTodo],删除用 prev.filter(...)。
易错点: 消息名写成 ADD_TODO 和 add_todo 不一致会收不到;忘记 return 取消订阅会造成重复订阅;输入框发布后要清空;其余复选框等功能可按同样“Item 发布、TodoList 订阅”的思路完成。
课堂画面






代码示例
示例一:hook 函数的使用原则(自定义 hook 与三种违规写法)
jsx
// src/components/FunTest.jsx
import React, { useRef, useState } from 'react'
export default function FunTest() {
const usernameRef = useRef()
const [count, setCount] = useState(0)
// ❌ 错误1:在普通函数里调用 hook
// 报错:React Hook "useState" is called in function "save" that is
// neither a React function component nor a custom React Hook function.
// function save() {
// let [count, setCount] = useState(100)
// }
// ✅ 正确:函数名以 use 开头 + 首字母大写,React 才认它是自定义 hook
function useSave() {
let [num, setNum] = useState(100)
return { num, setNum }
}
// ❌ 错误2:写在 if 语句中
// if (true) {
// let [msg, setMsg] = useState('atguigu')
// }
// ❌ 错误3:写在 for 循环中(hook 数量必须是确定的)
// for (let i = 0; i < 100; i++) {
// let [msg, setMsg] = useState('atguigu')
// }
// ✅ 正确:自定义 hook 只能在组件顶层或其它 hook 里调用
const { num, setNum } = useSave()
return (
<div>
<input ref={usernameRef} placeholder="用户名" />
<p>count: {count}</p>
<button onClick={() => setCount(count + 1)}>count + 1</button>
<p>useSave.num: {num}</p>
<button onClick={() => setNum(num + 1)}>num + 1</button>
</div>
)
}要点:
useSave之所以能被识别成自定义 hook,唯一原因就是函数名use+ 首字母大写;叫save、fn都会被当成普通函数,内部调useState直接报错。- hook 只能在函数组件和自定义 hook 里调用,类组件里用不了。
if/for里调 hook 会报错:React 靠调用顺序来对应每个 hook 的状态,条件或循环会让顺序错乱,所以 hook 数量必须确定。- 上面的
if和for只注释掉不删除,是为了对照笔记里的报错现象;实际项目里绝对不能保留。
示例二:自定义 hook —— usePosition(封装鼠标跟随逻辑)
js
// src/hook/usePosition.js
import { useEffect, useState } from 'react'
function usePosition() {
let [x, setX] = useState(110)
let [y, setY] = useState(110)
useEffect(() => { // 相当于 componentDidMount
function move(e) {
setX(e.clientX)
setY(e.clientY)
}
window.addEventListener('mousemove', move)
return () => { // 相当于 componentWillUnmount
window.removeEventListener('mousemove', move)
}
}, []) // 空依赖:只挂载一次
return { x, y }
}
export default usePosition要点:
- 自定义 hook 的本质就是把组件里重复的逻辑抽出来复用,它本身不是组件,没有 JSX,只返回数据/方法。
- 函数名必须是
usePosition(use 开头 + 首字母大写),否则内部调用useState/useEffect会报错。 useEffect第二个参数传[],才能保证只注册一次监听;漏了[]会每次渲染都重复绑定。return里必须写removeEventListener做清理,否则组件卸载后监听还挂着,会内存泄漏(还会因setState报警告)。move函数定义在useEffect内部,是为了让return里的清理函数能拿到同一个函数引用;定义在外面就解绑不掉了。
示例三:多个组件复用同一个自定义 hook(FuMing + Lili)
jsx
// src/components/FuMing.jsx
import React from 'react'
import usePosition from '../hook/usePosition'
export default function FuMing() {
let { x, y } = usePosition()
return (
<div
style={{
position: 'absolute',
left: x,
top: y,
width: 100,
height: 100,
border: '2px solid green',
}}
>
FuMing
</div>
)
}jsx
// src/components/Lili.jsx
import React from 'react'
import usePosition from '../hook/usePosition'
export default function Lili() {
let { x, y } = usePosition()
x -= 110
y -= 110
return (
<div
style={{
position: 'absolute',
left: x,
top: y,
width: 100,
height: 100,
border: '2px solid red',
}}
>
Lili
</div>
)
}要点:
- 两个组件各自调用
usePosition(),得到的是各自独立的一份状态,互不干扰(hook 复用逻辑,不复用状态)。 - 两个组件都注册了
mousemove监听,所以都能跟着鼠标走;这正是自定义 hook 的价值——写一次逻辑,多处复用。 Lili里的x -= 110只是改了本次渲染用到的局部变量,不会改到 hook 内部的 state,页面再次渲染时又会从 hook 的原始值算起;如果想让偏移持久生效,应该在usePosition内部处理或另传参数。- 所以这里必须用
let解构,用const会直接报“常量不可重新赋值”的错。 - 使用前记得在父组件里渲染
<FuMing />和<Lili />,并确保两个文件路径../hook/usePosition正确,否则会报模块找不到。
常见错误
- 现象:
useState写进if、for或普通函数save中,报 “React Hook ... is called in ...”。原因:Hook 调用位置/顺序不符合规则。修法:只在函数组件或useXxx自定义 Hook 的顶层调用,条件/循环逻辑放到 Hook 内部或拆组件。 - 现象:自定义 Hook 不以
use开头,或组件调用后拿不到状态。原因:命名与调用约定混乱,把普通函数当 Hook。修法:统一命名为useXxx,只在函数组件/其他 Hook 顶层调用,并明确返回值。 - 现象:
useEffect中addEventListener后切换页面仍触发、报内存泄漏。原因:未清理副作用。修法:在useEffect中return () => removeEventListener(...)。 - 现象:PubSub 订阅回调里
setTodos([...todos, newTodo])永远只加一条,删除也基于旧列表。原因:useEffect闭包捕获初始todos。修法:改用setTodos(prev => ...),订阅与取消订阅放在同一useEffect。 - 现象:
useContext读不到Provider的value。原因:创建、Provider、useContext使用的不是同一个context对象,或后代未被包裹。修法:统一从同一模块导入context,确认Provider包裹目标后代。 - 现象:axios 拦截器配置后请求发不出,或响应拿不到
data。原因:请求拦截器忘记return config,响应拦截器忘记return response/response.data。修法:请求拦截器必须return config;响应拦截器明确返回简化后的数据,并统一调用处约定。 - 现象:axios 的
get/delete/post/put参数位置写错,query 与 body 混淆。原因:对象式用法分两类。修法:get/delete(url, config);post/put/patch(url, data, config)。 - 现象:401 未跳登录,或错误继续进入业务
then。原因:响应拦截器未清 token/重定向,错误分支未阻断 Promise 链。修法:错误分支清 token、跳登录;需要阻断时return new Promise(() => {})。
练习
- 改错题:给出包含
useState在if、for、普通函数中的代码,指出所有违规点并改成合法写法。 - 封装
usePosition,在FuMing、Lili两个组件中复用,要求补全鼠标监听清理逻辑。 - 用 PubSub 改写 TodoList 的添加/删除:写出订阅、取消订阅、发布位置,并解决回调中拿到旧
todos的问题。 - 用
useContext实现App → Son跨级传递username/age,要求Father不用 props 中转,写出关键步骤。 - axios 基础配置:配置
baseURL/timeout,请求拦截器带 token 和NProgress.start(),响应拦截器关闭 loading、简化响应数据、401 清 token 跳登录、统一错误处理;标出所有必须return的位置。
原视频对照
| # | 视频 | 时长 |
|---|---|---|
| 1 | props通信总结 | 06:09 |
| 2 | useContext实现祖先组件向任意后代组件通信 | 14:53 |
| 3 | pubsub实现任意组件通信 | 09:51 |
| 4 | pubsub基本使用 | 14:45 |
| 5 | pubsub改写todolist | 12:15 |
| 6 | axios基本配置使用 | 15:30 |
| 7 | axios请求基本用法 | 06:53 |
小结
- Hook 的硬约束是调用位置:函数组件/自定义 Hook 顶层,不能类组件、普通函数、条件或循环中调用。
- 自定义 Hook 复用逻辑而非 UI;返回值按调用方设计,副作用必须清理。
- props 适合父子及兄弟经父传递;跨级共享用 Context,前提是同一个
context对象且Provider包裹后代。 - PubSub 适合任意组件通信;订阅在挂载、取消在卸载,回调更新状态优先函数式更新避免闭包旧值。
- axios 基础配置统一
baseURL/timeout;拦截器必须return config/response,否则链路中断。 - 响应拦截器集中处理 loading 关闭、数据简化、401 重定向和错误兜底;错误阻断要显式处理。
- TodoList 重写重点是状态归属、通信方式选择、闭包与清理,而不是堆 API。