React Hooks 常见问题及解决方案

常见问题

  • 🐤 useState 和 setState 有什么明显的区别?

  • 🐤 useState 和 useReducer 的初始值如果是个执行函数返回值,执行函数是否会多次执行?

  • 🐤 还原 useReducer 的初始值,为什么还原不回去了?

  • 🐤 useEffect 如何模拟 componentDidMount、componentUpdate、componentWillUnmount 生命周期?

  • 🐤 如何在 useEffect 中正确的为 DOM 设置事件监听?

  • 🐤 useEffect、useCallback、useMemo 中取到的 state、props 中为什么会是旧值?

  • 🐤 useEffect 为什么会出现无限执行的问题?

  • 🐤 useEffect 中出现竞态如何解决?

  • 🐤 如何在函数组件中保存一些属性,跟随组件进行创建和销毁?

  • 🐤 当 useCallback 会频繁触发时,应该如何进行优化?

  • 🐤 useCallback 和 useMemo 的使用场景有何区别?

一、函数组件渲染过程

先来看一下函数组件的运作方式:

Counter.js

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

    return <p onClick={() => setCount(count + 1)}>count: {count}</p>;
}

每次点击 p 标签,count 都会 + 1,setCount 会触发函数组件的渲染。函数组件的重新渲染其实是当前函数的重新执行。
在函数组件的每一次渲染中,内部的 state、函数以及传入的 props 都是独立的。

比如:

// 第一次渲染
function Counter() {
    // 第一次渲染,count = 0
    const [count, setCount] = useState(0);

    return <p onClick={() => setCount(count + 1)}>clicked {count} times</p>;
}


// 点击 p 标签触发第二次渲染
function Counter() {
    // 第二次渲染,count = 1
    const [count, setCount] = useState(0);

    return <p onClick={() => setCount(count + 1)}>clicked {count} times</p>;
}

// 点击 p 标签触发第三次渲染
function Counter() {
    // 第三次渲染,count = 2
    const [count, setCount] = useState(0);

    return <p onClick={() => setCount(count + 1)}>clicked {count} times</p>;
}

// ...

在函数组件中声明的方法也是类似。因此,在函数组件渲染的每一帧对应这自己独立的 statefunctionprops

二、useState / useReducer

useState VS setState

  • useState 只能作用在函数组件,setState 只能作用在类组件

  • useState 可以在函数组件中声明多个,而类组件中的状态值都必须声明在 thisstate 对象中

  • 一般的情况下,state 改变时:

    • useState 修改 state 时,同一个 useState 声明的值会被 覆盖处理,多个 useState 声明的值会触发 多次渲染

    • setState 修改 state 时,多次 setState 的对象会被 合并处理

  • useState 修改 state 时,设置相同的值,函数组件不会重新渲染,而继承 Component 的类组件,即便 setState 相同的值,也会触发渲染

useState VS useReducer

初始值
  • useState 设置初始值时,如果初始值是个值,可以直接设置,如果是个函数返回值,建议使用回调函数的方式设置
const initCount = c => {
    console.log('initCount 执行');
    return c * 2;
};

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

    return <p onClick={() => setCount(count + 1)}>clicked {count} times</p>;
}

会发现即便 Counter 组件重新渲染时没有再给 count 重新赋初始值,但是 initCount 函数却会重复执行

修改成回调函数的方式:

const initCount = c => {
    console.log('initCount 执行');
    return c * 2;
};

function Counter() {
    const [count, setCount] = useState(() => initCount(0));

    return <p onClick={() => setCount(count + 1)}>clicked {count} times</p>;
}

这个时候,initCount 函数只会在 Counter 组件初始化的时候执行,之后无论组件如何渲染,initCount 函数都不会再执行

  • useReducer 设置初始值时,初始值只能是个值,不能使用回调函数的方式
    • 如果是个执行函数返回值,那么在组件重新渲染时,这个执行函数依然会执行
修改状态
  • useState 修改状态时,同一个 useState 声明的状态会被覆盖处理
function Counter() {
    const [count, setCount] = useState(0);

    return (
        <p
            onClick={() => {
                setCount(count + 1);
                setCount(count + 2);
            }}
        >
            clicked {count} times
        </p>
    );
}

当前界面中 countstep 是 2

在这里插入图片描述

  • useReducer 修改状态时,多次 dispatch 会按顺序执行,依次对组件进行渲染
function Counter() {
    const [count, dispatch] = useReducer((x, payload) => x + payload, 0);

    return (
        <p
            onClick={() => {
                dispatch(1);
                dispatch(2);
            }}
        >
            clicked {count} times
        </p>
    );
}

当前界面中 countstep 是 3

在这里插入图片描述

还原 useReducer 的初始值,为什么还原不了

比如下面这个例子:

const initPerson = { name: '小明' };

const reducer = function (state, action) {
    switch (action.type) {
        case 'CHANGE':
            state.name = action.payload;
            return { ...state };
        case 'RESET':
            return initPerson;
        default:
            return state;
    }
};

function Counter() {
    const [person, dispatch] = useReducer(reducer, initPerson);
    const [value, setValue] = useState('小红');

    const handleChange = useCallback(e => setValue(e.target.value), []);

    const handleChangeClick = useCallback(() => dispatch({ type: 'CHANGE', payload: value }), [value]);

    const handleResetClick = useCallback(() => dispatch({ type: 'RESET' }), []);

    return (
        <>
            <p>name: {person.name}</p>
            <input type="text" value={value} onChange={handleChange} />
            <br />
            <br />
            <button onClick={handleChangeClick}>修改</button> |{' '}
            <button onClick={handleResetClick}>重置</button>
        </>
    );
}

点击修改按钮,将对象的 name 改为 小红,点击重置按钮,还原为原始对象。但是我们看看效果:

在这里插入图片描述

可以看到 name 修改小红后,无论如何点击重置按钮,都无法还原。

这是因为在 initPerson 的时候,我们改变了 state 的属性,导致初始值 initPerson 发生了变化,所以之后 RESET,即使返回了 initPerson``,但是name 值依然是小红。

所以我们在修改数据时,要注意,不要在原有数据上进行属性操作,重新创建新的对象进行操作即可。比如进行如下的修改:

// ...

const reducer = function (state, action) {
    switch (action.type) {
        case 'CHANGE':
            // !修改后的代码
            const newState = { ...state, name: action.payload }
            return newState;
        case 'RESET':
            return initPerson;
        default:
            return state;
    }
};

// ...

看看修改后的效果,可以正常的进行重置了:

在这里插入图片描述

三、useEffect

useEffect 基本用法:

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

    useEffect(() => {
        console.log('count: ', count);
    });

    return <p onClick={() => setCount(count + 1)}>clicked {count} times</p>;
}

每次点击 p 标签,Counter 组件都会重新渲染,都可以在控制台看到有 log 打印。

使用 useEffect 模拟 componentDidMount

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

    useEffect(() => {
        console.log('count: ', count);
        // 设置依赖为一个空数组
    }, []);

    return <p onClick={() => setCount(count + 1)}>clicked {count} times</p>;
}

useEffect 的依赖设置为空数组,可以看到,只有在组件初次渲染时,控制台会打印输出。之后无论 count 如何更新,都不会再打印。

使用 useEffect 模拟 componentDidUpdate

  • 使用条件判断依赖项是否是初始值,不是的话走更新逻辑
function Counter() {
    const [count, setCount] = useState(0);

    useEffect(() => {
        if (count !== 0) {
          console.log('count: ', count);
        }
    }, [count]);

    return <p onClick={() => setCount(count + 1)}>clicked {count} times</p>;
}

但是这样处理有个弊端,当有多个依赖项时,需要多次比较,因此可以选择使用下面这种方式。

  • 使用 useRef 设置一个初始值,进行比较
function Counter() {
    const [count, setCount] = useState(0);
    const firstRender = useRef(true);

    useEffect(() => {
        if (firstRender.current) {
          firstRender.current = false;
        } else {
          console.log('count: ', count);
        }
    }, [count]);

    return <p onClick={() => setCount(count + 1)}>clicked {count} times</p>;
}

使用 useEffect 模拟 componentWillUnmount

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

    useEffect(() => {
        console.log('count: ', count);

        return () => {
          console.log('component will unmount')
        }
    }, []);

    return <p onClick={() => setCount(count + 1)}>clicked {count} times</p>;
}

useEffect 中包裹函数中返回的函数,会在函数组件重新渲染时,清理上一帧数据时触发执行。因此这个函数可以做一些清理的工作。
如果 useEffect 给定的依赖项是一个空数组,那么返回函数被执行时,代表着组件真正被卸载了。

useEffect 设置 依赖项为空数组,并且 返回一个函数,那么这个返回的函数就相当于是 componentWillUnmount

请注意,必须要设置依赖项为空数组。如果不是空数组,那么这个函数并不是在组件被卸载时触发,而是会在组件重新渲染,清理上一帧的数据时触发。

useEffect 正确的为 DOM 设置事件监听

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

    useEffect(() => {
        const handleClick = function() {
            console.log('count: ', count);
        }
        window.addEventListener('click', handleClick, false)

        return () => {
          window.removeEventListener('click', handleClick, false)
        };
    }, [count]);

    return <p onClick={() => setCount(count + 1)}>clicked {count} times</p>;
}

useEffect 中设置事件监听,在 return 的函数中对副作用进行清理,取消监听事件

useEffect、useCallback、useMemo 中获取到的 state、props 为什么是旧值

正如我们刚才所说,函数组件的每一帧会有自己独立的 state、function、props。而 useEffect、useCallback、useMemo 具有缓存功能。

因此,我们取的是当前对应函数作用域下的变量。如果没有正确的设置依赖项,那么 useEffect、useCallback、useMemo 就不会重新执行,其中使用的变量还是之前的值。

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

    useEffect(() => {
      const handleClick = function() {
        console.log('count: ', count);
      }
        window.addEventListener('click', handleClick, false)

        return () => {
          window.removeEventListener('click', handleClick, false)
        };
    }, []);

    return <p onClick={() => setCount(count + 1)}>clicked {count} times</p>;
}

还是上一个例子,如果此时给 useEffect 设置空数组为依赖项,那么无论 count 改变了多少次,点击 window,打印出来的 count 依然是 0

useEffect 中为什么会出现无限执行的情况

  • 没有为 useEffect 设置依赖项,并且在 useEffect 中更新 state,会导致界面无限重复渲染
function Counter() {
    const [count, setCount] = useState(0);

    useEffect(() => {
      setCount(count + 1);
    });

    return <p onClick={() => setCount(count + 1)}>clicked {count} times</p>;
}

这种情况会导致界面无限重复渲染,因为没有设置依赖项,如果我们想在界面初次渲染时,给 count 设置新值,给依赖项设置空数组即可。

修改后:只会在初始化时设置 count

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

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

    return <p onClick={() => setCount(count + 1)}>clicked {count} times</p>;
}

上面这个例子是依赖项缺失的时候,会出现问题,那么在依赖项正常设置的情况下,也会出现问题。

  • 此时有一个需求:每次 count 增加的时候,我们需要进行翻页(page + 1),看看如何写:

由于此时我们依赖 count,依赖项中要包含 count,而修改 page 时又需要依赖 page,所以依赖项中也要包含 page

function Counter() {
    const [count, setCount] = useState(0);
    const [page, setPage] = useState(0);

    useEffect(() => {
        setPage(page + 1);
    }, [count, page]);

    return (
        <>
            <p onClick={() => setCount(count + 1)}>clicked {count} times</p>
            <p>page: {page}</p>
        </>
    );
}

此时也会导致界面无限重复渲染的情况,那么此时修改 page 时改成函数的方式,并从依赖性中移除 page 即可

修改后:既能实现效果,又避免了重复渲染

function Counter() {
    const [count, setCount] = useState(0);
    const [page, setPage] = useState(0);

    useEffect(() => {
        setPage(p => p + 1);
    }, [count]);

    return (
        <>
            <p onClick={() => setCount(count + 1)}>clicked {count} times</p>
            <p>page: {page}</p>
        </>
    );
}

四、竞态

执行更早但返回更晚的情况会错误的对状态值进行覆盖

useEffect 中,可能会有进行网络请求的场景,我们会根据父组件传入的 id,去发起网络请求,id 变化时,会重新进行请求。

function App() {
    const [id, setId] = useState(0);

    useEffect(() => {
        setId(10);
    }, []);

    // 传递 id 属性
    return <Counter id={id} />;
}


// 模拟网络请求
const fetchData = id =>
    new Promise(resolve => {
        setTimeout(() => {
            const result = `id 为${id} 的请求结果`;
            resolve(result);
        }, Math.random() * 1000 + 1000);
    });


function Counter({ id }) {
    const [data, setData] = useState('请求中。。。');

    useEffect(() => {
        // 发送网络请求,修改界面展示信息
        const getData = async () => {
            const result = await fetchData(id);
            setData(result);
        };
        getData();
    }, [id]);

    return <p>result: {data}</p>;
}

展示结果:

在这里插入图片描述

上面的实例,多次刷新页面,可以看到最终结果有时展示的是 id 为 0 的请求结果,有时是 id 为 10 的结果
正确的结果应该是 ‘id 为 10 的请求结果’。这个就是竞态带来的问题。

解决办法:

  • 取消异步操作
// 存储网络请求的 Map
const fetchMap = new Map();

// 模拟网络请求
const fetchData = id =>
    new Promise(resolve => {
        const timer = setTimeout(() => {
            const result = `id 为${id} 的请求结果`;
            // 请求结束移除对应的 id
            fetchMap.delete(id);
            resolve(result);
        }, Math.random() * 1000 + 1000);

        // 设置 id 到 fetchMap
        fetchMap.set(id, timer);
    });

// 取消 id 对应网络请求
const removeFetch = (id) => {
  clearTimeout(fetchMap.get(id));
}

function Counter({ id }) {
    const [data, setData] = useState('请求中。。。');

    useEffect(() => {
        const getData = async () => {
            const result = await fetchData(id);
            setData(result);
        };
        getData();
        return () => {
            // 取消对应网络请求
            removeFetch(id)
        }
    }, [id]);

    return <p>result: {data}</p>;
}

展示结果:

在这里插入图片描述

此时无论如何刷新页面,都只展示 id 为 10 的请求结果

  • 设置布尔值变量进行追踪
// 模拟网络请求
const fetchData = id =>
    new Promise(resolve => {
        setTimeout(() => {
            const result = `id 为${id} 的请求结果`;
            resolve(result);
        }, Math.random() * 1000 + 1000);
    });

function Counter({ id }) {
    const [data, setData] = useState('请求中。。。');

    useEffect(() => {
        let didCancel = false;
        const getData = async () => {
            const result = await fetchData(id);
            if (didCancel) {
                setData(result);
            }
        };
        getData();
        return () => {
            didCancel = true;
        };
    }, [id]);

    return <p>result: {data}</p>;
}

可以发现,此时无论如何刷新页面,也都只展示 id 为 10 的请求结果

五、如何在函数组件中保存住非 stateprops 的值

函数组件是没有 this 指向的,所以为了可以保存住组件实例的属性,可以使用 useRef 来进行操作

函数组件的 ref 具有可以 穿透闭包 的能力。通过将普通类型的值转换为一个带有 current 属性的对象引用,来保证每次访问到的属性值是最新的。

保证在函数组件的每一帧里访问到的 state 值是相同的

  • 先看看不使用 useRef 的情况下,每一帧里的 state 值是如何打印的
function Counter() {
    const [count, setCount] = useState(0);

    useEffect(() => {
        const handleClick = function() {
            console.log('count: ', count);
        }
        window.addEventListener('click', handleClick, false)
    });

    return <p onClick={() => setCount(count + 1)}>clicked {count} times</p>;
}

先点击 p 标签 5 次,之后点击 window 对象,可以看到打印结果:

在这里插入图片描述

  • 使用 useRef 之后,每一帧里的 ref 值是如何打印的
function Counter() {
    const [count, setCount] = useState(0);
    const countRef = useRef(count);

    useEffect(() => {
        // 将最新 state 设置给 countRef.current
        countRef.current = count;
        const handleClick = function () {
            console.log('count: ', countRef.current);
        };
        window.addEventListener('click', handleClick, false);
    });

    return <p onClick={() => setCount(count + 1)}>clicked {count} times</p>;
}

和之前一样的操作,先点击 p 标签 5 次,之后点击 window 界面,可以看到打印结果

在这里插入图片描述

使用 useRef 即可以保证函数组件的每一帧里访问到的 state 值是相同的。

如何保存住函数组件实例的属性

函数组件是没有实例的,因此属性也无法挂载到 this 上。那如果我们想创建一个非 stateprops 变量,能够跟随函数组件进行创建销毁,该如何操作呢?

同样的,还是可以通过 useRefuseRef 不仅可以作用在 DOM 上,还可以将普通变量转化成带有 current 属性的对象

比如,我们希望设置一个 Model 的实例,在组件创建时,生成 model 实例,组件销毁后,重新创建,会自动生成新的 model 实例

class Model {
    constructor() {
        console.log('创建 Model');
        this.data = [];
    }
}

function Counter() {
    const [count, setCount] = useState(0);
    const countRef = useRef(new Model());

    return <p onClick={() => setCount(count + 1)}>clicked {count} times</p>;
}

按照这种写法,可以实现在函数组件创建时,生成 Model 的实例,挂载到 countRefcurrent 属性上。重新渲染时,不会再给 countRef 重新赋值。

也就意味着在组件卸载之前使用的都是同一个 Model 实例,在卸载之后,当前 model 实例也会随之销毁。

仔细观察控制台的输出,会发现虽然 countRef 没有被重新赋值,但是在组件在重新渲染时,Model 的构造函数却依然会多次执行

所以此时我们可以借用 useState 的特性,改写一下。

class Model {
    constructor() {
        console.log('创建 Model');
        this.data = [];
    }
}

function Counter() {
    const [count, setCount] = useState(0);
    const [model] = useState(() => new Model());
    const countRef = useRef(model);

    return <p onClick={() => setCount(count + 1)}>clicked {count} times</p>;
}

这样使用,可以在不修改 state 的情况下,使用 model 实例中的一些属性,可以使 flag,可以是数据源,甚至可以作为 Mobxstore 进行使用。

六、useCallback

如题,当依赖频繁变更时,如何避免 useCallback 频繁执行呢?

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

    const handleClick = useCallback(() => {
        setCount(count + 1);
    }, [count]);

    return <p onClick={handleClick}>clicked {count} times</p>;
}

这里,我们把 click 事件提取出来,使用 useCallback 包裹,但其实并没有起到很好的效果。

因为 Counter 组件重新渲染目前只依赖 count 的变化,所以这里的 useCallback 用与不用没什么区别。

使用 useReducer 替代 useState

可以使用 useReducer 进行替代。

function Counter() {
    const [count, dispatch] = useReducer(x => x + 1, 0);

    const handleClick = useCallback(() => {
        dispatch();
    }, []);

    return <p onClick={handleClick}>clicked {count} times</p>;
}

useReducer 返回的 dispatch 函数是自带了 memoize 的,不会在多次渲染时改变。因此在 useCallback 中不需要将 dispatch 作为依赖项。

setState 中传递函数

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

    const handleClick = useCallback(() => {
        setCount(c => c + 1);
    }, []);

    return <p onClick={handleClick}>clicked {count} times</p>;
}

setCount 中使用函数作为参数时,接收到的值是最新的 state 值,因此可以通过这个值执行操作。

通过 useRef 进行闭包穿透

function Counter() {
    const [count, setCount] = useState(0);
    const countRef = useRef(count);

    useEffect(() => {
        countRef.current = count;
    }, [count]);

    const handleClick = useCallback(() => {
        setCount(countRef.current + 1);
    }, []);

    return <p onClick={handleClick}>clicked {count} times</p>;
}

这种方式也可以实现同样的效果。但是不推荐使用,不仅要编写更多的代码,而且可能会产生出乎预料的问题。

七、useMemo

上面讲述了 useCallback 的一些问题和解决办法。下面看一看 useMemo

useMemoReact.memo 不同:

  • useMemo 是对组件内部的一些数据进行优化和缓存,惰性处理。
  • React.memo 是对函数组件进行包裹,对组件内部的 stateprops 进行浅比较,判断是否需要进行渲染。

useMemouseCallback 的区别

  • useMemo 的返回值是一个值,可以是属性,可以是函数(包括组件)
  • useCallback 的返回值只能是函数

因此,useMemo 一定程度上可以替代 useCallback,等价条件:useCallback(fn, deps) => useMemo(() => fn, deps)

所以,上述关于 useCallback 一些优化点同样适用于 useMemo

八、参考文档

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐