shadowfish和他的代码

vuePress-theme-reco shadowfish    2020 - 2023
shadowfish和他的代码

Choose mode

  • dark
  • auto
  • light
时间轴

shadowfish

49

Article

42

Tag

时间轴

uidotdev/usehooks 是怎么实现 usePrevious 的?

vuePress-theme-reco shadowfish    2020 - 2023

uidotdev/usehooks 是怎么实现 usePrevious 的?

shadowfish 2023-06-10 React

最近发现了一个有趣的React库:https://github.com/uidotdev/usehooks,号称有一系列“现代化、服务器安全”的React Hooks,准备来学习学习人家的最佳实践。

首先我看的是usePrevious。官方对它的描述是这样的:

The usePrevious hook is a useful tool for tracking the previous value of a variable in a functional component. This can be particularly handy in scenarios where it is necessary to compare the current value with the previous one, such as triggering actions or rendering based on changes.

usePrevious 是用来追踪一个变量的上一个值的,在需要比较当前值和上一个值的场景很有用,比如触发动作、和基于变化渲染。

它给的例子是在按钮点击事件后,同时渲染在点击事件前后的变化:

那么我就挺好奇它的内部实现,看代码:

export function usePrevious(newValue) {
  const previousRef = React.useRef();

  React.useEffect(() => {
    previousRef.current = newValue;
  });

  return previousRef.current;
}

非常简洁。用到了useRef,利用useEffect在每次渲染的时候都给ref刷新一次值,最终返回的是ref.current。

结合它实现上面demo的代码一起看,我没反应过来,useRef不是不会触发更新吗?为什么setColor之后,previousColor展示的值也会发生变化?

import * as React from "react";
import { usePrevious } from "@uidotdev/usehooks";

function getRandomColor() {
  const colors = ["green", "blue", "purple", "red", "pink"];
  return colors[Math.floor(Math.random() * colors.length)];
}

export default function App() {
  const [color, setColor] = React.useState(getRandomColor());
  const previousColor = usePrevious(color);

  const handleClick = () => {
    function getNewColor() {
      const newColor = getRandomColor();
      if (color === newColor) {
        getNewColor();
      } else {
        setColor(newColor);
      }
    }
    getNewColor();
  };

  return (
    <section>
      <h1>usePrevious</h1>
      <button className="link" onClick={handleClick}>
        Next
      </button>
      <article>
        <figure>
          <p style={{ background: `var(--${previousColor})` }} />
          <figcaption>Previous: {previousColor}</figcaption>
        </figure>
        <figure>
          <p style={{ background: `var(--${color})` }} />
          <figcaption>Current: {color}</figcaption>
        </figure>
      </article>
    </section>
  );
}

事实上,usePrevious就是利用了useRef的值更改后不会触发重渲染的特性,来实现的保存变量的上个状态。

如下图所示,当我们点击按钮后,执行了setColor,然后React重新渲染组件,然后执行了usePrevious内部定义的useEffect函数,记录了这次渲染的最新的颜色值。但是,正是由于useRef的值不会触发重新渲染,所以页面上渲染的previousColor的值和它现在实际存的值是不一样的。

image-20230611012514435

上面官网的demo验证了这个usePrevious的实现,那么在事件回调中的使用场景呢?我们加一个按钮,点击后打印一下previousColor就能看出来,它的值也是正确的:

<button
  onClick={() => {
    console.log("color", previousColor, color);
  }}
>
  What is Prev
</button>

这里的值正确,其实只是因为闭包的特性,本次渲染传给button的函数内previousColor的值不会再随着previousRef.current的值改变而改变。实际上,previousRef.current的值在打印上面的日志时已经和color一样了。

我们把previousRef直接拿来打印就可以看出来:

<button
  onClick={() => {
    // previousColorRef?.current = color != previousColor
    console.log("color", previousColorRef?.current, previousColor, color);
  }}
>
  What is Prev
</button>

分析到这里,可以看出usePrevious的实现还是比较巧妙的,利用了useRef的值更新不会触发重渲,并且useEffect内的函数是在完成了渲染后再执行的两大机制,实现了对值的上一个状态的记录。

不过,渲染的值和实际存储的值不一样,总还是有些奇怪的。

查阅React官方文档 ,明确表明不要把ref.current的值在渲染时读写。

Do not write or read ref.current during rendering.

Bad case 长这样:

function MyComponent() {
  // ...
  // 🚩 Don't write a ref during rendering
  myRef.current = 123;
  // ...
  // 🚩 Don't read a ref during rendering
  return <h1>{myOtherRef.current}</h1>;
}

停下来想想,为什么不允许ref.current的值在渲染时读写呢?React官方给出的原因是:组件应该像一个纯函数一样,如果输入(props、state、context)一致,那么返回的JSX也是一致的。同时,组件函数的执行顺序也应当不影响它的返回结果。

显然,如果在render时读写ref.current的值,那么函数的返回值就不光和函数的输入挂钩,还和ref.current挂钩。而由于它的改变不会引起重渲染,因此它是不稳定的,会破坏函数组件的纯函数特性。


显然usePrevious用于渲染场景,是在渲染时读了ref.current的值了。

因此,我觉得这个库对usePrevious的实现并非最佳实践。不过,由于「获取上一次渲染的值」的场景很多,也是存在符合React官方的Good Case的:

function MyComponent() {
  // ...
  useEffect(() => {
    // ✅ You can read or write refs in effects
    myRef.current = 123;
  });
  // ...
  function handleClick() {
    // ✅ You can read or write refs in event handlers
    doSomething(myOtherRef.current);
  }
  // ...
}

最后我的评价是,如果要把上一次渲染的值作为JSX的输出去直接渲染,那么还是自己写useState会更好;其他场景,usePrevious能够满足需求。