进一步加深 React 钩子的理解

208 阅读10分钟

useLayoutEffect

前面我们对 useEffect 的了解我们知道,useEffect 始终在渲染之后执行,因此 useEffect 可以访问渲染后的所有值,但是我们不难发现,这其实是有执行顺序的,也就是,效应始终在渲染后执行,在此,我们即将解读一个新的钩子——useLayoutEffect。

useLayoutEffect 与 useEffect 有相似之处,就是都是在组件渲染后执行,同样可以返回一个函数,这个函数在组件移除时调用。但是,它们究竟谁先执行,谁后执行呢?我们一起来看一看这个例子,就一目了然了。

import { useEffect, useLayoutEffect } from "react";

const Example = () => {
  useEffect(() => {
    console.log("useEffect 执行了!");
  });
  useLayoutEffect(() => {
    console.log("useLayoutEffect 执行了!");
  });

  return (
    <p>useEffect 和 useLayoutEffect 谁先执行?</p>
  )
}

export default Example;

结果会是什么呢?

compare.png

虽然第一个钩子是 useEffect,第二个钩子是 useLayoutEffect,但是从控制台打印的结果我们不难看出,useLayoutEffect 钩子在组件渲染后先执行,而 useEffect 后执行。就此我们来看一下,组件渲染后的一个执行顺序。

组件渲染顺序.png

我们从上图可以看出,useLayoutEffect 钩子在组件渲染后就直接回调了,等到浏览器绘制完成才会调用 useEffect 钩子。

import { useEffect, useLayoutEffect, useRef } from "react";

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

  useLayoutEffect(() => {
    var divNode = divRef.current;
    console.log("useLayoutEffectSize", divNode.clientWidth, divNode.clientHeight);
    divNode.attributes.style.value = `width: ${divNode.clientWidth + 100}px; height: ${divNode.clientHeight + 100}px; border: 1px solid red;`
  }, []);

  useEffect(() => {
    var divNode = divRef.current;
    console.log("useEffectSize", divNode.clientWidth, divNode.clientHeight);
  }, [])

  return (
    <>
      <div ref={divRef} style={{width: 0, height: 0, border: '1px solid red'}}></div>
    </>
  )
}

export default DrawRect;

ui设置拦截.png

从上面代码我们可以看到,我们初始化的 <div> 的宽高是0,当渲染完成后,我们在 useLayoutEffect 钩子中可以拿到宽高属性也为0,同时我们在这个阶段把元素样式进行重新设置,结果在 useEffect 中我们拿到的元素宽高为100,最终浏览器绘制出来的 <div> 我们也能看到宽高为100。这个案例印证了我们上面提供的组件渲染后的钩子执行顺序。

通过上面的示例是不是也给我们提供了一些使用 useLayoutEffect 钩子的思路,我们是不是可以在这个钩子中进行浏览器绘制样式的设定呢?答案是肯定的,我们从 Layout 这个单词也能明白其大意,那肯定是跟布局有关,在日常开发中,这个钩子可能用得并不太多,但是,对于样式的设定,修改是可以再这个钩子中进行操作的。

useMemo

: "如下代码及图片中 chengedSum,打错了,应该为changedSum,抱歉!"

在我们上一篇文章中了解到了依赖数组可以是同时依赖多个元素,然而同时依赖多个元素的时候,任何一个依赖发生更新,都会使得依赖触发,出现频繁且不是我们需要的方法调用。这个时候 useMemo 钩子就派上用场了,useMemo 会调用一个函数来计算得到一个备忘值,存储在缓存中。这样我们每次再使用时,就不需要再频繁的调用计算来获取结果,所以但凡涉及到缓存的,几乎都是为了提升性能,既然提了提升性能,那么在性能优化时,这就是一个优化点咯哦。我们具体来看一看 useMemo 的语法。

const result = useMemo(() => calculatedResult, [dependence]);

从语法我们可以看到,useMemo 接收两个参数,一个函数且这个函数返回计算结果,useMemo 返回这个函数的计算结果,和一个依赖数组。

import { useEffect, useMemo, useState } from "react";

const SumInput = () => {
  const [num1, setNum1] = useState(0);
  const [num2, setNum2] = useState(0);

  const sum = useMemo(() => {
    console.log("num1:", num1);
    console.log("num2:", num2);
    return num1 * 1 + num2 * 1;
  }, [num1, num2]);

  const exchange = () => {
    console.log("点击切换-------");
    setNum1(num2);
    setNum2(num1);
  };

  useEffect(() => {
    console.log("chengedSum:", sum);
    console.log("---------------分割线------------------");
  }, [sum]);
  

  return(
    <>
      <div style={{margin: 20}}><input type="number" value={num1} onChange={(e) => setNum1(e.target.value)}/></div>
      <div style={{margin: 20}}><input type="number" value={num2} onChange={(e) => setNum2(e.target.value)}/></div>
      <button style={{margin: "10px 20px"}} onClick={exchange}>互换</button>
    </>
  );
};

export default SumInput;

我们看到这个组件是input框值相加的的组件,当组件初始化的时候,chengedSum 会打印出为 0 的值,然后我们分别输入 4 和 5,chengedSum 打印出 9,我们试想一下,如果点击切换按钮,chengedSum 会打印出啥?

useMemo.png

我们来看看这个打印过程及点击切换后的结果,发现,当我们点击切换按钮后,chengedSum 并没有打印,也就是 useEffect 依赖的 sum 根本没有发生变化,应为上一次计算的结果也为 9,所以切换以后,计算结果还是为 9,所以就不会再打印该值了。从上图中我们还可以发现,num1 和 num2 中任何一个值发生变化,都会使得 useMemo 重新调用。

执行逻辑.png

上图中虚线部分我模拟了如果 useEffect 直接依赖 num1, num2,那么不管是 num1,还是 num2 发生变化都会触发 useEffect 钩子的回调,如果 useEffect 只关注 num1,num2 的和的值是否发生变化,这就会造成不必要的消耗,所以才有了 useMemo,只要有个依赖发生变化,计算然后存起来,等需要的时候直接取就可以了,也不必每次都要去计算了,所以 useMemo 的主要作用就是减少函数式组件的渲染量,提高渲染性能

useCallback

useCallback 的作用于 useMemo 类似,不过其备忘的是函数。我们先来看这样一个示例:

import { useEffect, useState } from "react";

const userList = [
  {name: "炭烤小橙微辣", age: 18},
  {name: "炭烤小橙中辣", age: 20},
  {name: "炭烤小橙特辣", age: 30},
  {name: "炭烤小橙爆辣", age: 35}
];
const GetUserList = () => {
  const [name, setName] = useState();
  const [age, setAge] = useState('');

  const fetchList = () => {
    console.log("GetUserList 调用了!");
    return name ? userList.filter(user => user.name === name) : userList;
  }

  const nameChange = (e) => {
    console.log("-------------------");
    console.log("切换了 name ");
    setName(e.target.value);
  };

  const ageChange = (e) => {
    console.log("-------------------");
    console.log("age变了");
    setAge(Number(e.target.value));
  };

  return (
    <>
      <div style={{display: "flex"}}>
        <div style={{margin: 20}}>
          <label htmlFor="name">姓名:</label>
          <select defaultValue value={name} id="name" onChange={nameChange}>
            <option value="" style={{display: "none"}}></option>
            {
              userList.map((user, index) => (
                <option key={index} value={user.name}>{user.name}</option>
              ))
            }
          </select>
        </div>
        <div style={{margin: 20}}>
          <label htmlFor="age">期待年龄:</label>
          <input id="age" onChange={ageChange} style={{width: 100}}></input>
        </div>
      </div>
      <div style={{margin: 20}}>{`期待的年龄是 ${ age } 岁`}</div>
      <div>
        <List fetchList={fetchList}></List>
      </div>
    </>
  )
}

const List = ({fetchList}) => {
  const [list, setList] = useState([]);
  useEffect(() => {
    console.log("依赖更新了!");
    const fList = fetchList();
    console.log("获得的list:", JSON.stringify(fList));
    setList(fList);
  }, [fetchList]);

  return (
    <ul>
      {list.map((user, index) => (<li key={index}>{`${user.name} ———— ${user.age}岁`}</li>))}
    </ul>
  )
}

export default GetUserList;

这是一个我们在业务代码开发过程中常遇到的,通过筛选条件获取列表的一个组件。父组件为一个筛选条件查询组件和可以输入期待的人物年龄,子组件为列表组件。我们希望可以通过选择姓名来查询列表,输入的年龄展示在列表上方。

useCallback-unuse.png

从结果来看:

  1. 切换 name 选项,name 更新,组件重新渲染,导致 fetchList 依赖更新,获取了列表。
  2. 输入年龄,期待的年龄也展示了。

从上述结果来看,虽然我们实现了想要的功能,但是,输入年龄后,同样去获取了列表,这就增加了多余的开销,似乎得优化一下。我们来分析一下为啥会出现这种情况:组件的重新渲染,是有销毁再渲染的过程,也就是初始化的 fetchList 与 重渲染后的 fetchList 虽然是一样的名字,一样的功能,但实质已经不是同一个了,最主要的原因就是在存储中的指引反生了改变。所以我决定这样来修改一下:

import React, { useCallback, useEffect, useState } from "react";

const userList = [
  {name: "炭烤小橙微辣", age: 18},
  {name: "炭烤小橙中辣", age: 20},
  {name: "炭烤小橙特辣", age: 30},
  {name: "炭烤小橙爆辣", age: 35}
];
const GetUserList = () => {
  const [name, setName] = useState();
  const [age, setAge] = useState('');

  const fetchList = useCallback(() => {
    console.log("GetUserList 调用了!");
    return name ? userList.filter(user => user.name === name) : userList;
  }, [name]);

  const nameChange = (e) => {
    console.log("-------------------");
    console.log("切换了 name ");
    setName(e.target.value);
  };

  const ageChange = (e) => {
    console.log("-------------------");
    console.log("age变了");
    setAge(Number(e.target.value));
  };

  return (
    <>
      <div style={{display: "flex"}}>
        <div style={{margin: 20}}>
          <label htmlFor="name">姓名:</label>
          <select defaultValue value={name} id="name" onChange={nameChange}>
            <option value="" style={{display: "none"}}></option>
            {
              userList.map((user, index) => (
                <option key={index} value={user.name}>{user.name}</option>
              ))
            }
          </select>
        </div>
        <div style={{margin: 20}}>
          <label htmlFor="age">期待年龄:</label>
          <input id="age" onChange={ageChange} style={{width: 100}}></input>
        </div>
      </div>
      <div style={{margin: 20}}>{`期待的年龄是 ${ age } 岁`}</div>
      <div>
        <List fetchList={fetchList}></List>
      </div>
    </>
  )
}

const List = ({fetchList}) => {
  const [list, setList] = useState([]);
  console.log("子组件更新了!");
  useEffect(() => {
    console.log("依赖更新了!");
    const fList = fetchList();
    console.log("获得的list:", JSON.stringify(fList));
    setList(fList);
  }, [fetchList]);

  return (
    <ul>
      {list.map((user, index) => (<li key={index}>{`${user.name} ———— ${user.age}岁`}</li>))}
    </ul>
  )
}

export default GetUserList;

useCallback.png

我们使用了 useCallback 来包装 fetchList,当我们再次去输入年龄时,就不会再去查询列表了。解决完一个问题,又来一个问题,我们输入年龄的时候,子组件重新渲染了,但是我们知道年龄的变化跟子组件半毛钱关系都没有,所以不想让子组件重新刷新,看来这个地方还是不够完善啊。

看来我们还得处理一下,子组件添加 React.memo 方法进行包裹。

import { useCallback, useEffect, useState, memo } from "react";

const userList = [
  {name: "炭烤小橙微辣", age: 18},
  {name: "炭烤小橙中辣", age: 20},
  {name: "炭烤小橙特辣", age: 30},
  {name: "炭烤小橙爆辣", age: 35}
];
const GetUserList = () => {
  const [name, setName] = useState();
  const [age, setAge] = useState('');

  const fetchList = useCallback(() => {
    console.log("GetUserList 调用了!");
    return name ? userList.filter(user => user.name === name) : userList;
  }, [name]);

  const nameChange = (e) => {
    console.log("-------------------");
    console.log("切换了 name ");
    setName(e.target.value);
  };

  const ageChange = (e) => {
    console.log("-------------------");
    console.log("age变了");
    setAge(Number(e.target.value));
  };

  return (
    <>
      <div style={{display: "flex"}}>
        <div style={{margin: 20}}>
          <label htmlFor="name">姓名:</label>
          <select defaultValue value={name} id="name" onChange={nameChange}>
            <option value="" style={{display: "none"}}></option>
            {
              userList.map((user, index) => (
                <option key={index} value={user.name}>{user.name}</option>
              ))
            }
          </select>
        </div>
        <div style={{margin: 20}}>
          <label htmlFor="age">期待年龄:</label>
          <input id="age" onChange={ageChange} style={{width: 100}}></input>
        </div>
      </div>
      <div style={{margin: 20}}>{`期待的年龄是 ${ age } 岁`}</div>
      <div>
        <List fetchList={fetchList}></List>
      </div>
    </>
  )
}

const List = memo(({fetchList}) => {
  const [list, setList] = useState([]);
  console.log("子组件更新了!");
  useEffect(() => {
    console.log("依赖更新了!");
    const fList = fetchList();
    console.log("获得的list:", JSON.stringify(fList));
    setList(fList);
  }, [fetchList]);

  return (
    <ul>
      {list.map((user, index) => (<li key={index}>{`${user.name} ———— ${user.age}岁`}</li>))}
    </ul>
  )
})

export default GetUserList;

我们还是做上边的操作对结果进行对比:

react.memo.png

我们可以看到输入年龄后,子组件不再进行重渲染了。我们顺带来了解一下 React.memo 方法。

memo(Component, arePropsEqual?);
  • Component: 需要进行缓存的组件。

    memo方法会返回一个全新,被记忆化(缓存)后的组件。

  • arePropsEqual: 可选参数,类型为函数,接收两个参数:上一个组件的 props 与 当前组件的 props。

    arePropsEqual = (preProps, curProps) => preProps === curProps
    

    使用时通常不需要添加此参数,React 默认使用 Object.is 比较每个 props,但是这只是一个浅层比较,如果需要对负责对象进行比较,则需要手动添加 arePropsEqual 参数进行比较,为 false 重新渲染,反之不渲染。

  • 作用:当 props 没有改变时跳过重新渲染(性能优化点)。

  • 注意:默认浅比较、只关注 prop 是否改变,本组件内重新渲染条件照常执行

useReduce

前面的章节我们已经探讨了 useState 钩子,我们日常的开发工作中,绝大多数时候都是用到的的 useState,但是有一些特殊场景,我们使用 useReducer 就会更友好一些。我们先来看看 useReducer 的语法:

const [state, dispatch] = useReducer(reducer, initState, init);
  • useReducer 接受三个参数:
    • reducer:(state, action) => newState
      • state: 当前状态值
      • action:当前执行操作
      • newState:返回一个新的 state
    • initState: 初始 state
    • init(可选):(initState) => initialState;如果传入了init函数,那么初始的 state 就是 initialState
  • useReducer 返回一个数组:
    • state:action 之后的状态值(即 newState);
    • dispatch: (action) => void
      • action 参数就是 reducer 的 action 的入参

我们来对比一下 useState:

const [state, setState] = useState(initState)

我们可以看到 setState 可以直接去更改 state,而 useReducer 却多出来了 reducer,然后再返回 state,我们是否可以理解为 reducer 提供了更丰富的功能,可以按需返回 state

useReducer.png

上图我们模拟了 useState 和 useReducer 的运行逻辑图,这么一看下来,useReducer 就 比 useState 极为相似,我们可以看到,useState 中有一个 basicStateReducer (理解为:固定改变 state 的工具) 与 useReducer 中的 reducer(自定义改变 state 的工具),我们可以看一段 updateState 的源码,就能知道它们的联系:

function updateState<S>(
	initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
	return updateReducer(basicStateReducer, (initailState: any))
}

我们可以看到,当updateState 调用时返回的是 updateReducer,而 basicStateReducer 代替了我们在 useReducer 中自定义的 reducer,因此我们可知:

  • useReducer 与 useState 的作用其实都是一样的,就是更新 state。
  • useState 处理固定更新的场景,而 useReducer 处理自定义更新场景。

我们先来看一个简单的例子:

import { useState, useReducer } from "react";

const CheckBox = () => {
  // useState
  // const [checked, setChecked] = useState(false);
  // const toggle = () => {
  //   setChecked(checked => !checked);
  // }

  // useReducer
  const reducer = checked => !checked;
  const [checked, toggle] = useReducer(reducer, false);

  return (
    <>
      <input type="checkbox" value={checked} onChange={toggle}></input>
      {checked ? '开心' : '不开心'}
    </>
  )
}

export default CheckBox;

我们可以看到上面注释的部分为 useState 注册的状态,这里我们需要知道 setChecked(checked => !checked) 是 useState 的第二种更新 state 方式(函数式更新),我们再看 useReducer 中的 reducer 方法跟函数式更新一模一样,所以我们可以把 useReducer 理解为 useState 函数式更新的封装,来处理更为复杂的状态管理。

import { useState, useReducer } from "react";

const initUser = {
  name: "炭烤小橙微辣",
  age: 18,
};
const UserInfo = () => {
  const [user1, setUser1] = useState(initUser);
  const [user2, setUser2] = useState(initUser);
  const [user3, dispatchUser] = useReducer(
    (user, newInfo) => ({ ...user, ...newInfo }),
    initUser
  );

  const updateUser1 = () => {
    setUser1({ job: "男" });
  };

  const updateUser2 = () => {
    setUser2((user) => ({ ...user, job: "前端" }));
  };

  const updateUser3 = () => {
    dispatchUser({ job: "运动" });
  };

  return (
    <>
      <div style={{ paddingLeft: "20px" }}>
        <p>
          <button onClick={updateUser1} style={{ marginRight: "10px" }}>
            添加性别
          </button>
          user1:
          {Object.keys(user1)
            .map((key) => user1[key])
            .join(" —— ")}
        </p>
        <p>
          <button onClick={updateUser2} style={{ marginRight: "10px" }}>
            添加工作
          </button>
          user2:
          {Object.keys(user2)
            .map((key) => user2[key])
            .join(" —— ")}
        </p>
        <p>
          <button onClick={updateUser3} style={{ marginRight: "10px" }}>
            添加爱好
          </button>
          user3:
          {Object.keys(user3)
            .map((key) => user3[key])
            .join(" —— ")}
        </p>
      </div>
    </>
  );
};

export default UserInfo;

userReducer_opteration.png

同样的数据源,我们点击不同的按钮,用不同的方式对数据进行更新,得到不一样的结果,当我们调用 updateUser1 时,初始对象的属性被清除,证明我们我们用 useState 时不用直接更新,但是我们采用 updateUser2 中更新值时是能达到效果的,反观 useReducer 中的 reducer 方法与 updateUser2 中的方法其实是一样的,只是 useState 需要将结果前置,而 useReducer 是在钩子中得到结果,这样间接的印证了 useState 与 useReducer 效果其实就是一样的。只是我们使用 useReducer 来使得设置值得时候更纯粹一点。当然 useReducer 适用条件如果状态有多个子值或者下一个状态依赖于前一个状态。我在《React 学习手册》 中看到这样一句话给出解释就非常经典了:

授之以鱼不如授之以渔)。

  • 鱼: 结果
  • 渔:获取结果的方法

tip: 如果文章中有不对的地方,欢迎 diss!