React Hooks 原理与陷阱:从 useState 到自定义 Hook

React Hooks 出来好几年了,真正用对的人并不多。useState 加错位置导致死循环、useEffect 依赖数组写漏触发陈旧闭包、useMemo 加了反而更慢 —— 这些都不是 Hooks 的问题,是没理解它背后的机制。这篇文章把 Hooks 的实现原理、最容易踩的坑、以及自定义 Hook 的设计套路一次讲透。

Hooks 为什么是这种长相

函数组件每次渲染就是函数被调用一次。这意味着函数本身不能"持有状态"—— 每次都是新的局部变量。React 通过 Fiber 节点上的链表把状态挂在外部:Fiber 节点维护一个 hooks 链表,每次 render 时按调用顺序逐个取出。

// 极简版 useState 实现示意(真实实现复杂得多)
let currentFiber;
let hookIndex;

function useState(initial) {
    const hooks = currentFiber.hooks;
    const i = hookIndex++;
    if (hooks[i] === undefined) {
        hooks[i] = typeof initial === 'function' ? initial() : initial;
    }
    const setState = (next) => {
        hooks[i] = typeof next === 'function' ? next(hooks[i]) : next;
        scheduleRerender(currentFiber);
    };
    return [hooks[i], setState];
}

这就解释了两条"Hooks 黄金规则":

  1. 只能在组件最顶层调用 Hook,不能放在 if / for / 函数嵌套里。
  2. 每次渲染调用顺序必须相同

违反任何一条,链表对应位置就错了,React 取到的状态会张冠李戴 —— 这就是 ESLint 那条 react-hooks/rules-of-hooks 必须开的根本原因。

useState:函数式更新与惰性初始化

function Counter() {
    const [count, setCount] = useState(0);

    // 坑:这样写,连续 setCount 三次只会 +1
    const addThreeWrong = () => {
        setCount(count + 1);
        setCount(count + 1);
        setCount(count + 1);   // 三次都基于同一个 count(闭包里的快照)
    };

    // 对:用函数式更新,每次拿到的是最新值
    const addThreeRight = () => {
        setCount((c) => c + 1);
        setCount((c) => c + 1);
        setCount((c) => c + 1);
    };

    return <button onClick={addThreeRight}>{count}</button>;
}

另一个常被忽略的细节:初始值的惰性求值。如果初始值是个昂贵的计算,直接传函数:

// 坏:每次 render 都执行 heavyCompute(),只是结果被忽略
const [data, setData] = useState(heavyCompute());

// 好:只在挂载时执行一次
const [data, setData] = useState(() => heavyCompute());

useEffect:依赖数组才是核心

useEffect 的语义是:"在 render 提交到 DOM 后,如果依赖数组里的某个值变了,先跑清理函数,再跑 effect。"理解这三个时机和"依赖数组"的语义,能避免 80% 的 Hooks bug。

useEffect(() => {
    const timer = setInterval(() => console.log(count), 1000);
    return () => clearInterval(timer);   // 清理,在下次 effect 前或卸载时执行
}, [count]);   // 依赖数组:[] 表示只挂载一次,省略表示每次 render 都跑

陈旧闭包:Hooks 最大的坑

function Stale() {
    const [count, setCount] = useState(0);

    useEffect(() => {
        const id = setInterval(() => {
            console.log(count);     // 永远打印 0 —— 闭包捕获了挂载时的 count
        }, 1000);
        return () => clearInterval(id);
    }, []);   // 空依赖数组,effect 只跑一次

    return <button onClick={() => setCount(count + 1)}>{count}</button>;
}

修复有三种思路:

// 1. 把 count 加进依赖数组 —— 每次 count 变都重建 interval
useEffect(() => {
    const id = setInterval(() => console.log(count), 1000);
    return () => clearInterval(id);
}, [count]);

// 2. 用函数式更新避开 count
useEffect(() => {
    const id = setInterval(() => setCount((c) => {
        console.log(c);
        return c;
    }), 1000);
    return () => clearInterval(id);
}, []);

// 3. 用 useRef 保存最新值
const ref = useRef(count);
useEffect(() => { ref.current = count; });
useEffect(() => {
    const id = setInterval(() => console.log(ref.current), 1000);
    return () => clearInterval(id);
}, []);

ESLint 的 react-hooks/exhaustive-deps 规则会自动报漏掉的依赖,务必开启。遇到它报红时不要直接 disable,那是在埋雷

useReducer:状态多了就该升级

多个 useState 之间有联动逻辑时,useReducer 更清晰:

type State = { count: number; history: number[] };
type Action = { type: 'inc' } | { type: 'dec' } | { type: 'reset' };

const reducer = (s: State, a: Action): State => {
    switch (a.type) {
        case 'inc':   return { count: s.count + 1, history: [...s.history, s.count + 1] };
        case 'dec':   return { count: s.count - 1, history: [...s.history, s.count - 1] };
        case 'reset': return { count: 0, history: [] };
    }
};

function CounterAdv() {
    const [state, dispatch] = useReducer(reducer, { count: 0, history: [] });
    return (
        <>
            <span>{state.count}</span>
            <button onClick={() => dispatch({ type: 'inc' })}>+</button>
            <button onClick={() => dispatch({ type: 'reset' })}>重置</button>
        </>
    );
}

useMemo / useCallback:不是越多越好

useMemo 缓存计算结果,useCallback 缓存函数引用。它们的目的不是"快",而是"稳定引用",从而避免下游 React.memo / useEffect 误触发。

// 滥用:简单计算 + 没下游消费,加 useMemo 反而拖累
const sum = useMemo(() => a + b, [a, b]);   // 不值得

// 合理:计算昂贵 + 依赖不常变
const sortedList = useMemo(() => bigList.sort(byScore), [bigList]);

// 合理:函数会被作为依赖传给 useEffect / 子组件 props
const onSearch = useCallback((q) => api.search(q), []);
useEffect(() => { onSearch(q); }, [q, onSearch]);

记住:useMemo / useCallback 都有开销(对比依赖、维护缓存)。给"显然不慢"的计算加,是用复杂度换不到性能。

useRef:不只是 DOM 引用

useRef 返回一个可变对象 { current: ... },它有两个用途:

  1. 引用 DOM 节点 —— 这是大家最熟悉的。
  2. 跨 render 保存任意可变值,且修改它不会触发重渲
function Profile() {
    const inputRef = useRef<HTMLInputElement>(null);
    const renderCountRef = useRef(0);

    // 每次渲染都执行,但不触发重渲
    renderCountRef.current += 1;

    return (
        <>
            <input ref={inputRef} />
            <button onClick={() => inputRef.current?.focus()}>聚焦</button>
            <div>渲染次数:{renderCountRef.current}</div>
        </>
    );
}

useRef 是"逃出 React 重渲机制"的安全后门。常见用法:存定时器 ID、存上一次的值、存"是否首次渲染"的标志。

useContext:跨层级传值

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

function App() {
    const [theme, setTheme] = useState<'light' | 'dark'>('light');
    return (
        <ThemeContext.Provider value={theme}>
            <Toolbar />
            <button onClick={() => setTheme((t) => t === 'light' ? 'dark' : 'light')}>
                切换
            </button>
        </ThemeContext.Provider>
    );
}

function DeepChild() {
    const theme = useContext(ThemeContext);
    return <div className={theme}>...</div>;
}

陷阱:Provider 的 value 每次 render 都是新对象时,所有消费它的组件都会重渲。解决方法:把 value 用 useMemo 包起来。

const value = useMemo(() => ({ theme, setTheme }), [theme]);
return <ThemeContext.Provider value={value}>...</ThemeContext.Provider>;

自定义 Hook:逻辑复用的正确姿势

把"使用一组 Hook 的逻辑"抽成一个以 use 开头的函数,就是自定义 Hook。它继承 React 的 Hooks 调用规则,但能在多个组件间复用逻辑(不复用状态)。

// useDebounce:防抖一个值
function useDebounce<T>(value: T, delay: number): T {
    const [debounced, setDebounced] = useState(value);
    useEffect(() => {
        const t = setTimeout(() => setDebounced(value), delay);
        return () => clearTimeout(t);
    }, [value, delay]);
    return debounced;
}

// 使用
function Search() {
    const [q, setQ] = useState('');
    const dq = useDebounce(q, 300);
    useEffect(() => { if (dq) api.search(dq); }, [dq]);
    return <input value={q} onChange={(e) => setQ(e.target.value)} />;
}
// useLocalStorage:状态与 localStorage 同步
function useLocalStorage<T>(key: string, initial: T) {
    const [v, setV] = useState<T>(() => {
        try {
            const raw = localStorage.getItem(key);
            return raw === null ? initial : JSON.parse(raw);
        } catch {
            return initial;
        }
    });
    useEffect(() => { localStorage.setItem(key, JSON.stringify(v)); }, [key, v]);
    return [v, setV] as const;
}
// useAsync:封装一次性的异步数据获取
function useAsync<T>(fn: () => Promise<T>, deps: any[]) {
    const [state, setState] = useState<{
        loading: boolean; data?: T; error?: Error;
    }>({ loading: true });

    useEffect(() => {
        let cancelled = false;
        setState({ loading: true });
        fn().then(
            (data) => !cancelled && setState({ loading: false, data }),
            (error) => !cancelled && setState({ loading: false, error }),
        );
        return () => { cancelled = true; };   // 卸载时取消,避免内存泄漏
    }, deps);

    return state;
}

这三个 Hook 涵盖了"防抖、持久化、异步"三个最高频场景。useAsynccancelled 标志非常关键 —— 它防止"组件已卸载,但 promise 才回来"时去 setState,触发 React 警告。

性能优化的优先级

面试常考的"如何优化 React 性能":

  1. 先用 React DevTools Profiler 定位真实瓶颈,不要凭感觉优化。
  2. 把组件拆小,让 state 变化只触发该重渲的部分。
  3. React.memo 包"贵的、props 不常变的"子组件
  4. 必要时用 useMemo / useCallback 稳定引用,但不要无脑加。
  5. 长列表用 react-window / react-virtualized 虚拟滚动
  6. 避免在 render 里创建新对象/新函数作为 props 传给被 memo 的子组件 —— 这会让 memo 失效。

写在最后

Hooks 不是更短的语法糖,它是 React 心智模型的一次根本转变:组件从"实例 + 生命周期"变成"渲染函数 + 副作用声明"。一旦接受这个模型,你写代码的方式会改变 —— 不再问"什么时候做这件事",而是问"这件事依赖什么,依赖变了就重做"。

检查你 Hooks 代码质量的三道闸:Lint 规则没 disable;依赖数组没漏没多;effect 都有对应的清理。这三条做到位,你的组件就能稳定地跑在生产环境里,而不是某天用户切个 tab 回来发现页面卡死。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

TypeScript 泛型从入门到精通:让类型为你工作

2026-5-15 10:55:54

技术教程

动态规划完全指南:从入门到面试 10 道经典题型

2026-5-15 10:55:54

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索