循序渐进打破React Hooks冰山

184 阅读8分钟
原文链接: zhaima.tech

与类组件不同,React Hooks提供了底层API,以最少的样板即可优化和组合应用程序。

如果没有深入理解,那么就会因为一些细微的错误和资源泄漏,出现性能问题,而且代码复杂性也会增加。

我创建了13个示例,以演示常见问题以及解决方法。我还编写了React Hooks 雷达和React Hooks 建议清单,以提供一些小的建议和快速参考。

案例研究:实现定时器

目标是实现从0开始并每500ms增加的计数器。应该提供三个控制按钮:开始,停止和清除。

xxx

1. Hello World

export default function Demo1() {
  console.log('renderDemo1');  
  
  const [count, setCount] = useState(0);  
  
  return (
    <div>
      count => {count}
      <button onClick={() => setCount(count + 1)}>+</button>
      <button onClick={() => setCount(count - 1)}>-</button>
    </div>
  );
}

这是一个简单且正确实现的计数器,在用户点击时会增加或减少。

2. setInterval

export default function Demo2() {
  console.log('renderDemo2');  

  const [count, setCount] = useState(0);  
  
  setInterval(() => {
    setCount(count + 1);
  }, 500);  

  return <div>count => {count}</div>;
}

该代码的目的是每隔500ms,计数加1。该代码存在大量资源泄漏,并且执行不正确。它将很容易造成浏览器选项卡崩溃。由于每次渲染时都会调用Demo2函数,因此每次触发渲染时,此组件都会创建新的interval。

在功能组件的主体内(称为React的渲染阶段),不允许进行突变,订阅,计时器,日志记录和其他副作用。这样做会导致UI中的错误和不一致。

Hooks API参考:useEffect

3. useEffect

export default function Demo3() {
  console.log('renderDemo3');  
  
  const [count, setCount] = useState(0);  
  
  useEffect(() => {
    setInterval(() => {
      setCount(count + 1);
    }, 500);
  });  
  
  return <div>Level 2: count => {count}</div>;
}

大多数副作用发生在useEffect内。此代码还存在大量资源泄漏,并且实现不正确。 useEffect的默认行为是在每次渲染后运行,因此每次计数更改时都会创建新的interval。

4. run only once只执行一次

export default function Demo4() {
  console.log('renderDemo4');  

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

  useEffect(() => {
    setInterval(() => {
      setCount(count + 1);
    }, 300);
  }, []);  

  return <div>count => {count}</div>;
}

[]作为useEffect的第二个参数将在组件加载后调用一次函数。即使setInterval仅被调用一次,该代码也不正确。

计数将从0增加到1并保持不变。箭头函数将创建一次,当这种情况发生时,计数将为0。

该代码具有轻微的资源泄漏。即使在卸载组件后,仍将调用setCount。

5. cleanup清理

useEffect(() => {
    
  const interval = setInterval(() => {
    setCount(count + 1);
  }, 300);
    
  return () => clearInterval(interval);
}, []);

为了防止资源泄漏,必须在钩子的生命周期结束时处理所有东西。在这种情况下,将在组件卸载后调用返回的函数。

该代码没有资源泄漏,但是与上一个代码一样,实现不正确。

6. use count as dependency

useEffect(() => {
  const interval = setInterval(() => {
    setCount(count + 1);
  }, 500);
  
  return () => clearInterval(interval);
}, [count]);

将依赖项数组赋予useEffect将更改其生命周期。在此示例中,useEffect将在组件加载后调用一次,并且每次计数更改时都会调用一次。每当计数更改,处理之前的资源时,都会调用清除功能。

这段代码可以正常运行,没有任何错误,但是会引起误解。每500毫秒创建并处理一次setInterval。每个setInterval始终被调用一次。

7. setTimeout

useEffect(() => {
  const timeout = setTimeout(() => {
    setCount(count + 1);
  }, 500);
  
  return () => clearTimeout(timeout);
}, [count]);

此代码和上面的代码都能正常工作。由于useEffect在每次计数更改时都被调用,因此使用setTimeout与调用setInterval具有相同的效果。

此示例效率很低,每次渲染发生时都会创建新的setTimeout。 React有更好的方法来解决问题。

8. functional updates for useState

useEffect(() => {
  const interval = setInterval(() => {
    setCount(c => c + 1);
  }, 500);
  
  return () => clearInterval(interval);
}, []);

在前面的示例中,我们对每个计数更改都运行useEffect。这是必要的,因为我们需要始终保持最新的当前值。

useState提供API以更新以前的状态而不捕获当前值。为此,我们需要做的就是向setState提供lambda函数参数。

这段代码可以正确有效地工作。我们在组件的生命周期内使用单个setInterval。卸载组件后,只会调用一次clearInterval。

9. local variable

export default function Demo9() {
  console.log('renderDemo9');  

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

  let interval = null;  

  const start = () => {
    interval = setInterval(() => {
      setCount(c => c + 1);
    }, 500);
  };  

  const stop = () => {
    clearInterval(interval);
  };  

  return (
    <div>
      count => {count}
      <button onClick={start}>start</button>
      <button onClick={stop}>stop</button>
    </div>
  );
}

我们添加了开始和停止按钮。该代码执行不正确,停止按钮不起作用。在每次渲染期间interval都会创建新的引用,因此本地变量interval会被赋值为null。

10. useRef

export default function Demo10() {
  console.log('renderDemo10');  

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

  const intervalRef = useRef(null);  

  const start = () => {
    intervalRef.current = setInterval(() => {
      setCount(c => c + 1);
    }, 500);
  };  

  const stop = () => {
    clearInterval(intervalRef.current);
  };  

  return (
    <div>
      count => {count}
      <button onClick={start}>start</button>
      <button onClick={stop}>stop</button>
    </div>
  );
}

如果需要可变变量,那么useRef就相当于一个go-to hook。与局部变量不同,React确保在每次渲染期间都返回相同的引用。

该代码似乎正确,但是有一个细微的错误。如果多次调用start,则将多次调用setInterval触发资源泄漏。

11. 优化useRef

export default function Demo11() {
  console.log('renderDemo11');  

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

  const intervalRef = useRef(null);  

  const start = () => {
    if (intervalRef.current !== null) {
      return;
    } 
   
    intervalRef.current = setInterval(() => {
      setCount(c => c + 1);
    }, 500);
  };  

  const stop = () => {
    if (intervalRef.current === null) {
      return;
    }    

    clearInterval(intervalRef.current);

    intervalRef.current = null;
  };  

  return (
    <div>
      count => {count}
      <button onClick={start}>start</button>
      <button onClick={stop}>stop</button>
    </div>
  );
}

为了避免资源泄漏,如果已经开启了定时器,我们将忽略调用。尽管调用clearInterval(null)不会触发任何错误,但是好的做法是只处理一次资源。

此代码没有资源泄漏,已正确实现,但可能存在性能问题。

memoization是React中主要的性能优化工具。 React.memo进行浅层比较,如果引用相同,则跳过渲染。

如果将start和stop传递给已记忆的组件,则整个优化将失败,因为在每次渲染后都会返回新的引用。

12. useCallback

export default function Demo12() {
  console.log('renderDemo12');  

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

  const intervalRef = useRef(null);  

  const start = useCallback(() => {
    if (intervalRef.current !== null) {
      return;
    }    
    
    intervalRef.current = setInterval(() => {
      setCount(c => c + 1);
    }, 500);
  }, []);  

  const stop = useCallback(() => {
    if (intervalRef.current === null) {
      return;
    }    

    clearInterval(intervalRef.current);
    
    intervalRef.current = null;
  }, []);

  return (
    <div>
      count => {count}
      <button onClick={start}>start</button>
      <button onClick={stop}>stop</button>
    </div>
  );
}

为了使React.memo能够正确完成其工作,我们需要使用useCallback钩子来记住函数。这样,将在每次渲染后函数都提供相同的引用。

该代码没有资源泄漏,可以正确实现,没有性能问题,可以看到即使对于简单的计数器,代码也相当复杂了。

13. 自定义钩子

function useCounter(initialValue, ms) {
  const [count, setCount] = useState(initialValue);

  const intervalRef = useRef(null);  

  const start = useCallback(() => {
    if (intervalRef.current !== null) {
      return;
    }    

    intervalRef.current = setInterval(() => {
      setCount(c => c + 1);
    }, ms);
  }, []);  

  const stop = useCallback(() => {
    if (intervalRef.current === null) {
      return;
    }    

    clearInterval(intervalRef.current);

    intervalRef.current = null;
  }, []);  

  const reset = useCallback(() => {
    setCount(0);
  }, []);  

  return { count, start, stop, reset };
}

为了简化代码,我们需要将所有复杂性封装在useCounter自定义钩子内,并公开干净的api:{count,start,stop,reset}。

export default function Level13() {
  console.log('renderLevel13'); 

  const { count, start, stop, reset } = useCounter(0, 500);  
  
  return (
    <div>
      count => {count}
      <button onClick={start}>start</button>
      <button onClick={stop}>stop</button>
      <button onClick={reset}>reset</button>
    </div>
  );
}

React Hooks雷达

xxx

所有的React Hook都是平等的,但是有些钩子比其他的钩子更平等。

Green

绿色钩子是现代React应用程序的主要构建块。他们几乎可以安全地在所有地方使用。

  1. useReducer
  2. useState
  3. useContext

🌕 Yellow

黄色钩子通过使用记忆提供了有用的性能优化。管理生命周期和输入应谨慎进行。

  1. useCallback
  2. useMemo

🔴 Red

红钩通过副作用与可变的世界互动。它们功能最强大,应格外小心。对于所有非普通的用例,建议使用自定义钩子。

  1. useRef
  2. useEffect
  3. useLayoutEffect

React Hooks建议

xxx

  1. 遵守hooks规则。
  2. 不要在主渲染功能中产生任何副作用。
  3. 取消订阅/处理/销毁所有使用的资源。
  4. 对于useState,最好使用useReducer或函数更新,以防止在hook中读取和写入相同的值。
  5. 不要在render函数中使用可变变量,而要使用useRef。
  6. 如果您保存在useRef中的内容的生命周期比组件本身的生命周期短,请不要忘记在处理资源时取消设置该值。
  7. 注意无限递归和资源消耗匮乏。
  8. 在需要时记忆功能和对象以提高性能。
  9. 正确捕获输入依赖关系(undefined=>每个渲染,[a,b] =>当a或b更改时,[] =>仅一次)。
  10. 将自定义钩子用于非普通用例。