React Hooks最佳实践

折丹 / 125 /

ChatGPT 可用网址,仅供交流学习使用,如对您有所帮助,请收藏并推荐给需要的朋友。
https://ckai.xyz

React自从16.8版本增加Hooks概念以来,其老版官方文档对于Hooks的解释是基于类组件来类比的,很容易让人觉得Hooks是不一样写法的类组件实现。目前React出了一份全新的官方文档,完全基于Hooks与函数式组件来描述React机制,与之前的文档大相径庭,这就导致了很多老旧项目的Hooks代码是过时的,并不符合其新文档的理念,这里将对Hooks最佳实践进行总结。

为什么需要Hooks

Hooks发布之初React就在其老版官方文档介绍了动机,主要有如下几个原因。

  • 很难在组件之间复用有状态的逻辑

    对于之前的类组件,状态都是与组件绑定到一起的,可以看待成组件的私有属性。这样的话,涉及到有状态逻辑在组件间重用就变得很困难,类组件的解决办法为render props或者高阶组件。但是当你用到这两种模式的时候,你需要改变你的组件结构,并且会带来很多不必要的组件嵌套,让调试变得困难,代码的复杂程度也会提升很多。而Hooks将状态完全与组件抽离开来,是一个独立的东西,这样就能够让我们封装普通函数一样封装有状态逻辑,并且能轻松的在不同组件中复用。

  • 复杂的类组件很难理解与维护

    由于单向数据流与状态提升的原因,我们很容易遇到需要同时处理很多逻辑的巨型组件,这带来了很严重的耦合性,一个componentDidMount生命周期里可能会包含很多不相关的代码,并且一个1000行的组件维护起来也足够令人头疼。

  • 类的学习成本很高

    React组件分为两种,类组件与函数式组件,这两者的复杂度相差巨大,类组件带来了相当多的模板代码与难以理解的this指向。在Hooks诞生之前,函数式组件是无状态的,使用场景非常受限,但是当有Hooks之后,我们所有的组件都可以使用更简洁更容易理解的函数式组件。

老版文档对于Hooks使用引起的误导

对于Hooks产生动机方面,老版文档解释的非常清楚,但是对于Hooks如何使用,老版文档只详细说了useState,useEffect以及自定义Hooks,其中useEffect的使用很具有误导性,至于其他的Hooks只列了一个api列表简单的描述了一下各自的作用。

useState的使用基本没什么争议,我们主要看useEffect

import React, { useState, useEffect } from 'react';

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

  // Similar to componentDidMount and componentDidUpdate:
  useEffect(() => {
    // Update the document title using the browser API
    document.title = `You clicked ${count} times`;
  });

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  );
}

注意其官方示例中对于useEffect的注释,相当于让开发者认为它是生命周期的平替,这在最新的React文档中恰恰是错误的做法。当然,可能考虑到开发者刚开始从类组件转换到Hooks函数式组件需要一个参照或者一个最简单的上手示例,这样是最明了的表达方式。不过现如今新文档已经发布,对于Hooks的解释非常清晰且深入,这次我们就专注于新文档重塑对于Hooks的认知。

最佳实践

  • useState

    我们都知道React是基于函数式回调来更新UI的,状态的改变不能直接赋值,需要调用特定的setState函数来通知React更新DOM,类组件中通过重新执行render函数来实现,函数式组件因为没有render函数,所以其整个函数体就会在更新DOM的时候重新调用,这叫做re-render,那么函数中的普通变量就无法扮演状态的角色,因为每次函数重新调用,都会生成一个新的变量,导致每次都是初始值

    function Component() {
      let count = 0;  // 每次re-render,函数重新执行,count始终是0
      
      return (
        <div>{ count }</div>
      );
    }

    如果我们需要在每次re-render都记住一个状态的最新值,那就需要使用useState hook,其始终会返回状态的最新值。

    import { useState } from 'react';
    
    function Component() {
      const [count, setCount] = useState(0); // 每次re-render,count返回setCount设置的最新值
      
      function handleIncrease() {
        setCount(count + 1); // 触发re-render
      }
      
      return (
        <div>
          <button onClick="handleIncrease">+</button>
          { count }
        </div>
      );
    }

    需要注意的是,useState返回的set函数并不会立刻更新状态值,而是会批量更新,所以在set函数执行后,状态可能还是原始值,需要等到下次render值才会更新,所以假如多次调用set函数并且依赖了状态值,结果可能在预料之外。

    import { useState } from 'react';
    
    function Component() {
      const [count, setCount] = useState(0);
      
      function handleIncrease3() {
        // 并不会+3,每次点击还是只+1
        setCount(count + 1); // setCount(0 + 1)
        setCount(count + 1); // setCount(0 + 1)
        setCount(count + 1); // setCount(0 + 1)
      }
      
      return (
        <div>
          <button onClick="handleIncrease3">+</button>
          { count }
        </div>
      );
    }

    假如可以预料到一次操作需要多次更新并且依赖上一次更新的值,那么set函数应该传入函数来更新值。

    import { useState } from 'react';
    
    function Component() {
      const [count, setCount] = useState(0);
      
      function handleIncrease3() {
        // 这次与预期相符
        setCount(pre => pre + 1); // setCount(0 + 1)
        setCount(pre => pre + 1); // setCount(1 + 1)
        setCount(pre => pre + 1); // setCount(2 + 1)
      }
      
      return (
        <div>
          <button onClick="handleIncrease3">+</button>
          { count }
        </div>
      );
    }

    这种情况并不常见,通常在用户交互的事件处理函数中,比如click,React会在下一次事件触发之前更新状态,所以假如在一次事件处理函数中没有多次更新的情况下,setCount(count + 1)任然是可靠的。

    对于引用类型的状态,React推荐开发者遵从不可变原则,即不要改变现存的引用类型状态的内部值,具体原因这里有详细说明,在此不赘述,提供一些例子供参考。

    import { useState } from 'react';
    
    function Component() {
      const [userInfo, setUserInfo] = useState({
        user: {
          name: '',
          age: 0
        },
        permissions: []
      });
      
      function handleChangeName(name) {
        setUserInfo({
          ...userInfo,
          user: {
            ...userInfo.user,
            name
          }
        });
      }
      
      function handleAddPermission(permission) {
        setUserInfo({
          ...userInfo,
          permissions: [
            ...userInfo.permissions,
            permission
          ]
        });
      }
      
      return (
        <div>
          <button onClick="handleChangeName">Submit User Name</button>
          <button onClick="handleAddPermission">Add Permission</button>
        </div>
      );
    }
  • useRef

    由于每次re-render函数组件的函数体都会重新执行,所以定义的普通变量每次都会变成初始值,而useState生成的值必须通过set函数更新而触发re-render,如果我们仅需要缓存某个变量值而不希望改变它的时候造成重新渲染(因为这个变量与UI无关),那么就需要使用useRef钩子函数。

    import { useRef } from 'react';
    
    function Component() {
      const intervalRef = useRef(0);
      
      function handleIncrease() {
        intervalRef.current = setInterval(() => {
          // ...
        }, 1000);
      }
      
      return (
        <div>
          <button onClick="handleIncrease">+</button>
          { count }
        </div>
      );
    }

    useRef返回的值是一个对象,包含一个current属性,这个属性的值才是ref的真实值,所以无论是读取还是设置值,都需要.current。另外需要注意的是,不要在render期间读取或者改变ref的值,这虽然不会像useState一样造成死循环,但是这违背了React函数组件必须是纯函数的原则。

    useRef另一个常见的用例是操作DOM,当我们需要引用DOM元素时,可以使用ref属性配合useRef来实现

    import { useRef } from 'react';
    
    function Component() {
      const inputRef = useRef(null);
      
      function handleFocus() {
        inputRef.current.focus();
      }
      
      return (
        <div>
          <input ref={inputRef} />
          <button onClick="handleFocus">Focus</button>
        </div>
      );
    }

    useRef除了可以引用原生DOM元素外,还可以引用React组件,这需要配合另一个钩子函数useImperativeHandleforwardRef来实现,后文再介绍其用法。

  • useEffect

    接下来就是需要重点关注的useEffect,我们先来看React文档对于useEffect的定义:

    useEffect is a React Hook that lets you synchronize a component with an external system.

    这里提到的关键词是外部系统,查阅其深层的说明,会发现文档对于useEffect的定位是一个escape hatch,除非使用经典的React范式无法解决的场景,否则不推荐使用他,移除不必要的Effect会让你的代码变的更易读,更快,更不容易出错。接下来我们会着重探讨哪些场景需要使用useEffect,而哪些场景中的useEffect又是不必要的,当然在这之前,先介绍一下useEffect的用法。

    import { useState, useEffect } from 'react';
    
    function Component() {
      const [count, setCount] = useState(0);
      
      // 第一个参数是一个回调函数,会根据第二个参数在每次render之后执行
      // 第二个参数是依赖数组,依赖包含props, state, 以及函数组件内的任何变量(不包含ref)
      // 依赖数组如果不传,那么每次render之后都会执行回调
      // 如果传空数组,那么只执行一次
      // 如果传对应的响应变量,那么回调只会在响应变量变化的时候执行
      // 响应变量是否变化使用Object.is()比较
      useEffect(() => {
        console.log('effect run');  // 这里每次render都会执行
        
        return () => {};  // 清除函数,若存在,则每次先执行清除函数再执行下一次Effect
      });
      
      useEffect(() => {
        console.log('effect run');  // 这里只会在第一次render时执行
      }, []);
      
      useEffect(() => {
        console.log('effect run', count);  // 第一次render立马执行,然后仅在count变化的时候执行
      }, [count]);
      
      function handleIncrease() {
        setCount(count + 1);
      }
      
      return (
        <div>
          <button onClick="handleIncrease">+</button>
          <span>{ count }</span>
        </div>
      );
    }

    useEffect的使用场景大概分为以下几个类别:

    • 与外部系统交互

      import { useEffect } from 'react';
      import { createConnection } from './chat.js';
      
      function ChatRoom({ roomId }) {
        const [serverUrl, setServerUrl] = useState('https://localhost:1234');
      
        useEffect(() => {
            const connection = createConnection(serverUrl, roomId);
          connection.connect();
            return () => {
            connection.disconnect();
            };
        }, [serverUrl, roomId]);
        // ...
      }

      比如这个React文档的官方示例,页面需要在初始化的时候就连接一个聊天室,聊天室完全是属于一个外部的系统,应用内不关心他是如何实现的,聊天室只对外暴露了connectdisconnect两个方法,这种时候就只能使用useEffect实现。

    • 控制非React组件

      应用开发时,我们经常会遇到一些第三方组件,其实现方式并不是React,我们无法通过props的方式使用它,那么这个时候也只能使用useEffect

      import { useRef, useEffect } from 'react';
      import { MapWidget } from './map-widget.js';
      
      export default function Map({ zoomLevel }) {
        const containerRef = useRef(null);
        const mapRef = useRef(null);
      
        useEffect(() => {
          if (mapRef.current === null) {
            mapRef.current = new MapWidget(containerRef.current);
          }
      
          const map = mapRef.current;
          map.setZoom(zoomLevel);
        }, [zoomLevel]);
      
        return (
          <div
            style={{ width: 200, height: 200 }}
            ref={containerRef}
          />
        );
      }

      比如这个地图组件,它的缩放倍数是通过react状态控制的,当倍数变化的时候,就需要通过useEffect调用地图组件的API去同步这个状态,这个地图组件也可以称为外部系统。

    • 请求数据

      这个应该是useEffect最常见的使用场景了,因为页面上绝大多数信息都是需要请求接口获取,并且这个时机是在页面初始化的时候,这个时候也只能使用useEffect。但是像提交表单这样的事件驱动的请求是不需要useEffect的,只需要在对应的事件里发送请求就好了。

      import { useState, useEffect } from 'react';
      import { fetchData } from './api.js';
      
      export default function Page({ id }) {
        const [list, setList] = useState([]);
      
        useEffect(() => {
          let ignore = false;  // 这里是为了防止race condition导致bug
          fetchData(id).then(result => {
            if (!ignore) {
              setList(result);
            }
          });
          return () => {
            ignore = true;
          };
        }, [id]);
      
        // ...
      }

    以上场景都是正确使用useEffect的情况,并且一般情况下都推荐将Effect封装成自定义Hook,以提高代码的可读性与维护性。

    import { useState, useEffect } from 'react';
    import { fetchData } from './api.js';
    
    function useList(id) {
      const [list, setList] = useState([]);
      
      useEffect(() => {
        let ignore = false;
        fetchData(id).then(result => {
          if (!ignore) {
            setList(result);
          }
        });
        return () => {
          ignore = true;
        };
      }, [id]);
      
      return list;
    }
    
    
    export default function Page({id}) {
      const list = useList(id);  // 自定义Hook
    }
    

    使用useEffect的时候,有一个特别重要的点是,确保第二个参数,即依赖数组的正确性。我们项目中的代码很可能会有这种情况存在:

    useEffect(() => {
      // ...
      // 🔴 Avoid suppressing the linter like this:
      // eslint-ignore-next-line react-hooks/exhaustive-deps
    }, []);

    使用注释让linter忽略这个校验,不到万不得已尽量不要这么做,bug很可能就是从这里产生。那为什么我们项目中还是会有这种情况呢,因为很有可能这些依赖项不必要的出现在了Effect函数体中,开发者意识到了这并不是一个依赖项,偷懒式的使用注释一句话解决。真正要解决这个问题,需要改变Effect函数体代码,将不必要的依赖变量移除,向React linter证明它并不是这个Effect的依赖,开发者需要思考,让这个Effect重新执行到底需要哪些依赖项。

    还有一个需要注意的点是,直接把函数组件内的普通对象变量与函数作为Effect的依赖可能会导致死循环,因为每次re-render,对象和函数都是另一个不同的引用,这会被Object.is认为不相等,如果有这样的依赖,考虑使用useMemouseCallback,后面我们会再说到这两个Hook。

    接下来说一下哪些场景没有必要使用useEffect:

    • props或者state变化更新另一个state

      import { useState, useEffect } from 'react';
      
      function Component({ firstName, lastName }) {
        const [fullName, setFullName] = useState('');
        
        useEffect(() => {
          setFullName(firstName + ' ' + lastName);
        }, [firstName, lastName]);
      }

      类似fullName这样的状态可以归类为计算属性,和Vue的计算属性概念是一样的,不过React中的计算属性可以直接在函数组件中使用普通变量定义,因为无论是props与state更新,函数都会重新执行,改变量会一直是最新值。

      function Component({ firstName, lastName }) {
        const fullName = firstName + ' ' + lastName;  // 使用计算属性替代useEffect
      }
    • props变化重置状态

      想象一个场景,有一个联系人信息页面,根据不一样的用户ID输入不一样的备注。

      import { useState, useEffect } from 'react';
      
      function ProfilePage({ userId }) {
        const [comment, setComment] = useState('');
      
        useEffect(() => {
          setComment('');  // 用户ID变化重置备注
        }, [userId]);
        
        return (
          <textarea value={comment} onChange={e => setComment(e.target.value)}/>
        )
      }
      

      React默认使用同组件同位置的策略确定组件状态是否需要保留,为组件指定一个key可以让React根据key是否变化来替代默认策略。

      import { useState, useEffect } from 'react';
      
      function ProfilePage({ userId }) {
        return (
          <Profile
            userId={userId}
            key={userId}  // 通过key来重置state
          />
        );
      }
      
      function Profile({ userId }) {
        const [comment, setComment] = useState('');
        
        return (
          <textarea value={comment} onChange={e => setComment(e.target.value)}/>
        )
      }
    • 使用Effect处理用户交互事件

      import { useState, useEffect } from 'react';
      
      function Form() {
        const [firstName, setFirstName] = useState('');
        const [lastName, setLastName] = useState('');
        const [jsonToSubmit, setJsonToSubmit] = useState(null);
        
        // 🔴 Avoid: Event-specific logic inside an Effect
        useEffect(() => {
          if (jsonToSubmit !== null) {
            post('/api/register', jsonToSubmit);
          }
        }, [jsonToSubmit]);
      
        function handleSubmit(e) {
          e.preventDefault();
          setJsonToSubmit({ firstName, lastName });
        }
      }

      这样的通过监听state的中间方式是不必要的,应该直接在事件处理函数中处理对应逻辑

      function Form() {
        const [firstName, setFirstName] = useState('');
        const [lastName, setLastName] = useState('');
      
        function handleSubmit(e) {
          e.preventDefault();
          // ✅ Good: Event-specific logic is in the event handler
          post('/api/register', { firstName, lastName });
        }
      }

      总的来说,不必要使用useEffect可以分为两大类:

      1. render中的数据转换
      2. 用户交互事件触发的逻辑
  • useMemo、useCallback

    由于函数式组件re-render会将函数重新执行,假如我们有一个重计算的计算属性,那么每次render会带来一些额外的性能开销,useMemo可以将数据缓存起来,除非依赖变化,才会重新计算结果。(useCallbackuseMemo针对函数数据类型提供的一个语法糖,和useMemo本质是一样的)

    import { useMemo } from 'react';
    import { someExpensiveCalc } from 'utils';
    
    function Component({ tree }) {
      const filterTree = someExpensiveCalc(tree);  // 每次re-render都会重新执行重计算
      const filterTreeCache = useMemo(
        () => someExpensiveCalc(tree),
        [tree]
      );  // 只有tree变化的时候才会执行
    }

    useMemo不应该被滥用,没有必要为每一个计算属性都包裹上useMemo,这只是一个性能优化手段,如果这段计算代码并不耗费性能,包裹上useMemo没有任何好处,反而会降低代码可读性。如果你不确定某个计算是否耗费性能,可以在前后打印一下代码执行时间,如果达到了1ms,那么使用useMemo是有益处的。

    useMemo的另一个用法是配合memo函数跳过组件re-render,React重渲染的逻辑是很激进的,一旦某个组件重渲染,会递归的让所有子组件都重渲染一遍,这就会带来一个问题,当很外层的父组件改变一个状态,整个组件树全都重渲染了,当然一般来说这并不会造成性能问题,因为React在重渲染的过程中会做最小化更新,只更新改变了的DOM。memo的使用动机是在开发者观测到了性能问题后,没有必要在刚开始的时候就使用memo进行性能优化。memo函数的用法是直接包裹组件,这样组件仅仅会在props变化的时候才进行re-render,但是我们知道,Object.is函数判断对象与函数,每一次都是一个不一样的值,所以如果你的组件的props是对象或者函数,并且需要使用memo进行性能优化的时候,需要用到useMemouseCallback

    import { memo, useMemo } from 'react';
    import { filterTodos } from 'utils';
    
    function List({ items }) {}
    
    const ListMemo = memo(List);
    
    function TodoList({ todos, tab, theme }) {
      const visibleTodosCache = useMemo(
        () => filterTodos(todos, tab),
        [todos, tab]
      );
      
      return (
        <div className={theme}>
          <ListMemo items={visibleTodos} />
        </div>
      );
    }

    最后一个使用场景就是配合useEffect,或者useMemouseCallback本身进行依赖缓存,前面说到了,依赖项如果是一个对象或者函数,每次re-render都会是不一样的值,所以需要使用useMemo或者useCallback进行缓存。

    import { useMemo, useCallback, useEffect } from 'react';
    
    function Component({ text }) {
      // const config = { type: 1, value: text };
      const configCache = useMemo(() => ({ type: 1, value: text }), [text]);
      
      // function getData() {}
      
      const getDataCache = useCallback(() => {
        // getData的逻辑
        // ...
      }, []);
      
      useEffect(() => {
        getDataCache(configCache);
      }, [getDataCache, configCache]);
    }
  • useContext

    Context类似于Vue中的Provide与Inject,作用是透过组件层级传递属性,避免props多级透传的麻烦。

    import { createContext, useContext } from 'react';
    
    const ThemeContext = createContext('');
    
    function App() {
      return (
        <ThemeContext.Provider value="dark">
          <Form />
        </ThemeContext.Provider>
      );
    }
    
    function Button() {
      const theme = useContext(ThemeContext);
    }

    Context因为其便利性很容易被过度使用,但是也会带来数据流不明确的问题。如果仅仅是为了多级透传,并不一定要使用Context,可以尝试重新设计组件结构,使用children减少props层级。或者干脆就多穿几次props,虽然这样看起来很繁琐,但是会使你的应用数据流更清晰。这里总结了几个典型的Context适用场景:

    • 主题
    • 用户信息
    • 路由
    • 状态管理

      一个比较经典的组合是Context + useReducer进行复杂的状态管理,但是需要写不少的模板代码,类似与redux。

  • useImperativeHandle

    上文说到useRef的一个作用是引用React组件,这时候就需要用到useImperativeHandleforwardRef

    import { forwardRef, useImperativeHandle, useRef } from 'react';
    
    function App() {
      const childRef = useRef(null);
      
      return (
        <div>
          <Child ref={childRef} />
          <button onClick={handleClick}>Focus</button>
        </div>
      );
    }
    
    const Child = forwardRef(function Child(props, ref) {
      const inputRef = useRef(null);
      
      useImperativeHandle(ref, () => {
        return {
          focus() {
            inputRef.current.focus();
          }
        };
      }, []);
      
      return <input ref={inputRef} />
    });

    这种模式不应该被过度使用,从这个钩子函数的名字也可以看出来,除非是不得已的情况,尽量不要使用引用组件的形式,最好使用React props数据流的范式来构建应用。

  • 自定义Hooks

    除了上述内置钩子,开发者还可以封装自己的自定义钩子函数来复用有状态逻辑,这也是Hooks设计之初最重要的功能之一。但是自定义Hooks是一个特别大的主题,足以另开一篇文章细说,这次就简单说说自定义Hooks的基本原则。

    React对于Hooks的设计规则是,只能写在组件最顶层作用域,并且以use开头的函数,详细的原因这里有说明。自定义Hooks也应该遵循这种规则,注意不要用use开头的函数去封装一些状态无关的工具函数,因为自定义Hooks一定是状态相关的,换句话说就是,自定义Hook内部一定用到了内置Hook或者其他自定义Hook,否则它就不应该以use开头被称为Hook。

    自定义Hooks是公用有状态逻辑,而不是公用状态本身,每一个自定义Hook的状态都是独立的,并不会共用。

    封装自定义Hook的时机因人而异,但是你不需要对每一个细小的逻辑都做封装,自定义Hook应该封装为具体的上层业务逻辑,下面提供一些典型的封装场景:

    • useData(url)
    • useChatRoom(options)
    • useMediaQuery(query)

    同时避免封装下面这些抽象的Hook:

    • useMount(fn)
    • useEffectOnce(fn)
    • useUpdateEffect(fn)

作者
折丹
许可协议
CC BY 4.0
发布于
2023-08-27
修改于
2024-12-22
Bonnie image
尚未登录