React Hooks最佳实践

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 (
    

You clicked {count} times

); }

注意其官方示例中对于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 (
        
    { count }
    ); }

    如果我们需要在每次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 (
        
    { count }
    ); }

    需要注意的是,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 (
        
    { count }
    ); }

    假如可以预料到一次操作需要多次更新并且依赖上一次更新的值,那么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 (
        
    { count }
    ); }

    这种情况并不常见,通常在用户交互的事件处理函数中,比如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 (
        
    ); }
  • 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 (
        
    { count }
    ); }

    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 (
        
    ); }

    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 (
        
    { count }
    ); }

    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 (
          
      ); }

      比如这个地图组件,它的缩放倍数是通过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 (