大家好,我是长林啊!一个爱好 JavaScript、Go、Rust 的全栈开发者;致力于终身学习和技术分享。

本文首发在我的微信公众号【长林啊】,欢迎大家关注、分享、点赞!

Hooks 是 React 官方团队在 React 16.8 版本中正式引入的概念。通俗的讲,Hooks 只是一些函数,Hooks 可以用于在函数组件中引入状态管理和生命周期方法;如果希望让 React 函数组件拥有状态管理和生命周期方法,我们不需要再去将 React 函数组件重构成 React 类组件,而是直接使用 React Hooks。

与原来的 React Class 的组件不同,React Hooks在类组件中不起作用的,React不仅提供了一些内置的 Hooks,比如useState,还可以自定义 Hooks,用于管理重复组件之间的状态。

初识 React Hooks

什么是 Hooks?

顾名思义,Hook 就是“钩子”的意思。在 React 中,Hooks 就是把某个目标结果钩到某个可能会变化的数据源或者事件源上,那么当被钩到的数据或事件发生变化时,产生这个目标结果的代码会重新执行,产生更新后的结果

Hooks 是一组特殊的函数,它们让你在不编写类的情况下使用 React 的状态管理和生命周期特性。通过 Hooks,开发者可以在函数组件中管理状态、执行副作用、以及利用其他 React 特性,使得函数组件具备了以前只有类组件才有的能力。Hooks 借鉴自函数式编程,但同时在使用上也有一些限制。

为什么引入 Hooks?

  • 简化组件逻辑: 类组件常常带来复杂的状态管理和生命周期方法嵌套问题。Hooks 使得逻辑分离和重用变得更加容易。
  • 提高可读性和复用性: 函数组件的写法通常更简洁,更易于理解。使用 Hooks 后,组件的逻辑可以被抽象为自定义 Hooks,使得代码更加模块化和可复用。
  • 避免复杂的 class 组件: Hooks 让函数组件能够拥有类组件的所有功能,而不需要复杂的 this 绑定或生命周期方法的学习成本。

React Hooks 的优点

在 React 中,理想的UI模型可以被表示为 UI=f(state)。在这个模型中,

  • UI: 代表视图
  • state: 代表应用的状态
  • f:代表的是渲染过程

Hooks 带来的最大好处就是逻辑复用逻辑分离

逻辑复用

以窗口大小绑定为例,假设有多个组件需要在用户调整浏览器窗口大小时进行布局的重新调整,那么我们就需要将这种逻辑抽取出来,作为一个公共模块供多个组件使用。根据 React 的理念,我们会根据Size的大小在JSX中渲染不同的组件。

function render() {
    if (size === "small") return <SmallComponent />;
    else return <LargeComponent />;
}

这段代码是一个用于渲染组件的功能,它依赖于一个名为 size 的变量。如果 size 的值为 "small",那么就渲染 SmallComponent 组件;如果 size 的值不是 "small",那么就渲染 <LargeComponent/> 组件。虽然这段代码看起来很简单,但在以前的 Class 组件中,我们甚至需要使用一个相对复杂的设计模式来解决这个问题,那就是高阶组件。因此,接下来我们可以通过实际的代码来对比一下:

import React from 'react';

// 定义一个高阶组件 withWindowSize
const withWindowSize = Component => {
    // 定义一个新的组件 WrappedComponent,这个组件的主要作用是监听窗口大小的变化
    class WrappedComponent extends React.PureComponent {
        constructor(props) {
            super(props);
            // 在组件的state中保存当前窗口的大小
            this.state = {
                size: this.getSize(),
            };
        }

        // 在组件被挂载到DOM后,添加一个resize事件监听器
        componentDidMount() {
            window.addEventListener('resize', this.handleResize);
        }

        // 在组件被卸载前,移除resize事件监听器
        componentWillUnmount() {
            window.removeEventListener('resize', this.handleResize);
        }

        // 获取当前窗口的大小,如果窗口宽度大于500,则返回'large',否则返回'small'
        getSize() {
            return window.innerWidth > 500 ? 'large' : 'small';
        }

        // 定义resize事件的处理函数,当窗口大小变化时,更新state中的size
        handleResize = () => {
            const currentSize = this.getSize();
            this.setState({ size: currentSize });
        };

        // 在render方法中,将窗口的大小作为属性传递给被包装的组件
        render() {
            return <Component size={this.state.size} />;
        }
    }
    // 返回新的组件
    return WrappedComponent;
};

export default withWindowSize;

withWindowSize 函数接收一个 React 组件作为参数,并返回一个新的 React 组件。新的组件会监听窗口大小的变化,并将窗口大小作为属性传递给原始的组件。这样,原始的组件就可以根据窗口大小来动态调整自己的行为或呈现方式。调用方式如下:

// Render.jsx
import React from 'react';
import withWindowSize from './with-window-size';

class SmallComponent extends React.Component {
    render() {
        return <p>Small Component</p>;
    }
}

class LargeComponent extends React.Component {
    render() {
        return <p>Large Component</p>;
    }
}

class MyComponent extends React.Component {
    render() {
        const { size } = this.props;
        if (size === 'small') return <SmallComponent />;
        else return <LargeComponent />;
    }
}

// 使用 withWindowSize 产生高阶组件,用于产生 size 属性传递给真正的业务组件
export default withWindowSize(MyComponent);

上面代码可在 clin211/react-awesome 中查看,效果如下图:
<img src="https://files.mdnice.com/user/8213/3de1e702-4ba1-46ce-99ed-4454acd218e0.gif" style="border:1px solid rgb(222, 198, 251);border-radius: 8px" />

上面 Render.jsx 文件中也可以看出,为了传递一个外部的状态,我们不得不定义一个没有 UI 的外层组件,而这个组件只是为了封装一段可重用的逻辑。更为糟糕的是,高阶组件几乎是 Class 组件中实现代码逻辑复用的唯一方式,其缺点其实比较显然:

  • 高阶组件的代码难以理解,也不够直观。
  • 会增加很多额外的组件节点,每一个高阶组件都会多一层节点,这会给调试带来很大的负担。

最后,React 团队终于提出了全新的解决方案——Hooks。

同样的逻辑如果用 Hooks 和函数组件该如何实现?

import { useState, useEffect } from 'react';

// 定义一个函数getSize,用于获取当前窗口的大小。
const getSize = () => {
    // 如果窗口宽度大于500,则返回'large',否则返回'small'
    return window.innerWidth > 500 ? 'large' : 'small';
};

// 定义自定义Hook useWindowSize。
const useWindowSize = () => {
    // 使用useState Hook初始化窗口大小的状态变量size和相应的设置函数setSize。
    const [size, setSize] = useState(getSize());

    // 使用useEffect Hook添加一个副作用函数,该函数在组件挂载和更新时执行。
    useEffect(() => {
        // 在副作用函数内部,定义一个处理函数handler,用于设置窗口大小的状态。
        const handler = () => {
            setSize(getSize());
        };
        // 为窗口的resize事件添加处理函数handler。
        window.addEventListener('resize', handler);

        // 返回一个清理函数,在组件卸载前执行,用于移除窗口的resize事件监听器。
        return () => {
            window.removeEventListener('resize', handler);
        };
    }, []); // 传入空数组作为依赖列表,表示这个副作用函数只在组件挂载时执行一次。

    // 返回当前窗口的大小。
    return size;
};

export default useWindowSize; // 导出自定义Hook useWindowSize。

这段代码是一个自定义的 React Hook,名为 useWindowSize,用于监听和返回窗口的大小,当窗口大小发生变化时,使用这个 Hook 的组件就都会重新渲染。不理解上面的代码也没关系,因为上面用了两个 Hooks,后面会详细介绍相关 Hooks,而且代码中也有注释,结合注释也能理解。

如使用方式也很简单:

import { Component } from 'react';
import Render, { LargeComponent, SmallComponent } from './Render';
import useWindowSize from './hooks/useWindowSize';


function App() {
    const size = useWindowSize();
    return (
        <div className="App">
            // 其他逻辑....
            <h3>
                window size: {size}
                {size === 'small' ? <SmallComponent /> : <LargeComponent />}
            </h3>
        </div>
    );
}

export default App;

上面的详细代码可以在 clin211/react-awesome 中查看。传送门

逻辑分离

Hooks 能够让针对同一个业务逻辑的代码尽可能聚合在一块儿。这是过去在 Class 组件中很难做到的。因为在 Class 组件中,你不得不把同一个业务逻辑的代码分散在类组件的不同生命周期的方法中。

React 社区曾用一张图直观地展现了对比结果:

<img src="https://files.mdnice.com/user/8213/daac4763-6786-4b35-b498-03e77277c79c.png" style="border:1px solid rgb(222, 198, 251);border-radius: 8px" /><span style="display: block; text-align: center;font-size:12px;color:grey;">图片来源于互联网</span>

图的左侧是 Class 组件,右侧是函数组件结合 Hooks。蓝色和黄色代表不同的业务功能。可以看到,在 Class 组件中,代码是从技术角度组织在一起的,例如在 componentDidMount 中都去做一些初始化的事情。而在函数组件中,代码是从业务角度组织在一起的,相关代码能够出现在集中的地方,从而更容易理解和维护。

React Hooks 的基本规则

  • 只在顶层调用 Hooks

    function Counter() {
        // 在函数组件顶层
        const [count, setCount] = useState(0);
        // ...
    }
  • 只在 React 函数组件或者自定 Hooks 中调用 Hooks

    function useWindowWidth() {
        // 在自定义 Hooks 顶层
        const [width, setWidth] = useState(window.innerWidth);
        // ...
    }

    或者

    function Home(){
        // 函数组件中调用
        const [name, setName] = useState("clina");
        // ...
    }

    不支持在其他任何情况下调用以 use 开头的 Hook,例如:

    • 不要在条件语句或循环中调用 Hook。
    • 不要在条件性的 return 语句之后调用 Hook。
    • 不要在事件处理函数中调用 Hook。
    • 不要在类组件中调用 Hook。
    • 不要在传递给 useMemouseReduceruseEffect 的函数内部调用 Hook。
    • 不要在 try/catch/finally 代码块中调用 Hook。

React Hooks 有哪些?

我们已经了解了Hooks的概念,并通过实例对比,明白了Hooks的优势。我们再来看看 React(基于React 18.3.1版本) 中具体有哪些 Hooks 呢?

  • useState
  • useEffect
  • useReducer
  • useMemo
  • useCallback
  • useRef
  • useImperativeHandle
  • useLayoutEffect
  • useDebugValue
  • useDeferredValue
  • useInsertionEffect
  • useTransition
  • useSyncExternalStore
  • useId

实验性的 Hook:`

  • useFormStatus
  • useActionState
  • useOptimistic

虽然 React Hooks 看着不少,但也不必全都精通。在常见的开发场景中,使用最频繁的 Hooks大概有三个:useStateuseEffectuseContext。首先理解这三个基础的 Hooks,然后在此基础上:

  • 学习 useRef 的基础用法。
  • 当你需要优化组件性能,减少不必要的渲染时,useMemouseCallback 会非常有用。
  • 对于复杂的状态管理,useReducer 能提供强大的帮助。
  • 如果你需要封装组件并提供命令式的接口,那么 useRefuseImperativeHandle 就会派上用场。
  • 最后,当你需要在页面上进行优先级较高的更新优化时,useDeferredValueuseTransition 会是你的得力助手。

useState

在 React 中,理想的 UI 模型可以被表示为 UI=f(state)。在这个模型中:

  • UI 代表视图
  • state代表应用的状态
  • f 代表的是渲染过程,点击、拖拽等交互事件会改变状态,而状态改变会触发重新渲染。
    <img src="https://files.mdnice.com/user/8213/989e184c-ed0a-4fda-ad45-7bc82d77f4be.png" style="width:20em;" />

上面的例子中,我们也用到了 useState,在组件顶层调用 useState来声明一个状态变量,它返回一个包含两个成员的数组;也就是说 useState 是用来操作组件 state 的 Hook。

import { useState } from 'react';

export default function Counter() {
    const [count, setCount] = useState(0);

    function handleClick() {
        setCount(count + 1);
    }

    return (
        <button onClick={handleClick}>
            You pressed me {count} times
        </button>
    );
}

这段代码中,useState 中的 0 是初始值,它可以是任何类型的值;如果这个初始值是传入的一个函数时,这个函数应该是一个纯函数,且函数没有任何参数,最后返回一个任何类型的值

useState 返回一个由两个值组成的数组:

  1. 当前的 state,在首次渲染的时候,直接使用初始化时的值。
  2. set 函数,每次调用这个函数,都会触发并重新渲染。可以直接传递新状态,也可以传递一个根据先前状态来计算新状态的函数:

    import React, { useState } from 'react';
    
    const Profile = () => {
        const [age, setAge] = useState(18);
    
        const handleClick = () => {
            setAge(age + 1); // setAge((prev) => prev + 1);
        };
    
        return (
            <div>
                <button onClick={handleClick}>set age</button>
                <p>current age: {age}</p>
            </div>
        );
    };
    
    export default Profile;

    常见问题

  3. 闭包陷阱

    用一个例子来解释:在这个组件中,我们有一个名为 count 的状态,一个用于增加 count 的按钮,以及一个用于显示弹窗的按钮。弹窗会在 3 秒后显示当时点击的次数。

    import React, { useState } from 'react';
    
    function ClosureTrap() {
     const [count, setCount] = useState(0);
    
     const handleAlert = () => {
         setTimeout(() => {
             alert('You clicked on: ' + count);
         }, 3000);
     };
    
     return (
         <div>
             <p>You clicked {count} times</p>
             <button onClick={() => setCount(count + 1)}>Click me</button>
             <button onClick={handleAlert}>Show alert</button>
         </div>
     );
    }
    
    export default ClosureTrap;

    如果你点击增加按钮几次,然后点击显示弹窗按钮,你可能会期望弹窗显示的是你点击增加按钮的次数。但实际上,无论你点击增加按钮多少次,弹窗总是显示你在点击显示弹窗按钮时的点击次数。

    这是为什么呢?没错!就是闭包陷阱导致的,在 handleAlert 函数中,setTimeout 的回调函数是一个闭包,它捕获了 count的值。但是,当你点击增加按钮时,handleAlert 函数并没有重新运行,所以它捕获的 count 值并没有更新。这就是所谓的闭包陷阱。

    要解决这个问题,我们可以使用函数式更新,以便总是使用最新的状态值,如下所示:

    import React, { useState, useRef, useEffect } from 'react';
    
    function ClosureTrap() {
     const [count, setCount] = useState(0);
     // 用于存储count的引用,它的值会随count变化而变化且不会更新UI
     const countRef = useRef(count);
    
     const handleAlert = () => {
         setTimeout(() => {
             alert('You clicked on: ' + count);
         }, 3000);
     };
    
     // 当count变化时,更新countRef
     useEffect(() => {
         countRef.current = count;
     }, [count]);
    
     return (
         <div>
             <p>You clicked {count} times</p>
             <button onClick={() => setCount(count + 1)}>Click me</button>
             <button onClick={handleAlert}>Show alert</button>
         </div>
     );
    }
    
    export default ClosureTrap;

    这段代码中用到了两个 Hooks 没有介绍,后面会详细介绍;改完之后,运行再查看就是期望的效果了。关于闭包陷阱的详细解释,可以看看这篇文章《从根上理解 React Hooks 的闭包陷阱》

  4. 批处理合并更新

    import React, { useState } from 'react';
    
    const User = () => {
     const [age, setAge] = useState(18);
    
     const handleClick = () => {
         setAge(age + 1);
         setAge(age + 1);
         setAge(age + 1);
     };
    
     return (
         <div>
             <button onClick={handleClick}>set age</button>
             <p>current age: {age}</p>
         </div>
     );
    };
    
    export default User;

    点击一次 set age 按钮后,页面渲染的 age 应该是多少?21?当你执行后你会发现是 19,为什么呢?因为 React 会将这些更新批量处理,以优化性能。在这个过程中,它只会使用最后一次更新的值。setAge(age + 1) 被调用了三次,但是每次调用时的 age 值都是同一个,也就是 18。所以,无论调用多少次,age 的值都只会增加 1。这就是为什么点击一次click后,age的值没有增加三次的原因。如何解决这个问题呢?函数时更新:

    const handleClick = () => {
     setAge(prevAge => prevAge + 1);
     setAge(prevAge => prevAge + 1);
     setAge(prevAge => prevAge + 1);
    };
  5. 修改引用类型的数据时数据更新了,UI没有更新

    import React, { useState } from 'react';
    
    const UpdateObject = () => {
     const [person, setPerson] = useState({ name: 'clin', age: 18 });
    
     const handleClickUpdateAge = () => {
         person.age = 20;
         setPerson(person);
         console.log('person', person);
     };
    
     return (
         <div>
             current name: {person.name}, age: {person.age}
             <button onClick={handleClickUpdateAge}>update age</button>
         </div>
     );
    };
    
    export default UpdateObject;

    运行后,效果如下:
    <img src="https://files.mdnice.com/user/8213/20caf986-3fd7-40da-8037-8169a0cf78bf.png" style="border:1px solid rgb(222, 198, 251);border-radius: 8px" />

    从代码上来看,每次执行完更新数据后都会打印修改后的数据,在控制台中确实也能看到数据是被修改了的,为什么没有重新渲染组件呢?这是因为对象在 JavaScript 中是引用类型,它们的值在内存中的地址是不变的,并不能简单地通过比较新值与旧值来判断是否发生更新。因此,即使对象引用的值已经发生了改变,但它们的地址仍然相同,React 就会认为值没有改变,不会触发重新渲染。

    要解决这个问题,可以使用以下方法之一:使用不可变的数据类型,如字符串、数字和布尔值,而不是引用类型的对象,这样就不会出现值被修改但地址相同的问题。

    接着来解决一下上面的问题,有两种方式:深拷贝和扩展运算符的方式。我们这里就以扩展运算符的形式去解决组件渲染的问题:

    import React, { useState } from 'react';
    
    const UpdateObject = () => {
     const [person, setPerson] = useState({ name: 'clin', age: 18 });
    
     const handleClickUpdateAge = () => {
         const newPerson = { ...person }; // JSON.parse(JSON.stringify(person));
         newPerson.age = 20;
         setPerson(newPerson);
     };
    
     return (
         <div>
             current name: {person.name}, age: {person.age}
             <button onClick={handleClickUpdateAge}>update age</button>
         </div>
     );
    };
    
    export default UpdateObject;

    上面的代码在 clin211/react-awesome 中查看;传送门

useEffect

useEffect ,顾名思义,用于执行一段副作用。

什么是副作用呢?通常来说,副作用是指一段和当前执行结果无关的代码。比如说要修改函数外部的某个变量,要发起一个请求,等等。也就是说,在函数组件的当次执行过程中,useEffect 中代码的执行是不影响渲染出来的 UI 的。

useEffect(callback, dependencies);

参数:

  • callback 执行函数。
  • dependencies 可选的依赖项数组,依赖项是可选的。

    • 有依赖项时,那么只有依赖项中的值发生改变的时候,它才会执行。
    • 没有依赖项时,那么 callback 就会在每次函数组件执行完后都执行。
import React, { useState, useEffect } from 'react';

function Article({ id }) {
    // 设置一个本地 state 用于保存 blog 内容
    const [blogContent, setBlogContent] = useState({});

    useEffect(() => {
        // useEffect 的 callback 要避免直接的 async 函数,需要封装一下
        const fetchData = async () => {
            // 当 id 发生变化时,将当前内容清楚以保持一致性
            setBlogContent(null);
            // 发起请求获取数据
            const res = await fetch(`https://jsonplaceholder.typicode.com/posts/${id}`);
            // 将获取的数据放入 state
            setBlogContent(await res.json());
        };
        fetchData();
    }, [id]); // 使用 id 作为依赖项,变化时则执行副作用

    // 如果没有 blogContent 则认为是在 loading 状态
    const isLoading = !blogContent;
    return <div>{isLoading ? 'Loading...' : blogContent?.body}</div>;
}


export default Article;

两个特殊用法

  • 没有依赖项,则每次render后都会执行。例如:

    import React, { useState, useEffect } from 'react';
    
    function Effect() {
        const [count, setCount] = useState(0);
    
        useEffect(() => {
            // 每次 render 完一定执行
            console.log('re-rendered');
        });
    
        return (
            <>
                <p>Hello Effect</p>
                <p>{count}</p>
                <button onClick={() => setCount(count + 1)}>count每次改变都会执行没有依赖项的useEffect</button>
            </>
        );
    }
    
    export default Effect;
  • 空数组作为依赖项,则只在首次执行是触发,对应到 class 组件就是 componentDidMount。例如:

    import React, { useState, useEffect } from 'react';
    
    function Effect() {
        const [count, setCount] = useState(0);
    
        useEffect(() => {
            // 组件首次渲染时执行,等价于 class 组件中的 componentDidMount
            console.log('did mount');
        }, []);
    
        return (
            <>
                <p>Hello Effect</p>
                <p>{count}</p>
                <button onClick={() => setCount(count + 1)}>render</button>
            </>
        );
    }
    
    export default Effect;

useEffect 还允许你返回一个函数,用于在组件销毁的时候做一些清理的操作。比如移除事件的监听。这个机制就几乎等价于类组件中的 componentWillUnmount。举个例子,在组件中,我们需要监听窗口的大小变化,以便做一些布局上的调整:

import { useState, useEffect } from 'react';

const useWindowSize = () => {
    // 设置一个 size 的 state 用于保存当前窗口尺寸
    const [size, setSize] = useState({});
    
    useEffect(() => {
        // 窗口大小变化事件处理函数
        const handler = () => {
            setSize({
                width: window.innerWidth,
                height: window.innerHeight
            });
        };
        // 监听 resize 事件
        window.addEventListener('resize', handler);

        // 返回一个 callback 在组件销毁时调用
        return () => {
            // 移除 resize 事件
            window.removeEventListener('resize', handler);
        };
    }, []);
    
    return size;
};

export default useWindowSize;

useEffect 在以下四种时机去执行一个回调函数产生副作用:

  • 每次 render 后执行:不提供第二个依赖项参数。比如useEffect(() => {})
  • 仅第一次 render 后执行:提供一个空数组作为依赖项。比如useEffect(() => {}, [])
  • 第一次以及依赖项发生变化后执行:提供依赖项数组。比如useEffect(() => {}, [deps])
  • 组件销毁后执行:返回一个回调函数。比如useEffect() => { return () => {} }, [])

Hooks中的依赖

Hooks 提供了让你监听某个数据变化的能力。这个变化可能会触发组件的刷新,也可能是去创建一个副作用,又或者是刷新一个缓存。那么定义要监听哪些数据变化的机制,其实就是指定 Hooks 的依赖项。

在定义依赖项时,我们需要注意以下三点:

  • 依赖项中定义的变量一定是会在回调函数中用到的,否则声明依赖项其实是没有意义的。
  • 依赖项一般是一个常量数组,而不是一个变量;因为一般在创建 callback 的时候,你其实非常清楚其中要用到哪些依赖项了。
  • React 会使用浅比较来对比依赖项是否发生了变化,所以要特别注意数组或者对象类型如果你是每次创建一个新对象,即使和之前的值是等价的,也会被认为是依赖项发生了变化。这是一个刚开始使用 Hooks 时很容易导致 Bug 的地方。

使用 Eslint 插件帮助检查 Hooks 的使用

React 官方为我们提供了一个 ESLint 的插件,专门用来检查 Hooks 是否正确被使用,它就是 eslint-plugin-react-hooks;通过这个插件,如果发现缺少依赖项定义这样违反规则的情况,就会报一个错误提示(类似于语法错误的提示),方便进行修改,从而避免 Hooks 的错误使用。

在 eslint 配置中加入 react-hooks/rules-of-hooksreact-hooks/exhaustive-deps 两条规则:
module.exports = {
    env: {
        browser: true,
        es2023: true,
        node: true
    },
    extends: ['plugin:react/recommended', 'airbnb'],
    parserOptions: {
        ecmaFeatures: {
            jsx: true
        },
        ecmaVersion: 12,
        sourceType: 'module'
    },
    plugins: ['react', 'react-hooks'],
    rules: {
        // 4个空格缩进
        indent: [2, 4, {"SwitchCase": 1}],
        // 检查 Hooks 的使用规则
        'react-hooks/rules-of-hooks': 'error',
        // 检查依赖项的声明
        'react-hooks/exhaustive-deps': 'warn',
        // 允许JSX的.js扩展名
        'react/jsx-filename-extension': [1, { extensions: ['.js', '.jsx'] }]
    }
};

useLayoutEffect

useLayoutEffect 是 React Hooks 中的一个钩子,它与 useEffect 相似,都用于在组件渲染后执行副作用函数。但是,useLayoutEffectuseEffect 之间的主要区别在于它们执行的时机。

useEffect 会在浏览器完成布局和绘制后,在一个延迟事件中被调用。因此,它不会阻塞浏览器更新屏幕,这使得它适合大多数副作用场景,如数据获取、订阅或手动更改DOM。

相比之下,useLayoutEffect 会在所有的 DOM 变更之后同步调用,但是会在浏览器进行任何新的绘制之前运行。这使得它在读取布局并同步重新渲染的场景中非常有用。在浏览器执行绘制前修改 DOM 可以同步地更新屏幕,而不会有任何视觉上的抖动。

这里有一个简单的例子说明useLayoutEffect的使用:

import React, { useLayoutEffect, useRef } from 'react';

function Example() {
    const divRef = useRef();

    useLayoutEffect(() => {
        console.log(divRef.current.getBoundingClientRect());
    }, []);

    return <div ref={divRef}>Hello World</div>;
}

在这个例子中,我们使用 useLayoutEffect 获取并打印 div 元素的边界信息。因为 useLayoutEffect 在所有DOM变更后立即执行,所以当它运行时,div 元素已经被渲染到屏幕上,所以 getBoundingClientRect 返回的信息是准确的。

常见问题

  • 性能问题:由于 useLayoutEffect 是同步执行的,如果执行时间过长,可能会阻塞浏览器的渲染,导致性能问题。因此,只有在确实需要同步操作时才使用它。
  • 服务器端渲染(SSR)问题useLayoutEffect 在服务器端渲染时不会运行,因为它依赖于浏览器的 DOM API。如果代码在 SSR 中出现问题,可能需要回退到 useEffect 或进行其他处理。
  • 过度使用:避免在不需要同步 DOM 操作的情况下使用 useLayoutEffect,因为这可能会导致不必要的性能负担。

useContext

useContext 是 React Hooks 中的一个钩子,它能让你在组件中无需通过 props 就可以访问上下文(Context)的数据。这对于在组件树中深层次传递数据非常有用,避免了繁琐的 props 逐层传递。
<img src="https://files.mdnice.com/user/8213/0f862ecb-04a9-49ec-8862-d00d98597687.png" style="max-width: 30em" />

下面来写一个获取主题的例子(clin211/react-awesome 中查看代码):

import React, { useContext } from 'react';

// 创建一个全局上下文对象
const ThemeContext = React.createContext('light');

function ComponentA() {
    // 使用useContext读取当前的主题
    const theme = useContext(ThemeContext);
    return (
        <div>
            ComponentA, the current theme is {theme}
            <ComponentD />
            <ComponentE />
        </div>
    );
}

function ComponentB() {
    // 使用useContext读取当前的主题
    const theme = useContext(ThemeContext);
    return <div>ComponentB, the current theme is {theme}</div>;
}

function ComponentC() {
    return <div>ComponentC</div>;
}

function ComponentD() {
    return <div>ComponentD</div>;
}

function ComponentE() {
    // 使用useContext读取当前的主题
    const theme = useContext(ThemeContext);
    return <div>ComponentE, the current theme is {theme}</div>;
}

function Context() {
    return (
        <ThemeContext.Provider value="dark">
            <ComponentA />
            <ComponentB />
            <ComponentC />
        </ThemeContext.Provider>
    );
}

export default Context;

运行后可以在浏览器中查看到如下效果:

<img src="https://files.mdnice.com/user/8213/9ea1f0bd-0117-472c-9493-45281287f5d6.png" style="border:1px solid rgb(222, 198, 251);border-radius: 8px" />

这段代码主要演示了 React 中如何使用 useContext Hook和 Context API 来在组件树中共享一些全局数据。在这个例子中,共享的全局数据是一个主题(theme)。

  • 首先,使用 React.createContext 创建了一个全局上下文对象 ThemeContext,并初始化为'light'
  • 然后定义了一些组件,如 ComponentA、ComponentB 等。在 ComponentA、ComponentB 和 ComponentE 中,我们使用 useContext Hook 来读取当前的主题。
  • ComponentA、ComponentD 和 ComponentE 作为 ComponentA 的子组件,在 ComponentA 中被渲染。
  • 在 Context 组件中,我们使用 ThemeContext.Provider 来提供一个主题值,这个值会被所有的子组件(包括 ComponentA、ComponentB 和 ComponentC )以及它们的子组件所共享。在这个例子中,我们设置主题值为 'dark'
  • 当我们在 ComponentA、ComponentB 和 ComponentE 中使用 useContext 读取主题时,都会得到 'dark'

我们可以使用 createContext 来创建一个 context 对象,然后通过 Provider 来更新这个 context 对象中的值。在函数式组件中,我们可以利用 useContext 这个 Hook 来获取 context 的值

常见问题

  • 过度使用:如果过度使用 context,可能会导致组件之间的耦合增加,使得状态管理变得复杂。
  • 性能问题:如果 context 值频繁更新,并且很多组件都使用了这个 context,可能会导致不必要的渲染。React 16.3.0 引入了 React.memoReact.useMemo 来解决这个问题。
  • 命名冲突:如果你的应用中有多个 context,确保它们的命名是唯一的,以避免混淆。

总的来说,使用 useContext 可以简化跨组件的状态共享,但需要谨慎使用,以避免引入不必要的复杂性和性能问题。在设计 context 时,考虑其粒度和组件树的结构,以确保其有效性和可维护性。

useReducer

useReducer 是 React Hooks 中的一个钩子,它提供了一种使用 reducer 函数来管理函数组件中复杂的状态逻辑。与 useState 相比,useReducer 的优势在于它允许你将状态逻辑分解为更小的、易于管理的部分。

useReducer 接受两个参数:一个 reducer 函数和一个初始状态。它返回一个状态和一个 dispatch 函数。

reducer 函数接受当前的状态和一个动作作为参数,并返回新的状态。这个函数类似于 Redux 中的 reducer,它根据接收到的动作来更新状态。

假设你有一个计数器组件,其状态包括计数和错误信息:

import React, { useReducer } from 'react';

// 定义 reducer 函数
function reducer(state, action) {
    switch (action.type) {
        case 'increment':
            return { ...state, count: state.count + 1 };
        case 'decrement':
            return { ...state, count: state.count - 1 };
        case 'setError':
            return { ...state, error: action.payload };
        default:
            throw new Error();
    }
}

// 计数器组件
function Counter() {
    // 使用 useReducer 钩子初始化状态和 dispatch 函数
    const [state, dispatch] = useReducer(reducer, { count: 0, error: null });

    return (
        <div>
            <p>Count: {state.count}</p>
            {state.error && <p>Error: {state.error}</p>}
            <button onClick={() => dispatch({ type: 'increment' })}>+</button>
            <button onClick={() => dispatch({ type: 'decrement' })}>-</button>
            <button
                onClick={() =>
                    dispatch({
                        type: 'setError',
                        payload: 'Something went wrong',
                    })
                }>
                Set Error
            </button>
        </div>
    );
}

运行后,可以在浏览器中查看效果如下:

<img src="https://files.mdnice.com/user/8213/0c9d39f6-d2bd-4142-ae79-14b71dab619e.gif" style="border:1px solid rgb(222, 198, 251);border-radius: 8px" />

上面列子创建了一个简单的计数器,并能够处理错误信息:

  1. useReducer 钩子接受两个参数,一个是 reducer 函数,另一个是初始状态。这里的初始状态是一个对象 { count: 0, error: null }
  2. reducer 函数是一个处理状态更新的函数,它根据传入的 action 的类型来决定如何更新状态。
  3. 在 Counter 组件内部,我们使用 useReducer 创建了一个状态 state 和一个 dispatch 函数。state 是当前的状态,包含 counterror 两个属性。dispatch 函数用于触发状态更新。
  4. 组件内部有三个按钮,点击按钮会调用 dispatch 函数并传入一个 action。每个 action 都是一个包含 type 属性的对象,type 决定了要执行哪种类型的状态更新。
  5. 当点击 increment 按钮时,会触发一个类型为 incrementactionreducer 函数会接收到这个 action 并将 count 状态增加1。
  6. 当点击 decrement 按钮时,会触发一个类型为 decrementactionreducer 函数会接收到这个 action 并将 count状态减少 1。
  7. 当点击 setError 按钮时,会触发一个类型为 setErroractionreducer 函数会接收到这个 action 并将 error 状态设置为 action 中的 payload

数据可变性

如果你直接修改原始的 state 返回,是触发不了重新渲染的。下面我们用个例子来看一下:

import React, { useReducer } from 'react';

function reducer(state, action) {
    switch (action.type) {
        case 'increment':
            state.count += 1;
            return state;
        case 'decrement':
            state.count -= 1;
            return state;
        case 'setError':
            return { ...state, error: action.payload };
        default:
            throw new Error();
    }
}

function Immutable() {
    const [state, dispatch] = useReducer(reducer, { count: 0, error: null });
    return (
        <div>
            <p>Count: {state.count}</p>
            {state.error && <p>Error: {state.error}</p>}
            <button onClick={() => dispatch({ type: 'increment' })}>+</button>
            <button onClick={() => dispatch({ type: 'decrement' })}>-</button>
            <button
                onClick={() =>
                    dispatch({
                        type: 'setError',
                        payload: 'Something went wrong',
                    })
                }>
                Set Error
            </button>
        </div>
    );
}

export default Immutable;

运行后,效果如下:

<img src="https://files.mdnice.com/user/8213/9179dc86-1fcf-4da0-8be0-a7a0ee5a5cc6.gif" style="border:1px solid rgb(222, 198, 251);border-radius: 8px" />

如果直接修改原始的 state 返回,是不能触发重新渲染的,必须返回一个新的对象才行。原因与 useState 中的应用类型数据不能更新是一样的,上面的介绍 useState 有详细的解释,这里就赘述了。

如果要解决这个问题,可以对应用类型的数据进行解构或者创建一个新的对象;如果对象结果很复杂,每次都创建一个新的对象也比较繁琐,而且性能也不好。比如下面这个数据结构:

const state = {
    a: {
        b: {
            c: {
                d: {
                    e: {
                        f: 1,
                    },
                },
            },
        },
    },
};

我要修改 f 的值,要么一层一层的使用扩展运算符,要么创建一个新的对象然后链式的修改。上面这个例子还好,每一层的属性不多,使用链式的方式就能解决,如果每层的属性都很多链式就不能解决这个问题,既然我们有这个问题,别人肯定也有这个问题;社区一找发现还有不少解决方案,其中包括:immutable.jsimmerlimu等等。其中 immer 是这些解决方案中脱颖而出的一个,接下来也尝试用 immer 来解决上面的问题。

在 react 社区,有一个基于 immer 实现的库 use-immer,接下来用 use-immer 解决上面的问题:

  • 安装 use-immer 库

    npm i immer use-immer
  • 优化上面的示例

    import React from 'react';
    import { useImmerReducer } from 'use-immer';
    
    function reducer(state, action) {
        switch (action.type) {
            case 'increment':
                state.count += 1;
                return state;
            case 'decrement':
                state.count -= 1;
                return state;
            case 'setError':
                return { ...state, error: action.payload };
            default:
                throw new Error();
        }
    }
    
    function ImmutableUseImmer() {
        const [state, dispatch] = useImmerReducer(reducer, {
            count: 0,
            error: null,
        });
        return (
            <div>
                <p>Count: {state.count}</p>
                {state.error && <p>Error: {state.error}</p>}
                <button onClick={() => dispatch({ type: 'increment' })}>+</button>
                <button onClick={() => dispatch({ type: 'decrement' })}>-</button>
                <button
                    onClick={() =>
                        dispatch({
                            type: 'setError',
                            payload: 'Something went wrong',
                        })
                    }>
                    Set Error
                </button>
            </div>
        );
    }
    
    export default ImmutableUseImmer;

    其实也就是将 内置 useReducer 换成 use-immer 提供的 useImmerReducer,如下图:

    优化后效果如下:
    <img src="https://files.mdnice.com/user/8213/c1f12b74-3cc8-4a94-b7cd-38cec203c369.gif" style="border:1px solid rgb(222, 198, 251);border-radius: 8px" />

上面的所有示例代码都可以在 clin211/react-awesome 中查看。传送门

使用 useReducer 的常见问题

  • 直接修改状态在 reducer 函数中,应该总是返回一个新的状态对象,而不是直接修改传入的状态。这是因为 React 使用浅比较来确定是否需要重新渲染组件。如果状态对象的内存地址没有改变,那么 React 就不会触发组件的重新渲染。
  • 忘记包括默认操作reducer 函数中,应该总是包括一个 default 操作。如果没有default操作,那么当传入一个未知的 action 类型时,reducer 函数将不会返回任何东西,这可能会导致状态变为undefined。
  • reducer 函数中执行副作用reducer 函数应该是纯函数,也就是说,它的输出只应该由其输入决定,而且不应该有任何副作用。这意味着你不应该在 reducer 函数中执行诸如网络请求或访问全局变量之类的操作。
  • 动作对象的结构在调用 dispatch 函数时,传入的动作对象通常应该包含一个 type 属性,以及可选的 payload 属性type 属性确定了要执行哪种类型的状态更新,而 payload 属性包含了任何需要进行状态更新的数据。
  • 异步操作useReducer 本身并不能处理异步操作。如果你需要在 reducer 中处理异步操作,例如数据获取,你可能需要使用其他的解决方案,如中间件或自定义的异步处理钩子。

useRef

useRef 是React Hooks中的一个钩子,它可以创建一个持久的、可变的引用

useRef 接受一个参数,这个参数将成为返回的 ref 对象的当前值。useRef 返回的对象具有一个 current属性,这个 current 在组件的整个声明周期内保持不变,我们可以自由地更改这个属性。

主要应用场景

  • 访问 DOM 元素:最常用的用途就是访问 DOM`元素。通过将 ref 对象传递给元素的 ref 属性,可以直接访问和操作 DOM 元素。
  • 保存可变值useRef 也常常用于保存任何可变值,它的值在所有渲染中都会保持不变。
  • 保存上一次的 props 或 state:有时我们可能需要比较 propsstate 的当前值和上一次的值。这时可以使用 useRef 来保存这些值,以便在需要时进行比较。
  • 触发强制更新:虽然不是推荐的做法,但是在某些情况下,你可能会需要强制更新组件。useRef 可以配合使用 useState 来实现强制更新。
  • 存储定时器或其他副作用的ID:当你使用 setTimeoutsetInterval 时,你可能会需要在组件卸载时清除它们。你可以使用 useRef 来存储这些ID,然后在 useEffect 的清除函数中清除它们。
import React, { useRef } from 'react';

function TextInputWithFocusButton() {
    const inputEl = useRef(null);

    const onButtonClick = () => {
        // `current`指向了真实的input元素
        inputEl.current.focus();
    };

    return (
        <>
            <input ref={inputEl} type="text" />
            <button onClick={onButtonClick}>Focus the input</button>
        </>
    );
}

export default TextInputWithFocusButton;

在这个例子中,我们使用 useRef 创建了一个 ref,并将它赋值到了一个 input 元素上。然后我们在一个按钮的点击事件处理函数中,通过 ref 的 current 属性访问到了这个 input 元素,并调用了它的 focus 方法。

Tips:

useRefcreateRef 的主要区别在于:useRef 返回的 ref 对象在组件的整个生命周期内保持不变,而 createRef 每次渲染都会返回一个新的 ref 对象。

常见坑点

  • 组件的每次渲染,返回的值都不变;示例如下:

    import React, { useState, useRef } from 'react';
    
    function Unalterable() {
        const [count, setCount] = useState(0);
    
        return (
            <div>
                <p>点击次数: {count} </p>
                <p>
                    时间戳: <Time />
                </p>
                <button onClick={() => setCount(count + 1)}>Click me</button>
            </div>
        );
    }
    
    function Time() {
        const ref = useRef(new Date().getTime());
        console.log('🚀 ~ Time ~ ref:', ref.current);
        return <div>{ref.current}</div>;
    }
    
    export default Unalterable;

    效果如下:

    <img src="https://files.mdnice.com/user/8213/f4fd0bd0-11a5-4249-b68b-ab356f21614c.gif" style="border:1px solid rgb(222, 198, 251);border-radius: 8px" />

    你会发现,即使 count 的值在每次渲染时都会改变,但是 countRef.current 的值在组件的每次渲染中都是不变的,它始终引用的是上一次渲染时 ref 的值,并没有重新获取最新的时间戳。这就是 useRef 返回的值在每次渲染中都不变的原因。

  • ref.current 发生变化并不会造成 UI 重新渲染;示例如下:

    import React, { useRef } from 'react';
    
    function NotRerender() {
        const count = useRef(0);
    
        const setCount = () => {
            count.current += 1;
            console.log('🚀 ~ NotRerender ~ count:', count.current);
        };
    
        return (
            <div>
                NotRerender {count.current}
                <button onClick={setCount}>set count</button>{' '}
            </div>
        );
    }
    
    export default NotRerender;

    在浏览器中查看效果如下:

    <img src="https://files.mdnice.com/user/8213/8b46adff-4e2c-4c4d-bacb-ebef92bd8817.gif" style="border:1px solid rgb(222, 198, 251);border-radius: 8px" />

  • 不可以在 render 里更新 ref.current

    import React, { useState, useRef } from 'react';
    
    function PreventRefChangeInRender() {
        const [count, setCount] = useState(0);
        const ref = useRef(0);
        ref.current++;
    
        const handleOnSetCount = () => {
            setCount(count + 1);
            console.log('🚀 ~ PreventRefChangeInRender ~ count:', ref.current);
        };
        return (
            <div>
                PreventRefChangeInRender
                <p>current count:{count}</p>
                <button onClick={handleOnSetCount}>+</button>
            </div>
        );
    }
    
    export default PreventRefChangeInRender;

    效果如下图:

    <img src="https://files.mdnice.com/user/8213/67531bb6-81ac-462d-b2ab-13baacefb6e3.gif" style="border:1px solid rgb(222, 198, 251);border-radius: 8px" />

    从上面的效果图中也可以看到,在页面中点击“+”按钮,触发 handleOnSetCount 函数更新 count 的值,然后在控制台中打印当前 count 的引用;从图中右侧的控制台中可以看出,当你点击按钮时,count 状态的值将增加1,同时在控制台中打印出点击时 ref.current 的值。需要注意的是,由于 ref.current++ 的存在,每次点击按钮时打印出的 ref.current 的值都会比上一次大 1。另外,由于 setCount 的更新可能是异步的,因此控制台中打印的 ref.current 的值可能会比实际的 count 值大 1。

  • 如果给一个组件设定了 ref 属性,但是对应的值不是有 useRef 创建的,React 会报错无法正常渲染

    这句其实也不难理解,看看下面的例子:

    import React, { useState } from 'react';
    
    function Panic() {
        const [count, setCount] = useState('');
    
        return (
            <div>
                <h1 ref={count}>Panic</h1>
            </div>
        );
    }
    
    export default Panic;

    运行效果如下:

关于 useRef 的所有示例代码都可以在 clin211/react-awesome 上查看。传送门

useMemo

useMemo 是一个 React Hook,它在每次重新渲染的时候能够缓存计算的结果。

useMemo(calculateValue, dependencies)

calculateValue要缓存计算值的函数。它应该是一个没有任何参数的纯函数,并且可以返回任意类型

  • React 将会在首次渲染时调用该函数;
  • 在之后的渲染中,如果 dependencies 没有发生变化,React 将直接返回相同值。
  • 否则,将会再次调用 calculateValue 并返回最新结果,然后缓存该结果以便下次重复使用。

dependencies:所有在 calculateValue 函数中使用的响应式变量组成的数组。响应式变量包括 props、state 和所有你直接在组件中定义的变量和函数。如果你在代码检查工具中 配置了 React,它将会确保每一个响应式数据都被正确地定义为依赖项。依赖项数组的长度必须是固定的并且必须写成 [dep1, dep2, dep3] 这种形式。React 使用 Object.is 将每个依赖项与其之前的值进行比较。

dependencies 参数跟前面的 useEffect 是一样的:

  • 不传输组,每次都会重新计算
  • 空数组,只会计算一次
  • 依赖对应的值,对应的值发生变化重新执行 calculateValue 函数

接下面我们用一个例子来理解这个 Hooks 的使用,示例如下:

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

function Todo() {
    const [count, setCount] = useState(0);
    const [todo, setTodo] = useState([]);

    // 过滤userId不为1的数据
    const filterTodo = useMemo(
        () => todo.filter(item => item.userId === 1),
        [todo]
    );

    const fetchTodo = async () => {
        const res = await fetch('https://jsonplaceholder.typicode.com/todos');
        const data = await res.json();
        setTodo(data);
    };

    useEffect(() => {
        fetchTodo();
    }, []);

    return (
        <div>
            <h1>Todo</h1>
            <p>
                count: {count}{' '}
                <button onClick={() => setCount(count + 1)}>+</button>
            </p>
            {filterTodo.map((item, index) => (
                <div key={item.id} className="todo-item">
                    {index + 1}、{item.title}
                </div>
            ))}
        </div>
    );
}

export default Todo;

上面这段代码中,无论 count 怎么变化, filterTodo 只要 todo 没有变化,那么它就永远都会重用上一次的结算结果;只有 todo 中的数据变化后,filterData 才会重新计算。

既然 useMemo 无法带来视觉上的差异,我们为什么还要使用useMemo?

  • 重新计算的开销:大量数据处理、循环或其他复杂逻辑的场景时,重复不必要的计算可能会导致浏览器的卡顿,从而影响用户体验。
  • 渲染的开销:当我们谈论 React 性能时,经常考虑的不仅仅是计算的速度,还有避免不必要的渲染。如果某个子组件依赖于一个对象或数组,并且这个对象或数组在每次父组件渲染时都重新创建,即使实际的数据没有改变,那么子组件也会不必要地重新渲染。

如何避免 useMemo 的滥用

  • 当一个组件视觉上包裹其他组件时,应将 JSX 作为子组件传递。这样可以让 React 知道当包裹器组件更新状态时,其子组件无需重新渲染。
  • 应优先使用本地状态,除非必要,否则不要进行状态提升。例如,表单状态或组件是否被鼠标悬停等瞬时状态,无需保存在组件树的顶部。
  • 渲染逻辑应保持纯粹。如果重新渲染组件会引发问题或明显的视觉错误,那么这就是需要修复的错误,而不是应该使用记忆化技术来避免的问题。
  • 避免在 Effect 中不必要地更新状态。大部分React应用的性能问题都是由 Effect 引起的,因为它创建的更新链会导致组件反复重新渲染。
  • 应尽量从 Effect 中移除不必要的依赖项。例如,将某些对象或函数移动到 Effect 内部或组件外部,通常比使用记忆化更简单。

useMemo 中演示的所有示例都可以在 clin211/react-awesome 中查看。传送门

useCallback

useCallback 是对 useMemo的特化,它可以返回一个缓存版本的函数,只有当它的依赖项改变时,函数才会被重新创建。也就是如果依赖没有改变,函数引用保持不变,从而避免了因函数引用改变导致的不必要的重新渲染。

const cachedFn = useCallback(fn, dependencies)
  • fn: 想要缓存的函数;此函数可以接受任何参数并且可以返回任何值。在初次渲染时,React 将把函数返回给你(而不是调用它)
  • dependencies:依赖项;有关是否更新 fn 的所有响应式值的一个数组;跟 useEffectuseMemo 是一样的。

返回你传入的 fn 函数;在之后的渲染中, 如果依赖没有改变,useCallback 返回上一次渲染中缓存的 fn 函数;否则返回这一次渲染传入的 fn

下面我们用一个事例来演示;假设我们有一个 TodoList 组件,其中有一个 TodoItem 子组件:

import { useState } from 'react';

function TodoItem({ todo, onDelete }) {
    console.log('TodoItem render:', todo.id);
    return (
        <div>
            {todo.text}
            <button onClick={() => onDelete(todo.id)}>Delete</button>
        </div>
    );
}

function TodoList() {
    const [todos, setTodos] = useState([
        { id: 1, text: 'Learn React' },
        { id: 2, text: 'Learn useCallback' },
    ]);

    const handleDelete = id => {
        setTodos(todos => todos.filter(todo => todo.id !== id));
    };

    return (
        <div>
            {todos.map(todo => (
                <TodoItem key={todo.id} todo={todo} onDelete={handleDelete} />
            ))}
        </div>
    );
}

export default TodoList;

上述代码中,每次 TodoList 重新渲染时,handleDelete 都会被重新创建,导致 TodoItem 也重新渲染。为了优化这一点,我们可以使用 useCallback

const handleDelete = useCallback(id => {
    setTodos(todos => todos.filter(todo => todo.id !== id));
}, []);

优化之后,handleDelete 只会在组件首次渲染时被创建一次。

useMemo 与 useCallback 的差异

  1. 用途与缓存的内容不同:

    • useMemo: 用于缓存复杂函数的计算结果或者构造的值。它返回缓存的结果。
    • useCallback: 用于缓存函数本身,确保函数的引用在依赖没有改变时保持稳定。
  2. 底层关联:

    从本质上说,useCallback(fn, deps) 就是 useMemo(() => fn, deps) 的语法糖:

    function useCallback(fn, dependencies) {
        return useMemo(() => fn, dependencies);
    }

那些场景下使用 useCallback

不是使用 useCallback 就能提升性能,以下场景就应该避免使用:

  • 过度优化:函数组件的重新渲染并不会带来明显的性能问题,过度使用useCallback可能会使代码变得复杂且难以维护。
  • 简单组件:对于没有经过 React.memo 优化的子组件或者那些不会因为 prop 变化而重新渲染的组件,就没必要使用 useCallback
  • 使代码复杂化:如果使用 useCallback 仅仅是为了“可能会”有性能提升,而实际上并没有明确的证据表明确实有性能问题,这可能会降低代码的可读性和可维护性。
  • 不涉及其它 Hooks 的函数:如果一个函数并不被用作其他 Hooks 的依赖,并且也不被传递给任何子组件,那么没有理由使用 useCallback

除此之外,还应该注意针对 useCallback 的依赖项的设计,警惕非必要依赖的混入,造成useCallback的效果大打折扣。例如这个非常典型的案例:

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

function Dependence() {
    const [todos, setTodos] = useState([]);
    const [inputValue, setInputValue] = useState('');

    const handleInputChange = event => {
        setInputValue(event.target.value);
    };

    const handleAddTodo = useCallback(
        text => {
            const newTodo = { id: Date.now(), text };
            setTodos(prevTodos => [...prevTodos, newTodo]);
        },
        [todos] // 这里是问题所在,todos的依赖导致这个useCallback几乎失去了其作用
    );

    return (
        <div>
            <input value={inputValue} onChange={handleInputChange} />
            <button onClick={() => handleAddTodo(inputValue)}>Add Todo</button>
            <ul>
                {todos.map(todo => (
                    <li key={todo.id}>{todo.text}</li>
                ))}
            </ul>
        </div>
    );
}

export default Dependence;

在上面的示例中,每当 todos 改变,handleAddTodo 都会重新创建,尽管我们使用了 useCallback。这实际上并没有给我们带来预期的性能优化。正确的做法是利用 setTodos 的函数式更新,这样我们就可以去掉 todos 依赖:

const handleAddTodo = useCallback(
    text => {
        const newTodo = { id: Date.now(), text };
        setTodos(prevTodos => [...prevTodos, newTodo]);
    },
    [] // 这里是问题所在,todos的依赖导致这个useCallback几乎失去了其作用
);

useCallback 所有的演示代码都可以在 clin211/react-awesome 上查看,传送门

useImperativeHandle

useImperativeHandle 是 React Hooks 中的一个特殊的 Hook,我们可以使用它来在父组件中直接操作子组件的实例方法。让我们在开发过程中实现对组件的细粒度控制和精确的行为封装。

通常情况下,我们不建议在函数组件中直接操作子组件的实例方法,因为这不符合 React 数据自顶向下(从父组件到子组件)流动的原则。但是在某些情况下,我们可能需要在父组件中直接调用子组件的某个方法,这时就可以使用 useImperativeHandle

useImperativeHandle 通常与 forwardRef 一起使用,以便将 ref 传递给函数组件。这是一个使用 useImperativeHandle 的例子:

import React, { useRef, useImperativeHandle, forwardRef } from 'react';

const ChildComponent = forwardRef((props, ref) => {
    useImperativeHandle(ref, () => ({
        sayHello() {
            alert('Hello from ChildComponent');
        },
    }));

    return <div>ChildComponent</div>;
});

function UseImperativeHandle() {
    const childRef = useRef();

    const handleClick = () => {
        childRef.current.sayHello();
    };

    return (
        <div>
            <ChildComponent ref={childRef} />
            <button onClick={handleClick}>Invoke Child Method</button>
        </div>
    );
}

export default UseImperativeHandle;

效果如下:

<img src="https://files.mdnice.com/user/8213/d4738a91-ad6f-4bb8-926a-ef6a6795d1c7.gif" style="border:1px solid rgb(222, 198, 251);border-radius: 8px" />

在这个例子中,ChildComponent 使用 useImperativeHandle 来暴露一个 sayHello 方法,这个方法可以在其父组件中通过 ref 被直接调用。当我们点击 "Invoke Child Method" 按钮时,就会调用 ChildComponent 中的 sayHello 方法,弹出一个包含 "Hello from ChildComponent" 的警告框。

useImperativeHandle 使 React 应用更强大更灵活,但是不应该为了灵活而过度使用。虽然 useImperativeHandle 钩子能够更好的封装你想暴露的特定方法和属性,还可以精确控制组件的行为;但也有一个局限性:过度依赖 useImperativeHandle 可能会导致代码难以理解和维护;如果依赖外部变量或状态,还可能引起不必要的组件重新渲染;使用 useCallbackuseMemo 可以在一定程度上减少这样的重新渲染。

useImperativeHanlde 钩子的所有演示代码都可以在 clin211/react-awesome 中查看。传送门

自定义 Hooks

虽然 React 内置了一些 Hook,但有时,我们可能希望有一个特定目的的 Hook :例如获取数据 useData,获取连接 useConnect 等。虽然在 React 中找不到这些 Hooks,但 React 提供了非常灵活的方式让你为自己的需求来创建自己的自定义 Hooks。

如何创建自定义 Hooks?

自定义 Hooks 在形式上其实非常简单,就是声明一个名字以 use 开头的函数,比如 useCounter。这个函数在形式上和普通的 JavaScript 函数没有任何区别,你可以传递任意参数给这个 Hook,也可以返回任何值。

Hooks 和普通函数在语义上是有区别的,就在于函数中有没有用到其它 Hooks

看过上一篇文章的小伙伴可能会有印象,在上一篇文章用到的 useWindowSize 就是一个自定义 Hook,这里再把它搬出来:

import { useState, useEffect } from 'react';

// 定义一个函数getSize,用于获取当前窗口的大小。
const getSize = () => {
    // 如果窗口宽度大于500,则返回'large',否则返回'small'
    return window.innerWidth > 500 ? 'large' : 'small';
};

// 定义自定义Hook useWindowSize。
const useWindowSize = () => {
    // 使用useState Hook初始化窗口大小的状态变量size和相应的设置函数setSize。
    const [size, setSize] = useState(getSize());

    // 使用useEffect Hook添加一个副作用函数,该函数在组件挂载和更新时执行。
    useEffect(() => {
        // 在副作用函数内部,定义一个处理函数handler,用于设置窗口大小的状态。
        const handler = () => {
            setSize(getSize());
        };
        // 为窗口的resize事件添加处理函数handler。
        window.addEventListener('resize', handler);

        // 返回一个清理函数,在组件卸载前执行,用于移除窗口的resize事件监听器。
        return () => {
            window.removeEventListener('resize', handler);
        };
    }, []); // 传入空数组作为依赖列表,表示这个副作用函数只在组件挂载时执行一次。

    // 返回当前窗口的大小。
    return size;
};

export default useWindowSize; // 导出自定义Hook useWindowSize。

在组件中使用也很简单:

// import ....

function App() {
    return (
        <h3>
            window size: {size}
            {size === 'small' ? <SmallComponent /> : <LargeComponent />}
        </h3>
    );
}

上面的例子就是把浏览器窗口的大小的逻辑提取了出来,成为了一个单独的 Hook,一方面能让这个逻辑重用,另一方面能让放代码更加语义化,并且易与理解和维护

自定义 Hook 的特点

从上面的例子中可以总结出自定义 Hook 的特点如下:

  1. 名字一定是以 use 开头的函数,这样 React 才能够知道这个函数是一个 Hook;
  2. 函数内部一定调用了其它的 Hooks,可以是内置的 Hooks,也可以是其它自定义 Hooks。这样才能够让组件刷新,或者去产生副作用。

自定义 Hook 的常用场景

  • 抽取业务逻辑
  • 封装通用逻辑
  • 监听浏览器状态
  • 拆分复杂组件

开源的 React Hooks 库

  • react-use: 一个必不可少的 React Hooks 集合。其包含了传感器、用户界面、动画效果、副作用、生命周期、状态这六大类的Hooks。
  • ahooks: 一套由阿里巴巴开源的 React Hooks 库,封装了大量好用的 Hooks。
  • react-hook-form:一个功能强大,灵活且可扩展的表单,配备了易于使用的验证功能。它可以在 React Web 和 React Native 中使用。
  • swr:一个用于获取数据的 React Hooks 库。只需一个 Hook,就可以显着简化项目中的数据获取逻辑。
  • beautiful-react-hooks:可以显著为你提升组件开发和 hooks 开发效率的一系列漂亮的 React hooks。
  • react-recipes:包含流行自定义钩子的 React Hooks 实用程序库。
  • rooks:一组基本的 React 自定义Hooks。

总结

我们对 React Hooks 的全面而深入的讲解,包含了 useStateuseEffectuseContextuseMemouseCallbackuseRefuseImperativeHandle 以及如何创建自定义 Hook。还有几个 Hook 并没有在本文中介绍到,有了上面的基础,学习另外的几个 Hook,相信也不是什么难事。

useStateuseEffect 是最基本的 Hooks,用于处理状态和副作用。useContext 则是 React 的上下文 API 的 Hooks 版本,让我们能够更方便地在组件树中共享状态。useMemouseCallback 可以帮助我们优化性能,通过记忆复杂计算的结果和稳定回调函数的引用。useRefuseImperativeHandle 则是少数几个能让我们与 DOM 进行更直接交互的 Hooks。还介绍了如何创建自定义 Hooks 让我们能复用组件逻辑,使代码更加干净和可维护。最后,还推荐了社区比较流行的 Hooks 库,用于提升我们的开发效率。

扩展阅读:


长林啊
1 声望0 粉丝

专注前端、Go、Rust及服务端开发,致力于技术分享与终生学习。