React Hooks 整理

1.useState

  • 使用单个 state 变量还是多个 state 变量

    useState 的出现,让我们可以使用多个 state 变量来保存 state,比如:

    const [left, setLeft] = useState(0);
    const [top, setTop] = useState(0);
    

    但同时,我们也可以像 Class 组件的 this.state 一样,将所有的 state 放到一个 object 中, 这样只需一个 state 变量即可:

     const [state, setState] = useState({
       left: 0,
       top: 0
     });
    

    如果使用单个 state 变量,每次更新 state 时需要合并之前的 state。因为 useState 返回的 setState 会替换原来的值。这一点和 Class 组件的 this.setState 不同。this.setState 会把更新的字段自动合并到 this.state 对象中。

     const handleMouseMove = (e) => {
       setState((prevState) => ({
       ...prevState,
       left: e.pageX,
       top: e.pageY,
       }))
     };
    

    使用多个 state 变量可以让 state 的粒度更细,更易于逻辑的拆分和组合。比如,我们可以将关联的逻辑提取到自定义 Hook 中:

     function usePosition() {
       const [left, setLeft] = useState(0);
       const [top, setTop] = useState(0);
    
       useEffect(() => {
       // ...
       }, []);
    
       return [left, top, setLeft, setTop];
     }
    

    我们发现,每次更新 left 时 top 也会随之更新。因此,把 top 和 left 拆分为两个 state 变量显得有点多余。
    在使用 state 之前,我们需要考虑状态拆分的「粒度」问题。如果粒度过细,代码就会变得比较冗余。如果粒度过粗,代码的可复用性就会降低。那么,到底哪些 state 应该合并,哪些 state 应该拆分呢?我总结了下面两点:

    1.将完全不相关的 state 拆分为多组 state。比如 size 和 position。
    2.如果某些 state 是相互关联的,或者需要一起发生改变,就可以把它们合并为一组 state。 比如 left 和 top。

    function Box() {
      const [position, setPosition] = usePosition();
      const [size, setSize] = useState({width: 100, height: 100});
      // ...
    }
    
    function usePosition() {
      const [position, setPosition] = useState({left: 0, top: 0});
    
      useEffect(() => {
        // ...
      }, []);
    
      return [position, setPosition];
    }
    
  • 使用setState更新state的选择
    传值更新

    setState(newState);
    

    函数式更新
    如果新的 state 需要通过使用先前的 state 计算得出,那么可以将函数传递给 setState。该函数将接收先前的 state,并返回一个更新后的值。下面的计数器组件示例展示了 setState 的两种用法:

    function Counter({initialCount}) {
      const [count, setCount] = useState(initialCount);
      return (
        <>
          Count: {count}
          <button onClick={() => setCount(initialCount)}>Reset</button>
          <button onClick={() => setCount(prevCount => prevCount - 1)}>-</button>
          <button onClick={() => setCount(prevCount => prevCount + 1)}>+</button>
        </>
      );
    }
    

    “+” 和 “-” 按钮采用函数式形式,因为被更新的 state 需要基于之前的 state。但是“重置”按钮则采用普通形式,因为它总是把 count 设置回初始值。
    如果你的更新函数返回值与当前 state 完全相同,则随后的重渲染会被完全跳过。

    除此之外,我们还可以在其他地方活用函数式更新。
    有时候,你的 effect 可能会使用一些频繁变化的值。你可能会忽略依赖列表中 state,但这通常会引起 Bug:

     function Counter() {
        const [count, setCount] = useState(0);
    
        useEffect(() => {
           const id = setInterval(() => {
           setCount(count + 1); // 这个 effect 依赖于 `count` state
           }, 1000);
           return () => clearInterval(id);
        }, []); // 🔴 Bug: `count` 没有被指定为依赖
    
        return <h1>{count}</h1>;
     }
    

    传入空的依赖数组 [],意味着该 hook 只在组件挂载时运行一次,并非重新渲染时。但如此会有问题,在 setInterval 的回调中,count 的值不会发生变化。因为当 effect 执行时,我们会创建一个闭包,并将 count 的值被保存在该闭包当中,且初值为 0。每隔一秒,回调就会执行 setCount(0 + 1),因此,count 永远不会超过 1。
    指定 [count] 作为依赖列表就能修复这个 Bug,但会导致每次改变发生时定时器都被重置。事实上,每个 setInterval 在被清除前(类似于 setTimeout)都会调用一次。但这并不是我们想要的。要解决这个问题,我们可以使用 setState 的函数式更新形式。它允许我们指定 state 该 如何 改变而不用引用 当前 state:

    function Counter() {
      const [count, setCount] = useState(0);
    
      useEffect(() => {
        const id = setInterval(() => {
        setCount(c => c + 1); // ✅ 在这不依赖于外部的 `count` 变量
        }, 1000);
         return () => clearInterval(id);
      }, []); // ✅ 我们的 effect 不适用组件作用域中的任何变量
    
       return <h1>{count}</h1>;
    }
    

    此时,setInterval 的回调依旧每秒调用一次,但每次 setCount 内部的回调取到的 count 最新值(在回调中变量命名为 c)。

2.useEffect

  • effect清除

    通常,组件卸载时需要清除 effect 创建的诸如订阅或计时器 ID 等资源。要实现这一点,useEffect 函数需返回一个清除函数。为防止内存泄漏,清除函数会在组件卸载前执行。

    1. 清除订阅
    useEffect(() => {
      const subscription = props.source.subscribe();
      return () => {
      // 清除订阅
      subscription.unsubscribe();
     };
    });
    
    1. 避免组件unmount后的state update
     useEffect(() => {
      let ignore = false;
      async function fetchProduct() {
       const response = await fetch('http://myapi/product/' + productId);
       const json = await response.json();
       if (!ignore) setProduct(json);
      }
    
     fetchProduct();
     return () => { ignore = true };
    }, [productId]);
    
  • 使用useEffect deps过多

    使用 useEffect hook 时,为了避免每次 render 都去执行它的 callback,我们通常会传入第二个参数「dependency array」(下面统称为依赖数组)。这样,只有当依赖数组发生变化时,才会执行 useEffect 的回调函数。

    function Example({id, name}) {
      useEffect(() => {
        // 由于依赖数组中不包含 name,所以当 name 发生变化时,无法打印日志
        console.log(id, name); 
      }, [id]);
    }
    

    在 React 中,除了 useEffect 外,接收依赖数组作为参数的 Hook 还有 useMemo、 useCallback 和 useImperativeHandle。我们刚刚也提到了,依赖数组中千万不要遗漏回调函数内部依赖的值。但是,如果依赖数组依赖了过多东西,可能导致代码难以维护.

    const refresh = useCallback(() => {
      // ...
    }, [name, searchState, address, status, personA, personB, progress, page, size]);
    

    不要说内部逻辑了,光是看到这一堆依赖就令人头大!如果项目中到处都是这样的代码,可想而知维护起来多么痛苦。如何才能避免写出这样的代码呢?

    首先,你需要重新思考一下,这些 deps 是否真的都需要?看下面这个例子:

     function Example({id}) {
       const requestParams = useRef({});
    
       useEffect(() => {
         requestParams.current = {page: 1, size: 20, id};
       });
    
       const refresh = useCallback(() => {
         doRefresh(requestParams.current);
       }, []);
    
    
       useEffect(() => {
         id && refresh();
       }, [id, refresh]); // 思考这里的 deps list 是否合理?
    }
    

    虽然 useEffect 的回调函数依赖了 id 和 refresh 方法,但是观察 refresh 方法可以发现,它在首次 render 被创建之后,永远不会发生改变了。因此,把它作为 useEffect 的 deps 是多余的。

    其次,如果这些依赖真的都是需要的,那么这些逻辑是否应该放到同一个 hook 中?

    function Example({id, name, address, status, personA, personB, progress}) {
      const [page, setPage] = useState();
      const [size, setSize] = useState();
    
      const doSearch = useCallback(() => {
       // ...
      }, []);
    
      const doRefresh = useCallback(() => {
       // ...
      }, []);
    
    
      useEffect(() => {
       id && doSearch({name, address, status, personA, personB, progress});
       page && doRefresh({name, page, size});
     }, [id, name, address, status, personA, personB, progress, page, size]);
    }
    

    可以看出,在 useEffect 中有两段逻辑,这两段逻辑是相互独立的,因此我们可以将这两段逻辑放到不同 useEffect 中:

    useEffect(() => {
     id && doSearch({name, address, status, personA, personB, progress});
    }, [id, name, address, status, personA, personB, progress]);
    
    useEffect(() => {
     page && doRefresh({name, page, size});
    }, [name,  page, size]);
    

    如果逻辑无法继续拆分,但是依赖数组还是依赖了过多东西,该怎么办呢?就比如我们上面的代码:

    useEffect(() => {
     id && doSearch({name, address, status, personA, personB, progress});
    }, [id, name, address, status, personA, personB, progress]);
    

    这段代码中的 useEffect 依赖了七个值,还是偏多了。仔细观察上面的代码,可以发现这些值都是「过滤条件」的一部分,通过这些条件可以过滤页面上的数据。因此,我们可以将它们看做一个整体,也就是我们前面讲过的合并 state:

     const [filters, setFilters] = useState({
       name: "",
       address: "",
       status: "",
       personA: "",
       personB: "",
      progress: ""
     });
    
    useEffect(() => {
      id && doSearch(filters);
    }, [id, filters]);
    

    如果 state 不能合并,在 callback 内部又使用了 setState 方法,那么可以考虑使用 setState callback 来减少一些依赖。比如:

     const useValues = () => {
      const [values, setValues] = useState({
        data: {},
        count: 0
      });
    
      const [updateData] = useCallback(
        (nextData) => {
         setValues({
           data: nextData,
           count: values.count + 1 // 因为 callback 内部依赖了外部的 values 变量,所以必须在依赖数组中指定它
         });
       },
       [values], 
       );
    
       return [values, updateData];
    };
    

    上面的代码中,我们必须在 useCallback 的依赖数组中指定 values,否则我们无法在 callback 中获取到最新的 values 状态。但是,通过 setState 回调函数,我们不用再依赖外部的 values 变量,因此也无需在依赖数组中指定它。就像下面这样:

     const useValues = () => {
      const [values, setValues] = useState({});
    
      const [updateData] = useCallback((nextData) => {
        setValues((prevValues) => ({
        data: nextData,
        count: prevValues.count + 1, // 通过 setState 回调函数获取最新的 values 状态,这时 callback 不再依赖于外部的 values 变量了,因此依赖数组中不需要指定任何值
       }));
      }, []); // 这个 callback 永远不会重新创建
    
      return [values, updateData];
    };
    

    说了这么多,归根到底都是为了写出更加清晰、易于维护的代码。如果发现依赖数组依赖过多,我们就需要重新审视自己的代码。

    1.依赖数组依赖的值最好不要超过 3 个,否则会导致代码会难以维护。
    2.如果发现依赖数组依赖的值过多,我们应该采取一些方法来减少它。
    3.去掉不必要的依赖。
    4.将 Hook 拆分为更小的单元,每个 Hook 依赖于各自的依赖数组。
    5.通过合并相关的 state,将多个依赖值聚合为一个。
    6.通过 setState 回调函数获取最新的 state,以减少外部依赖。

  • useMemo

    该不该使用 useMemo?对于这个问题,有的人从来没有思考过,有的人甚至不觉得这是个问题。不管什么情况,只要用 useMemo 或者 useCallback 「包裹一下」,似乎就能使应用远离性能的问题。但真的是这样吗?有的时候 useMemo 没有任何作用,甚至还会影响应用的性能。

    为什么这么说呢?首先,我们需要知道 useMemo本身也有开销。useMemo 会「记住」一些值,同时在后续 render 时,将依赖数组中的值取出来和上一次记录的值进行比较,如果不相等才会重新执行回调函数,否则直接返回「记住」的值。这个过程本身就会消耗一定的内存和计算资源。因此,过度使用 useMemo 可能会影响程序的性能。

    要想合理使用 useMemo,我们需要搞清楚 useMemo 适用的场景:

    • 有些计算开销很大,我们就需要「记住」它的返回值,避免每次 render 都去重新计算。
    • 由于值的引用发生变化,导致下游组件重新渲染,我们也需要「记住」这个值。

    让我们来看个例子:

     interface IExampleProps {
       page: number;
       type: string;
     }
    
    const Example = ({page, type}: IExampleProps) => {
       const resolvedValue = useMemo(() => {
         return getResolvedValue(page, type);
       }, [page, type]);
    
       return <ExpensiveComponent resolvedValue={resolvedValue}/>;
    };
    

    在上面的例子中,渲染 ExpensiveComponent 的开销很大。所以,当 resolvedValue 的引用发生变化时,作者不想重新渲染这个组件。因此,作者使用了 useMemo,避免每次 render 重新计算 resolvedValue,导致它的引用发生改变,从而使下游组件 re-render。

    这个担忧是正确的,但是使用 useMemo 之前,我们应该先思考两个问题:

    1.传递给 useMemo 的函数开销大不大?在上面的例子中,就是考虑 getResolvedValue 函数的开销大不大。JS 中大多数方法都是优化过的,比如 Array.map、Array.forEach 等。如果你执行的操作开销不大,那么就不需要记住返回值。否则,使用 useMemo 本身的开销就可能超过重新计算这个值的开销。因此,对于一些简单的 JS 运算来说,我们不需要使用 useMemo 来「记住」它的返回值。

    2.当输入相同时,「记忆」值的引用是否会发生改变?在上面的例子中,就是当 page 和 type 相同时,resolvedValue 的引用是否会发生改变?这里我们就需要考虑 resolvedValue 的类型了。如果 resolvedValue 是一个对象,由于我们项目上使用「函数式编程」,每次函数调用都会产生一个新的引用。但是,如果 resolvedValue 是一个原始值(string, boolean, null, undefined, number, symbol),也就不存在「引用」的概念了,每次计算出来的这个值一定是相等的。也就是说,ExpensiveComponent 组件不会被重新渲染。

    因此,如果 getResolvedValue 的开销不大,并且 resolvedValue 返回一个字符串之类的原始值,那我们完全可以去掉 useMemo,就像下面这样:

    interface IExampleProps {
     page: number;
     type: string;
    }
    
    const Example = ({page, type}: IExampleProps) => {
     const resolvedValue = getResolvedValue(page, type);
     return <ExpensiveComponent resolvedValue={resolvedValue}/>;
    };
    

    保持引用不变

     // 使用 useMemo
    function Example() {
      const users = useMemo(() => [1, 2, 3], []);
    
      return <ExpensiveComponent users={users} />
    }
    

    在上面的例子中,我们用 useMemo 来「记住」users 数组,不是因为数组本身的开销大,而是因为 users 的引用在每次 render 时都会发生改变,从而导致子组件 ExpensiveComponent 重新渲染(可能会带来较大开销)。

    在编写自定义 Hook 时,返回值一定要保持引用的一致性。因为你无法确定外部要如何使用它的返回值。如果返回值被用做其他 Hook 的依赖,并且每次 re-render 时引用不一致(当值相等的情况),就可能会产生 bug。比如:

    function Example() {
     const data = useData();
     const [dataChanged, setDataChanged] = useState(false);
    
     useEffect(() => {
       setDataChanged((prevDataChanged) => !prevDataChanged); // 当 data 发生变化时,调用 setState。如果 data 值相同而引用不同,就可能会产生非预期的结果。
     }, [data]);
    
     console.log(dataChanged);
    
     return <ExpensiveComponent data={data} />;
    }
    
    const useData = () => {
    // 获取异步数据
    const resp = getAsyncData([]);
    
    // 处理获取到的异步数据,这里使用了 Array.map。因此,即使 data 相同,每次调用得到的引用也是不同的。
    const mapper = (data) => data.map((item) => ({...item, selected: false}));
    
    return resp ? mapper(resp) : resp;
    };
    

    在上面的例子中,我们通过 useData Hook 获取了 data。每次 render 时 data 的值没有发生变化,但是引用却不一致。如果把 data 用到 useEffect 的依赖数组中,就可能产生非预期的结果。另外,由于引用的不同,也会导致 ExpensiveComponent 组件 re-render,产生性能问题。

    因此,在使用 useMemo 之前,我们不妨先问自己几个问题:

    1.要记住的函数开销很大吗?
    2.返回的值是原始值吗?
    3.记忆的值会被其他 Hook 或者子组件用到吗?

    一、应该使用 useMemo 的场景

    1.保持引用相等

    • 对于组件内部用到的 object、array、函数等,如果用在了其他 Hook 的依赖数组中,或者作为 props 传递给了下游组件,应该使用 useMemo。
    • 自定义 Hook 中暴露出来的 object、array、函数等,都应该使用 useMemo 。以确保当值相同时,引用不发生变化。
    • 使用 Context 时,如果 Provider 的 value 中定义的值(第一层)发生了变化,即便用了 Pure Component 或者 React.memo,仍然会导致子组件 re-render。这种情况下,仍然建议使用 useMemo 保持引用的一致性。
    1. 成本很高的计算

    二、无需使用 useMemo 的场景

    • 如果返回的值是原始值: string, boolean, null, undefined, number, symbol(不包括动态声明的 Symbol),一般不需要使用 useMemo。
    • 仅在组件内部用到的 object、array、函数等(没有作为 props 传递给子组件),且没有用到其他 Hook 的依赖数组中,一般不需要使用 useMemo。
  • useRef

    useRef 返回一个可变的 ref 对象,其 .current 属性被初始化为传入的参数(initialValue)。返回的 ref 对象在组件的整个生命周期内保持不变。
    1.使用ref访问子组件

    function TextInputWithFocusButton() {
     const inputEl = useRef(null);
     const onButtonClick = () => {
     // `current` 指向已挂载到 DOM 上的文本输入元素
     inputEl.current.focus();
     };
     return (
       <>
        <input ref={inputEl} type="text" />
        <button onClick={onButtonClick}>Focus the input</button>
       </>
     );
    }
    

    除此之外,useRef()ref 属性更有用。它可以很方便地保存任何可变值,其类似于在 class 中使用实例字段的方式。
    这是因为它创建的是一个普通 Javascript 对象。而 useRef() 和自建一个 {current: ...} 对象的唯一区别是,useRef 会在每次渲染时返回同一个 ref 对象。

    1. 使用useRef保证引用不变
      // 使用 useRef
    function Example() {
      const {current: users} = useRef([1, 2, 3]);
    
      return <ExpensiveComponent users={users} />
    }
    
    1. 使用ref保存可变变量
      实现获取上一轮的 props 或 state
    function Counter() {
      const [count, setCount] = useState(0);
      const prevCount = usePrevious(count);
      return <h1>Now: {count}, before: {prevCount}</h1>;
    }
    
    function usePrevious(value) {
      const ref = useRef();
      useEffect(() => {
        ref.current = value;
      });
      return ref.current;
    }
    

    实现effect deps减少

    function Example(props) {
      // 把最新的 props 保存在一个 ref 中
      const latestProps = useRef(props);
      useEffect(() => {
        latestProps.current = props;
      });
    
      useEffect(() => {
        function tick() {
        // 在任何时候读取最新的 props
        console.log(latestProps.current);
      }
    
       const id = setInterval(tick, 1000);
       return () => clearInterval(id);
      }, []); // 这个 effect 从不会重新执行
    }
    
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,839评论 6 482
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,543评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 153,116评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,371评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,384评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,111评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,416评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,053评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,558评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,007评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,117评论 1 334
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,756评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,324评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,315评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,539评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,578评论 2 355
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,877评论 2 345