This repository has been archived on 2026-05-24. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
all-in-kingsoft/hhs/REACT/2. Hooks 篇/05-核心 Hooks.md
T

14 KiB
Raw Blame History

tags, create time
tags create time
React
Hooks
useState
useEffect
useContext
useRef
Frontend
2026-04-29 22:04

核心 Hooks

概述

Hook 是 React 16.8 引入的特性,让函数组件拥有 State、副作用管理、上下文消费等原本只有 Class 组件才具备的能力。

[!question] 思考:在 Hook 出现之前,Class 组件有哪些痛点?

痛点 表现 Hook 的解法
状态逻辑复用 用 HOC / Render Props 嵌套过深("回调地狱") 自定义 Hook 直接抽离状态逻辑
职责分散 同一逻辑被拆分到 componentDidMount、componentDidUpdate、componentWillUnmount 同一 useEffect 内组合关联逻辑 + cleanup
this 指向混乱 需要手动 bind、箭头函数或类字段属性 函数组件没有 this,闭包天然解决
组件臃肿 大组件难以阅读和维护 按关注点拆分为多个 Hook

Hook 的设计哲学可以总结为一句话:把"组件做什么"和"组件什么时候做"解耦。

本文聚焦四个核心 Hook —— useState、useEffect、useContext、useRef,并在此基础上介绍性能优化 Hook 与自定义 Hook 实战。


Hook 执行规则

[!warning] 两条铁律

  1. 只在最顶层调用 Hook — 不能在循环、条件、嵌套函数中调用
  2. 只在 React 函数组件或自定义 Hook 中调用 — 不能在其他普通 JS 函数中调用
// ❌ 违反规则:条件调用 Hook
function Component({ loaded }) {
  if (loaded) {
    const [data, setData] = useState(null);  // 崩溃!
  }
}

// ✅ 正确:条件逻辑放在 Hook 内部
function Component({ loaded }) {
  const [data, setData] = useState(null);
  useEffect(() => {
    if (!loaded) return;
    fetch("/api/data").then(setData);
  }, [loaded]);
}

Hook 的执行顺序依赖调用顺序

React 内部用一个数组存储每个组件的 Hook 状态。每次渲染时,按声明顺序依次取出对应 Hook 的状态。这就是为什么必须在顶层、固定顺序调用。

function Counter() {
  const [count, setCount] = useState(0);      // Hook #0
  const theme = useContext(ThemeContext);     // Hook #1
  const btnRef = useRef<HTMLButtonElement>(); // Hook #2

  // 每次渲染都按这个顺序取状态,不能颠倒或删除
}

useState

基础用法

const [state, setState] = useState<T>(initialValue);

当初始值计算开销较大时,使用惰性初始化避免重复计算:

const [items, setItems] = useState(() => loadFromStorage());  // 仅首次渲染执行

闭包陷阱(Closure Trap)

[!warning] 高频踩坑点:React 每次渲染都创建全新的作用域和变量副本

const [count, setCount] = useState(0);
setTimeout(() => setCount(count + 1), 1000);  // ❌ count 始终为首次渲染时的快照值

为什么? React 的 setCount 不是异步函数,而是 闭包捕获了渲染时的变量快照 —— 第一次渲染后,handleClick 里的 count 就被锁死在 0 上了。

[!tip] 如何修复? 有两种思路:让 React 自己提供最新状态(updater),或者用 ref 持有一个始终更新的引用。具体方案与对比见 → 05-核心 Hooks/01-闭包陷阱

关键行为

[!tip] 核心认知 setState 是异步合并的 —— 你传给它的不是当前 state,而是基于"即将更新"的旧值计算新值。

场景 行为
对象/数组作为 state 引用比较——必须创建新对象/数组才会触发重渲染
连续多次 setState React 18 自动合并为一次 re-render
在非 React 代码中调用 使用 startTransition / flushSync 控制

不可变更新模式

const [list, setList] = useState([{ id: 1, done: false }]);

// ❌ 直接修改 —— 不会触发重渲染
list[0].done = true;
setList(list);

// ✅ 创建新引用 —— 正确触发更新
setList(prev => prev.map(item => item.id === 1 ? { ...item, done: true } : item));

[!note] 为什么要有不可变性? React 通过浅比较引用来判断是否需要重渲染。如果原地修改了原有对象,引用地址不变,React 认为"没变化"就跳过了 DOM 更新。这正是 React 高效的原因之一。


useEffect

基本模型

useEffect(() => {
  // 副作用逻辑
  return () => { /* cleanup */ };   // 可选清理函数
}, [dependencies]);                 // 依赖数组

三种行为模式

// 无依赖数组 → 每次渲染后执行(几乎不推荐)
useEffect(() => { console.log("every render"); });

// 空数组 [] → 仅挂载时执行一次
useEffect(() => {
  console.log("mount once");
  return () => console.log("unmount");
}, []);

// 有依赖项 → 任一依赖变化时重新执行
useEffect(() => {
  const sub = api.subscribe(userId);
  return () => sub.unsubscribe();
}, [userId]);  // userId 变了 → 先 clean 旧的 → 再执行新的

useEffect 生命周期时序

[!abstract] useEffect 的生命周期可以用下面的时序图理解:

sequenceDiagram
    participant R as React
    participant E as Effect
    participant C as Cleanup

    R->>E: 渲染完成,执行 effect
    Note over E: side effect 运行<br/>(数据请求、DOM 操作等)
    R->>R: 再次渲染...

    alt 依赖项变化
        R->>C: 先执行上一次 effect 的 cleanup
        R->>E: 执行新的 effect
    else 依赖项未变化
        R->>R: 跳过本次 effect
    end

[!question] 思考:cleanup 函数何时执行?

  • 组件卸载时
  • 下一次 effect 执行前(前提是依赖项发生了变化) 这意味着你可以用同一个 effect 同时处理"订阅 → 取消 → 重新订阅"整个流程。

常见副作用类型

// 数据获取
useEffect(() => {
  let cancelled = false;
  fetch("/api/data")
    .then(res => res.json())
    .then(data => { if (!cancelled) setData(data); });

  return () => { cancelled = true; };
}, []);

// DOM 事件监听
useEffect(() => {
  const handleResize = () => setWidth(window.innerWidth);
  window.addEventListener("resize", handleResize);
  return () => window.removeEventListener("resize", handleResize);
}, []);

// 定时轮询(用 ref 避免把 fetchData 写入依赖数组导致频繁清理)
useEffect(() => {
  const fetchData = () => {/* polling logic */};
  let mounted = true;

  const timer = setInterval(async () => {
    if (!mounted) return;
    try {
      const res = await fetch("/api/data");
      const json = await res.json();
      setData(json);
    } catch (err) {
      setError(err instanceof Error ? err : new Error(String(err)));
    }
  }, 5000);

  return () => { mounted = false; clearInterval(timer); };
}, []);

副作用分类指南

[!summary] 哪些该放 useEffect?哪些不该?

应该用 useEffect 不应该用 useEffect
数据获取、订阅、定时器 JSX 渲染中的同步逻辑
操作 DOM(focus、测量) 阻止表单提交、路由跳转
第三方库集成 事件处理函数体内(直接用 onClick 即可)

useMemo & useCallback

这两个 Hook 都是记忆化工具,核心目标相同:缓存计算结果,避免不必要的重新计算或子组件重渲染。

useMemo —— 缓存计算结果

const sortedItems = useMemo(() => items.sort(comparator), [items]);

[!important] useMemo 不等于"不用每次都算" useMemo 本身也有开销(缓存对比),它只是给开发者一个"提示 React:结果可以被复用"。只有在计算昂贵或在渲染期间产生高成本时才有意义。

useCallback —— 缓存函数引用

const handleClick = useCallback(
  (id: string) => { deleteItem(id); },
  [deleteItem]  // deleteItem 变化时返回新函数
);

两者关系与选择

quadrantChart
    title "useMemo vs useCallback 使用决策"
    x-axis "少用" --> "常用"
    y-axis "缓存计算" --> "缓存引用"
    "useCallback": [0.7, 0.9]
    "useMemo": [0.3, 0.9]
    "纯计算,传给子组件": [0.7, 0.5]
    "纯值,渲染中使用": [0.3, 0.5]
Hook 缓存什么 典型场景
useMemo 计算后的值 列表排序、过滤、复杂运算
useCallback 函数引用 传给 React.memo 包裹的子组件

三者联合:防止过度重渲染

function Parent() {
  const [text, setText] = useState("");

  // 缓存 handler 引用 —— 避免 Child 因 props.fn 变化而重渲染
  const handleSubmit = useCallback(
    (e: React.FormEvent) => { alert(text); },
    [text]
  );

  // 缓存 computed 值 —— 避免每次渲染都重新 sort/filter
  const filteredUsers = useMemo(
    () => users.filter(u => u.name.includes(text)),
    [users, text]
  );

  return <Child data={filteredUsers} onSubmit={handleSubmit} />;
}

[!example] 配合 React.memo 的效果

// Child 仅在 props 引用变化时才重渲染
const Child = React.memo(({ data, onSubmit }) => (
  <div>{/* ... */}</div>
));

如果没有 useCallback 和 useMemo,即使 data 和 onSubmit 内容没变,每次父组件渲染都会生成新引用,导致 Child 无效重渲染。


useContext

基本概念

Context 解决的是"跨层级传参"问题,无需逐层透传 props(又称 prop drilling)。

const ThemeContext = createContext<"light" | "dark">("light");

function App() {
  return (
    <ThemeContext.Provider value="dark">
      <Header />      {/* 不需要 Header 向 Button 传递 theme */}
      <Main />
    </ThemeContext.Provider>
  );
}

function Button() {
  const theme = useContext(ThemeContext);  // 任意深度消费
  return <button className={theme}>Click</button>;
}

最佳实践:封装自定义 Hook

const ThemeContext = createContext<"light" | "dark">("light");

// ❌ 不要到处写 useContext(ThemeContext)
// ✅ 抽取为语义化 Hook
function useTheme() {
  const ctx = useContext(ThemeContext);
  if (!ctx) throw new Error("useTheme must be used within a ThemeProvider");
  return ctx;
}

Context 的性能陷阱

[!warning] Provider 的 value 一旦变化,所有消费该 Context 的组件都会重渲染

因为每次 { value: count } 都是一个新对象,浅比较不等。

解决方案:

// 方案1:拆分成多个小 Context
const UserContext = createContext(user);
const ThemeContext = createContext(theme);

// 方案2:用 useReducer 保持 dispatch 引用稳定
function App() {
  const [state, dispatch] = useReducer(reducer, initial);
  return <MyContext.Provider value={{ state, dispatch }}>
    <Children />
  </MyContext.Provider>;
}

[!tip] 何时用 Context?

  • 适合"全局配置":主题、语言、用户信息
  • 不适合高频更新的值(频繁变化会导致大量重渲染)
  • 复杂状态管理考虑 Zustand / Jotai / Redux Toolkit

useRef

两个用途

// 用途1:持有可变值,修改不触发重渲染
const timerRef = useRef<number | null>(null);
const prevCountRef = useRef<string>("");

// 用途2:访问 DOM 元素
function FocusInput() {
  const inputRef = useRef<HTMLInputElement>(null);

  useEffect(() => { inputRef.current?.focus(); }, []);

  return <input ref={inputRef} />;
}

ref vs state 对比

维度 state ref
修改触发重渲染 ✅ ❌
跨渲染持久化 ✅ ✅
写入方式 setState(val) ref.current = val
典型用途 UI 驱动数据 DOM 句柄 / 定时器 / 临时计数器

[!note] 本质区别 ref 的 .current 是一个普通的 JavaScript 属性,修改它就像改任何对象的属性一样,React 完全不知道。State 是 React 管理的响应式数据,修改后会告诉 React "请重新渲染"。

实用技巧:保存上一次的值

function usePrevious<T>(value: T): T | undefined {
  const ref = useRef<T>();
  useEffect(() => { ref.current = value; });
  return ref.current;
}

// 用法
function Example({ value }) {
  const prev = usePrevious(value);
  // prev !== undefined && prev !== value → 值变更了
}

自定义 Hook 实战

自定义 Hook 是 React 中最强大的抽象手段之一 —— 它是一个普通函数,名字以 use 开头,内部可以调用其他 Hook,从而实现状态逻辑的跨组件复用。

示例:自动失焦 Hook

function useAutoFocus(delayMs: number = 300) {
  const ref = useRef<HTMLInputElement>(null);

  useEffect(() => {
    const timer = setTimeout(() => ref.current?.focus(), delayMs);
    return () => clearTimeout(timer);
  }, [delayMs]);

  return ref;  // 暴露给组件绑定到 input
}

// 使用
function LoginForm() {
  const usernameRef = useAutoFocus();
  return <input ref={usernameRef} placeholder="Username" />;
}

示例:在线状态检测

function useIsOnline() {
  const [online, setOnline] = useState(navigator.onLine);

  useEffect(() => {
    setOnline(navigator.onLine);
    const onOnline = () => setOnline(true);
    const onOffline = () => setOnline(false);
    window.addEventListener("online", onOnline);
    window.addEventListener("offline", onOffline);
    return () => {
      window.removeEventListener("online", onOnline);
      window.removeEventListener("offline", onOffline);
    };
  }, []);

  return online;
}

自定义 Hook 设计原则

[!summary] 好的自定义 Hook 应遵循以下原则

  1. 单一职责 —— 每个 Hook 解决一个问题(如上述两个例子各自独立)
  2. 命名清晰 —— useXxx 格式,读起来像动作:"监听网络"、"记住上次值"
  3. 返回值明确 —— 返回需要的引用或状态,不要暴露过多内部细节
  4. 可以组合 —— useFetch 内部可用 useEffect + useRef,外层可再用 useAutoFocus

Hook 调试技巧

[!tip] React DevTools 浏览器扩展 安装后可以:

  • 查看每个组件的 Hook 值和更新次数
  • 定位"为什么这个组件在重渲染"
  • 记录渲染时间,发现性能瓶颈

关联笔记

  • 3. 组件篇/01-组件基础.md
  • 3. 组件篇/03-性能优化.md
  • config/REACT/react-best-practices.md