最近React团队发布了Hooks的alpha预览版(预计明年正式发布)这是一个革命性的新特性。

Hooks能让函数组件拥有状态和生命周期的能力不需要再写类组件。这意味着我们可以用更简洁的函数式的方式写React组件代码更短更清晰更易复用。

我第一时间体验了Hooks用它重构了一些项目踩了很多坑。因为是alpha预览版文档还不够完善很多东西需要自己摸索。

今天想记录一下我在React Hooks实战中踩过的那些坑和解决方法。希望这些经验能帮大家在使用Hooks的时候,少踩坑。

一、Hooks简介

先简单介绍一下Hooks。

Hooks是React 16.7的新特性(alpha预览版)它让你在函数组件中使用状态和,其他React特性不需要写类组件。

Hooks的核心思想是让函数组件也能拥有类组件的能力,同时保持函数式编程的简洁和优雅。

常用的Hooks有:

  • useState:让函数组件拥有状态。
  • useEffect:让函数组件拥有副作用的能力类似生命周期。
  • useContext:让函数组件使用Context。
  • useReducer:让函数组件用reducer的方式管理复杂状态。
  • useCallback:缓存回调函数避免不必要的重新渲染。
  • useMemo:缓存计算结果避免重复计算。
  • useRef:让函数组件拥有ref。
  • useImperativeMethods:让父组件能调用子组件的方法。

这些Hooks覆盖了类组件的大部分能力用它们基本能替代类组件。

二、useState的坑

第一个坑是useState的坑。

useState是最基础的Hook用来给函数组件添加状态。

用法很简单:

function Counter() {
  const [count, setCount] = useState(0);
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>+1</button>
    </div>
  );
}

看起来很简单,但是实际使用的时候,踩了一些坑。

坑1:setState的异步更新

第一个坑是setState的异步更新。

在类组件中setState是异步的多次调用setState会被合并。在函数组件中useState返回的setCount也是异步的多次调用也会被合并。

比如:

function Counter() {
  const [count, setCount] = useState(0);
  
  const handleClick = () => {
    setCount(count + 1);
    setCount(count + 1);
    setCount(count + 1);
  };
  
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={handleClick}>+3</button>
    </div>
  );
}

你可能以为点击按钮count会增加3但是实际上,只增加1。因为三次setCount都用的是同一个count值(0)所以三次都设置为1合并后就是1。

解决方法是用函数式更新:

const handleClick = () => {
  setCount(prev => prev + 1);
  setCount(prev => prev + 1);
  setCount(prev => prev + 1);
};

这样每次setCount都用的是上一次的结果三次调用后count会增加3。

这个坑和类组件的setState异步更新是一样的,但是,因为函数组件的写法不同容易忽略。

坑2:初始值只在第一次渲染时生效

第二个坑是useState的初始值只在第一次渲染时生效。

比如:

function Counter(props) {
  const [count, setCount] = useState(props.initialCount);
  // ...
}

你可能以为props.initialCount变化的时候,候count会跟着变化。但是实际上useState的初始值只在第一次渲染时生效后续props.initialCount变化count不会跟着变。

如果需要根据props更新state需要用useEffect监听props的变化,然后更新state。

或者,如果不需要state和props同步只是用props作为初始值那没问题。

坑3:复杂状态的管理

第三个坑是复杂状态的管理。

如果组件的状态比较复杂有很多字段用多个useState会比较乱也不好管理。

比如:

function UserForm() {
  const [name, setName] = useState('');
  const [age, setAge] = useState(0);
  const [email, setEmail] = useState('');
  const [address, setAddress] = useState('');
  const [phone, setPhone] = useState('');
  // ...
}

这样写很多useState代码比较乱也不好批量更新。

解决方法是用useReducer管理复杂状态,或者把相关的状态合并成一个对象用一个useState。

比如:

function UserForm() {
  const [user, setUser] = useState({
    name: '',
    age: 0,
    email: '',
    address: '',
    phone: ''
  });
  
  const handleChange = (field, value) => {
    setUser(prev => ({ ...prev, [field]: value }));
  };
  // ...
}

这样代码更清晰更好管理。

三、useEffect的坑

useEffect是最常用的Hook之一用来处理副作用,比如数据获取订阅DOM操作等等。

但是useEffect也是最容易踩坑的Hook。

坑1:依赖数组的问题

第一个坑是依赖数组的问题。

useEffect接受两个参数第一个是副作用函数第二个是依赖数组。依赖数组决定了useEffect什么时候执行。

如果不传依赖数组useEffect每次渲染后都会执行。如果传空数组useEffect只在第一次渲染后执行类似componentDidMount。如果传有值的数组useEffect在数组中的值变化时执行。

很多人容易在依赖数组上踩坑。

比如忘记传依赖数组导致useEffect每次渲染都执行造成死循环,或者性能问题。

比如:

function DataFetcher() {
  const [data, setData] = useState(null);
  
  useEffect(() => {
    fetch('/api/data')
      .then(res => res.json())
      .then(data => setData(data));
  }); // 没传依赖数组每次渲染都执行
  
  return <div>{data ? JSON.stringify(data) : 'Loading...'}</div>;
}

这个组件每次渲染后都会执行useEffect获取数据,然后setData触发重新渲染,然后又执行useEffect造成死循环。

解决方法是传空数组让useEffect只在第一次渲染后执行:

useEffect(() => {
  fetch('/api/data')
    .then(res => res.json())
    .then(data => setData(data));
}, []); // 空数组只在第一次渲染后执行

另一个常见的坑是依赖数组漏了依赖导致useEffect用了旧的值。

比如:

function Counter() {
  const [count, setCount] = useState(0);
  
  useEffect(() => {
    const timer = setInterval(() => {
      console.log(count); // 这里的count永远是0
    }, 1000);
    return () => clearInterval(timer);
  }, []); // 依赖数组是空的count变化不会触发重新执行
  
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>+1</button>
    </div>
  );
}

这个例子中useEffect的依赖数组是空的,所以只在第一次渲染后执行。setInterval的回调中用的count是第一次渲染时的count(0)所以,即使count变化了console.log打印的还是0。

解决方法是把count加到依赖数组中:

useEffect(() => {
  const timer = setInterval(() => {
    console.log(count);
  }, 1000);
  return () => clearInterval(timer);
}, [count]); // count变化时重新执行

这样count变化时useEffect会重新执行清除旧的timer创建新的timer用新的count。

但是这样每次count变化都会清除和重新创建timer有点浪费。更好的方法是用useRef保存最新的count或者用函数式更新。

坑2:清理函数的问题

第二个坑是清理函数的问题。

useEffect可以返回一个清理函数在组件卸载时,或者下一次useEffect执行前调用用来清理副作用,比如清除timer取消订阅等等。

很多人容易忘记写清理函数导致内存泄漏,或者其他问题。

比如:

function DataFetcher() {
  const [data, setData] = useState(null);
  
  useEffect(() => {
    fetch('/api/data')
      .then(res => res.json())
      .then(data => setData(data));
    // 没有清理函数
  }, []);
  
  return <div>{data ? JSON.stringify(data) : 'Loading...'}</div>;
}

这个例子中,如果组件在fetch完成前卸载了fetch完成后还会调用setData但是组件已经卸载了会导致警告甚至内存泄漏。

解决方法是用一个变量标记组件是否卸载了:

useEffect(() => {
  let isMounted = true;
  fetch('/api/data')
    .then(res => res.json())
    .then(data => {
      if (isMounted) {
        setData(data);
      }
    });
  return () => {
    isMounted = false;
  };
}, []);

这样组件卸载后isMounted为false不会再调用setData。

对于timer订阅等副作用一定要写清理函数,否则会导致内存泄漏。

坑3:useEffect的执行时机

第三个坑是useEffect的执行时机。

useEffect是在渲染完成后异步执行的不会阻塞浏览器的绘制。这和componentDidMountcomponentDidUpdate类似,但是有细微的差别。

比如useEffect中读取DOM的尺寸可能会导致闪烁,因为useEffect是在浏览器绘制后执行的这时候用户已经看到了旧的布局,然后useEffect修改布局会导致闪烁。

如果需要在浏览器绘制前执行副作用可以用useLayoutEffect它是,同步执行的在浏览器绘制前执行。

但是useLayoutEffect会阻塞浏览器绘制可能影响性能,所以大部分情况用useEffect就够了,只有需要同步修改DOM的时候,才用useLayoutEffect。

四、useContext的坑

useContext让函数组件能使用Context不需要Consumer组件嵌套。

用法很简单:

const ThemeContext = React.createContext('light');

function App() {
  return (
    <ThemeContext.Provider value="dark">
      <Toolbar />
    </ThemeContext.Provider>
  );
}

function Toolbar() {
  const theme = useContext(ThemeContext);
  return <div>Theme: {theme}</div>;
}

看起来很简单,但是也有坑。

坑:Context value的引用变化导致重新渲染

useContext的组件会在Context的value变化时重新渲染。如果Context的value是一个对象每次渲染都创建新的对象,即使对象的内容没变也会导致所有useContext的组件重新渲染。

比如:

function App() {
  const [count, setCount] = useState(0);
  const value = { count, setCount }; // 每次渲染都创建新对象
  
  return (
    <ThemeContext.Provider value={value}>
      <Child />
    </ThemeContext.Provider>
  );
}

这个例子中每次App渲染都会创建新的value对象导致所有useContext(ThemeContext)的组件都重新渲染,即使count没变。

解决方法是用useMemo缓存value:

function App() {
  const [count, setCount] = useState(0);
  const value = useMemo(() => ({ count, setCount }), [count]);
  
  return (
    <ThemeContext.Provider value={value}>
      <Child />
    </ThemeContext.Provider>
  );
}

这样,只有count变化时才创建新的value对象避免不必要的重新渲染。

五、useReducer的坑

useReducer用来管理复杂的状态类似Redux的reducer。

用法:

const initialState = { count: 0 };

function reducer(state, action) {
  switch (action.type) {
    case 'increment':
      return { count: state.count + 1 };
    case 'decrement':
      return { count: state.count - 1 };
    default:
      return state;
  }
}

function Counter() {
  const [state, dispatch] = useReducer(reducer, initialState);
  
  return (
    <div>
      <p>Count: {state.count}</p>
      <button onClick={() => dispatch({ type: 'increment' })}>+1</button>
      <button onClick={() => dispatch({ type: 'decrement' })}>-1</button>
    </div>
  );
}

useReducer的坑相对少一些,但是也有。

坑:reducer必须是纯函数

reducer必须是纯函数不能有副作用不能修改原state必须返回新的state。

如果reducer不是纯函数会导致各种奇怪的问题,比如状态不更新,或者更新异常。

比如不要在reducer中直接修改state:

// 错误
function reducer(state, action) {
  switch (action.type) {
    case 'increment':
      state.count++; // 直接修改原state错误
      return state;
    default:
      return state;
  }
}

// 正确
function reducer(state, action) {
  switch (action.type) {
    case 'increment':
      return { ...state, count: state.count + 1 }; // 返回新的state
    default:
      return state;
  }
}

六、自定义Hooks的坑

Hooks的一大优势是可以把组件逻辑抽取成自定义Hooks复用。

但是自定义Hooks也有一些坑。

坑1:Hooks的调用规则

Hooks有调用规则:只能在函数组件的顶层调用不能在循环条件嵌套函数中调用。

自定义Hooks也要遵守这个规则。而且自定义Hooks的名字必须以use开头这样React才能识别它是Hook检查调用规则。

坑2:自定义Hooks的状态隔离

每个组件调用自定义Hook都会创建独立的状态不会共享。如果需要共享状态需要用Context或者其他方式。

比如:

function useCounter() {
  const [count, setCount] = useState(0);
  const increment = () => setCount(count + 1);
  return { count, increment };
}

function ComponentA() {
  const { count, increment } = useCounter();
  // ...
}

function ComponentB() {
  const { count, increment } = useCounter();
  // ComponentA和ComponentB的count是独立的不共享
}

如果需要共享状态需要把状态提升到父组件,或者用Context。

七、Hooks的最佳实践

最后总结一下Hooks的最佳实践。

1. 遵守Hooks的调用规则

只能在函数组件的顶层调用Hooks不能在循环条件嵌套函数中调用。可以用eslint-plugin-react-hooks插件检查调用规则。

2. 正确设置依赖数组

useEffectuseCallbackuseMemo的依赖数组要正确设置不要漏了依赖也不要多加不必要的依赖。可以用eslint-plugin-react-hooks的exhaustive-deps规则检查。

3. 及时清理副作用

useEffect中的副作用,比如timer订阅请求等等要及时清理避免内存泄漏。

4. 合理拆分状态

不要把所有状态都放一个useState也不要拆得太碎。相关的状态可以放一起用一个useState或者useReducer。不相关的状态分开用多个useState。

5. 用useCallback和useMemo优化性能

对于传递给子组件的回调函数用useCallback缓存避免子组件不必要的重新渲染。对于复杂的计算用useMemo缓存结果避免重复计算。

但是不要滥用useCallback和useMemo它们本身也有开销,只有确实需要优化的时候,才用。

6. 抽取自定义Hooks复用逻辑

把可复用的组件逻辑抽取成自定义Hooks提高代码复用率和可维护性。

八、写在最后

以上就是我在React Hooks实战中踩过的那些坑和总结的最佳实践。

Hooks是一个非常优秀的新特性它让React组件的写法更简洁更优雅更函数式。但是,因为是新特性,而且目前还是alpha预览版文档不够完善很多东西需要自己摸索容易踩坑。

希望我的这些经验能帮大家在使用Hooks的时候,少踩坑更快上手。

当然Hooks还在快速发展中正式版发布的时候,可能会有一些变化。大家要关注官方文档和更新。

最后用一句话结束这篇文章:"Hooks是React的未来早学早受益。"

愿大家都能用好Hooks写出更优雅的React代码。