Skip to content

闭包陷阱与解决

学习目标

通过本章学习,你将深入理解:

  • JavaScript闭包的基本概念
  • Hooks中的闭包陷阱
  • 过期闭包(Stale Closure)问题
  • useEffect中的闭包问题
  • useCallback和useMemo的闭包陷阱
  • 使用useRef解决闭包问题
  • React 19的优化方案
  • 实战案例和最佳实践

第一部分:JavaScript闭包基础

1.1 什么是闭包

闭包是指函数能够记住并访问其词法作用域,即使函数在其词法作用域之外执行。

javascript
// 基本闭包示例
function outer() {
  const message = 'Hello';
  
  function inner() {
    console.log(message);  // inner可以访问outer的变量
  }
  
  return inner;
}

const fn = outer();
fn();  // 输出: Hello
// inner函数记住了message变量,即使outer已经执行完毕

闭包的特点:

javascript
function createCounter() {
  let count = 0;
  
  return {
    increment: () => ++count,
    decrement: () => --count,
    getCount: () => count
  };
}

const counter = createCounter();
console.log(counter.increment());  // 1
console.log(counter.increment());  // 2
console.log(counter.getCount());   // 2

// count变量被闭包"捕获",在函数外部无法直接访问
// 但可以通过返回的函数间接访问和修改

1.2 闭包在React中的应用

React Hooks大量使用闭包:

jsx
function Component() {
  const [count, setCount] = useState(0);
  
  // handleClick形成闭包,捕获count
  const handleClick = () => {
    console.log(count);
  };
  
  return <button onClick={handleClick}>{count}</button>;
}

// 每次渲染都会创建新的handleClick函数
// 每个handleClick都捕获了当时的count值

第二部分:过期闭包问题

2.1 基本的过期闭包

jsx
// 问题示例
function Counter() {
  const [count, setCount] = useState(0);
  
  useEffect(() => {
    const timer = setInterval(() => {
      // ❌ 这里的count永远是0!
      console.log(count);
      setCount(count + 1);  // 永远是 0 + 1 = 1
    }, 1000);
    
    return () => clearInterval(timer);
  }, []);  // 空依赖数组
  
  return <div>{count}</div>;
}

// 问题分析:
// 1. useEffect只在mount时执行一次
// 2. 定时器回调捕获了初始的count值(0)
// 3. count更新后,定时器回调中的count仍然是0
// 4. 这就是"过期闭包"(Stale Closure)

问题可视化:

初始渲染(count = 0)

useEffect执行,创建定时器

定时器回调捕获 count = 0

1秒后,setCount(0 + 1)

组件重新渲染,count = 1

但定时器回调中的count仍然是0!

再过1秒,setCount(0 + 1)

count还是1,没有增长!

2.2 解决方案1:函数式更新

jsx
// ✅ 使用函数式更新
function Counter() {
  const [count, setCount] = useState(0);
  
  useEffect(() => {
    const timer = setInterval(() => {
      // ✅ 使用函数式更新,不依赖外部count
      setCount(prevCount => prevCount + 1);
    }, 1000);
    
    return () => clearInterval(timer);
  }, []);
  
  return <div>{count}</div>;
}

// 原理:
// setCount接收函数时,React会传入最新的state值
// 不需要闭包捕获count

2.3 解决方案2:添加依赖

jsx
// ✅ 添加count到依赖数组
function Counter() {
  const [count, setCount] = useState(0);
  
  useEffect(() => {
    const timer = setInterval(() => {
      console.log(count);  // ✅ 总是最新的count
      setCount(count + 1);
    }, 1000);
    
    return () => clearInterval(timer);
  }, [count]);  // count变化时重新创建定时器
  
  return <div>{count}</div>;
}

// 注意:这会导致定时器频繁重建
// 每次count变化都会清除旧定时器,创建新定时器

2.4 解决方案3:使用useRef

jsx
// ✅ 使用useRef保存最新值
function Counter() {
  const [count, setCount] = useState(0);
  const countRef = useRef(count);
  
  // 每次渲染更新ref
  useEffect(() => {
    countRef.current = count;
  }, [count]);
  
  useEffect(() => {
    const timer = setInterval(() => {
      // ✅ 通过ref获取最新值
      console.log(countRef.current);
      setCount(countRef.current + 1);
    }, 1000);
    
    return () => clearInterval(timer);
  }, []);
  
  return <div>{count}</div>;
}

// 优点:
// - 不需要频繁重建定时器
// - 可以访问最新的count值

第三部分:useEffect中的闭包陷阱

3.1 事件监听器的闭包问题

jsx
// ❌ 问题示例
function Component() {
  const [count, setCount] = useState(0);
  
  useEffect(() => {
    const handleClick = () => {
      // ❌ count永远是初始值
      console.log('Count:', count);
    };
    
    document.addEventListener('click', handleClick);
    
    return () => {
      document.removeEventListener('click', handleClick);
    };
  }, []);  // 空依赖数组
  
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>+1</button>
    </div>
  );
}

// 点击按钮后,document的click事件仍然输出初始count

解决方案:

jsx
// ✅ 方案1:使用useRef
function Component() {
  const [count, setCount] = useState(0);
  const countRef = useRef(count);
  
  useEffect(() => {
    countRef.current = count;
  }, [count]);
  
  useEffect(() => {
    const handleClick = () => {
      console.log('Count:', countRef.current);
    };
    
    document.addEventListener('click', handleClick);
    return () => document.removeEventListener('click', handleClick);
  }, []);
  
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>+1</button>
    </div>
  );
}

// ✅ 方案2:添加依赖
function Component() {
  const [count, setCount] = useState(0);
  
  useEffect(() => {
    const handleClick = () => {
      console.log('Count:', count);
    };
    
    document.addEventListener('click', handleClick);
    return () => document.removeEventListener('click', handleClick);
  }, [count]);  // count变化时重新绑定事件
  
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>+1</button>
    </div>
  );
}

3.2 异步操作的闭包问题

jsx
// ❌ 问题示例
function SearchComponent() {
  const [keyword, setKeyword] = useState('');
  const [results, setResults] = useState([]);
  
  useEffect(() => {
    const fetchData = async () => {
      const data = await fetch(`/api/search?q=${keyword}`);
      // ❌ 如果keyword快速变化,这里可能设置过期的结果
      setResults(data);
    };
    
    fetchData();
  }, [keyword]);
  
  return (
    <div>
      <input value={keyword} onChange={e => setKeyword(e.target.value)} />
      <Results data={results} />
    </div>
  );
}

// 问题:
// 1. 用户输入"react"
// 2. 发起请求A
// 3. 用户立即改为"redux"
// 4. 发起请求B
// 5. 如果请求A比请求B后返回,会显示错误的结果

解决方案:

jsx
// ✅ 使用cleanup取消过期请求
function SearchComponent() {
  const [keyword, setKeyword] = useState('');
  const [results, setResults] = useState([]);
  
  useEffect(() => {
    let cancelled = false;
    
    const fetchData = async () => {
      const data = await fetch(`/api/search?q=${keyword}`);
      
      // ✅ 检查是否已取消
      if (!cancelled) {
        setResults(data);
      }
    };
    
    fetchData();
    
    return () => {
      // ✅ 组件卸载或keyword变化时设置标志
      cancelled = true;
    };
  }, [keyword]);
  
  return (
    <div>
      <input value={keyword} onChange={e => setKeyword(e.target.value)} />
      <Results data={results} />
    </div>
  );
}

// ✅ 使用AbortController
function SearchComponent() {
  const [keyword, setKeyword] = useState('');
  const [results, setResults] = useState([]);
  
  useEffect(() => {
    const controller = new AbortController();
    
    const fetchData = async () => {
      try {
        const data = await fetch(`/api/search?q=${keyword}`, {
          signal: controller.signal
        });
        setResults(data);
      } catch (error) {
        if (error.name !== 'AbortError') {
          console.error(error);
        }
      }
    };
    
    fetchData();
    
    return () => {
      controller.abort();
    };
  }, [keyword]);
  
  return (
    <div>
      <input value={keyword} onChange={e => setKeyword(e.target.value)} />
      <Results data={results} />
    </div>
  );
}

第四部分:useCallback中的闭包陷阱

4.1 基本的闭包问题

jsx
// ❌ 问题示例
function Component() {
  const [count, setCount] = useState(0);
  
  // ❌ 回调捕获了初始的count
  const handleClick = useCallback(() => {
    console.log('Count:', count);  // 永远是0
  }, []);  // 空依赖数组
  
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>+1</button>
      <button onClick={handleClick}>Log Count</button>
    </div>
  );
}

解决方案:

jsx
// ✅ 方案1:添加依赖
function Component() {
  const [count, setCount] = useState(0);
  
  const handleClick = useCallback(() => {
    console.log('Count:', count);
  }, [count]);  // ✅ 添加count依赖
  
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>+1</button>
      <button onClick={handleClick}>Log Count</button>
    </div>
  );
}

// ✅ 方案2:使用useRef
function Component() {
  const [count, setCount] = useState(0);
  const countRef = useRef(count);
  
  useEffect(() => {
    countRef.current = count;
  }, [count]);
  
  const handleClick = useCallback(() => {
    console.log('Count:', countRef.current);
  }, []);  // ✅ 空依赖,但能访问最新count
  
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>+1</button>
      <button onClick={handleClick}>Log Count</button>
    </div>
  );
}

// ✅ 方案3:函数式更新
function Component() {
  const [count, setCount] = useState(0);
  
  const handleIncrement = useCallback(() => {
    setCount(prev => {
      console.log('Count:', prev);
      return prev + 1;
    });
  }, []);
  
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={handleIncrement}>+1 and Log</button>
    </div>
  );
}

4.2 复杂场景的闭包问题

jsx
// ❌ 问题:表单提交捕获旧的formData
function FormComponent() {
  const [formData, setFormData] = useState({ name: '', email: '' });
  
  const handleSubmit = useCallback(() => {
    // ❌ 如果没有把formData加入依赖,这里会是旧数据
    console.log('Submitting:', formData);
    fetch('/api/submit', {
      method: 'POST',
      body: JSON.stringify(formData)
    });
  }, []);  // ❌ 缺少formData依赖
  
  return (
    <form>
      <input 
        value={formData.name}
        onChange={e => setFormData({ ...formData, name: e.target.value })}
      />
      <button type="button" onClick={handleSubmit}>提交</button>
    </form>
  );
}

// ✅ 解决方案
function FormComponent() {
  const [formData, setFormData] = useState({ name: '', email: '' });
  
  const handleSubmit = useCallback(() => {
    console.log('Submitting:', formData);
    fetch('/api/submit', {
      method: 'POST',
      body: JSON.stringify(formData)
    });
  }, [formData]);  // ✅ 添加依赖
  
  return (
    <form>
      <input 
        value={formData.name}
        onChange={e => setFormData({ ...formData, name: e.target.value })}
      />
      <button type="button" onClick={handleSubmit}>提交</button>
    </form>
  );
}

第五部分:自定义Hook中的闭包陷阱

5.1 自定义Hook的闭包问题

jsx
// ❌ 问题示例
function useInterval(callback, delay) {
  useEffect(() => {
    const timer = setInterval(() => {
      // ❌ callback捕获了旧的引用
      callback();
    }, delay);
    
    return () => clearInterval(timer);
  }, [delay]);  // ❌ 缺少callback依赖
}

// 使用
function Component() {
  const [count, setCount] = useState(0);
  
  useInterval(() => {
    // ❌ 这里的count永远是初始值
    console.log('Count:', count);
  }, 1000);
  
  return <div>{count}</div>;
}

解决方案:

jsx
// ✅ 使用useRef保存最新callback
function useInterval(callback, delay) {
  const savedCallback = useRef(callback);
  
  // 每次渲染更新callback
  useEffect(() => {
    savedCallback.current = callback;
  }, [callback]);
  
  useEffect(() => {
    const timer = setInterval(() => {
      // ✅ 调用最新的callback
      savedCallback.current();
    }, delay);
    
    return () => clearInterval(timer);
  }, [delay]);
}

// 使用
function Component() {
  const [count, setCount] = useState(0);
  
  useInterval(() => {
    console.log('Count:', count);  // ✅ 总是最新的count
  }, 1000);
  
  return (
    <div>
      <p>{count}</p>
      <button onClick={() => setCount(count + 1)}>+1</button>
    </div>
  );
}

5.2 useLatest - 通用解决方案

jsx
// 创建通用的useLatest Hook
function useLatest(value) {
  const ref = useRef(value);
  
  useEffect(() => {
    ref.current = value;
  }, [value]);
  
  return ref;
}

// 使用useLatest重写useInterval
function useInterval(callback, delay) {
  const savedCallback = useLatest(callback);
  
  useEffect(() => {
    const timer = setInterval(() => {
      savedCallback.current();
    }, delay);
    
    return () => clearInterval(timer);
  }, [delay, savedCallback]);
}

// 其他应用场景
function Component() {
  const [count, setCount] = useState(0);
  const countRef = useLatest(count);
  
  useEffect(() => {
    const handleClick = () => {
      console.log('Latest count:', countRef.current);
    };
    
    document.addEventListener('click', handleClick);
    return () => document.removeEventListener('click', handleClick);
  }, [countRef]);
  
  return <div>{count}</div>;
}

第六部分:React 19的改进

6.1 useEffectEvent(实验性)

jsx
// React 19引入useEffectEvent来解决闭包问题
import { useEffectEvent } from 'react';

function Component() {
  const [count, setCount] = useState(0);
  
  // ✅ useEffectEvent创建的函数总是使用最新值
  const onTick = useEffectEvent(() => {
    console.log('Count:', count);  // ✅ 总是最新的count
  });
  
  useEffect(() => {
    const timer = setInterval(() => {
      onTick();
    }, 1000);
    
    return () => clearInterval(timer);
  }, []);  // ✅ 不需要添加onTick或count依赖
  
  return <div>{count}</div>;
}

6.2 自动批处理

jsx
// React 19自动批处理减少闭包问题

// React 18
function Component() {
  const [count, setCount] = useState(0);
  
  const handleClick = () => {
    // 这些会分别触发渲染
    setTimeout(() => {
      setCount(c => c + 1);
      setCount(c => c + 1);
    }, 1000);
  };
}

// React 19
function Component() {
  const [count, setCount] = useState(0);
  
  const handleClick = () => {
    // ✅ 这些会被批处理,只触发一次渲染
    setTimeout(() => {
      setCount(c => c + 1);
      setCount(c => c + 1);
    }, 1000);
  };
}

第七部分:实战案例

7.1 防抖输入

jsx
// 完整的防抖搜索实现
function SearchInput() {
  const [keyword, setKeyword] = useState('');
  const [results, setResults] = useState([]);
  const keywordRef = useLatest(keyword);
  
  useEffect(() => {
    const timer = setTimeout(() => {
      // ✅ 使用最新的keyword
      if (keywordRef.current) {
        fetch(`/api/search?q=${keywordRef.current}`)
          .then(res => res.json())
          .then(setResults);
      }
    }, 500);
    
    return () => clearTimeout(timer);
  }, [keywordRef, keyword]);
  
  return (
    <div>
      <input 
        value={keyword}
        onChange={e => setKeyword(e.target.value)}
        placeholder="搜索..."
      />
      <SearchResults results={results} />
    </div>
  );
}

7.2 WebSocket连接

jsx
function useWebSocket(url) {
  const [data, setData] = useState(null);
  const dataRef = useLatest(data);
  
  useEffect(() => {
    const ws = new WebSocket(url);
    
    ws.onmessage = (event) => {
      const newData = JSON.parse(event.data);
      // ✅ 可以访问最新的data进行处理
      setData(prev => {
        return processData(prev, newData);
      });
    };
    
    ws.onerror = (error) => {
      console.error('WebSocket error:', error);
    };
    
    return () => {
      ws.close();
    };
  }, [url]);
  
  return data;
}

7.3 动画循环

jsx
function useAnimationFrame(callback) {
  const requestRef = useRef();
  const previousTimeRef = useRef();
  const callbackRef = useLatest(callback);
  
  useEffect(() => {
    const animate = (time) => {
      if (previousTimeRef.current !== undefined) {
        const deltaTime = time - previousTimeRef.current;
        // ✅ 调用最新的callback
        callbackRef.current(deltaTime);
      }
      previousTimeRef.current = time;
      requestRef.current = requestAnimationFrame(animate);
    };
    
    requestRef.current = requestAnimationFrame(animate);
    
    return () => {
      if (requestRef.current) {
        cancelAnimationFrame(requestRef.current);
      }
    };
  }, [callbackRef]);
}

// 使用
function AnimationComponent() {
  const [position, setPosition] = useState(0);
  
  useAnimationFrame((deltaTime) => {
    setPosition(prev => prev + deltaTime * 0.1);
  });
  
  return (
    <div style={{ transform: `translateX(${position}px)` }}>
      Moving element
    </div>
  );
}

注意事项

1. 识别闭包陷阱

jsx
// 检查这些模式:
// - useEffect中访问state/props
// - useCallback捕获state/props
// - setInterval/setTimeout中使用state
// - 事件监听器中访问state

2. 优先使用函数式更新

jsx
// ✅ 推荐
setCount(prev => prev + 1);

// ❌ 避免(除非count在依赖数组中)
setCount(count + 1);

3. 合理使用useRef

jsx
// ✅ 适合useRef的场景:
// - 需要可变引用
// - 避免闭包陷阱
// - 存储定时器/订阅ID

常见问题

Q1: 什么时候会遇到闭包陷阱?

A: 主要场景:

  • 空依赖数组的useEffect
  • 异步操作
  • 定时器
  • 事件监听器
  • useCallback without dependencies

Q2: useRef vs useState的选择?

A:

jsx
// useRef: 
// - 不触发重渲染
// - 保存最新值
// - 解决闭包陷阱

// useState:
// - 触发重渲染
// - 响应式数据
// - UI展示

Q3: 如何在useEffect中安全地使用async/await?

A:

jsx
useEffect(() => {
  let cancelled = false;
  
  const fetchData = async () => {
    const data = await API.call();
    if (!cancelled) {
      setData(data);
    }
  };
  
  fetchData();
  
  return () => {
    cancelled = true;
  };
}, []);

总结

闭包陷阱的本质

函数捕获外部变量

变量值在函数创建时确定

后续变量更新不影响已创建的函数

函数使用"过期"的值

解决策略

  1. 函数式更新:不依赖外部state
  2. 添加依赖:重新创建函数
  3. useRef:保存最新值引用
  4. useLatest:自定义Hook封装
  5. useEffectEvent:React 19新特性

最佳实践

  • 正确声明依赖数组
  • 使用ESLint检查
  • 理解闭包原理
  • 合理使用useRef
  • 函数式更新优先

理解并掌握闭包陷阱的解决方案,是编写高质量React代码的关键能力!