uidotdev/usehooks 是怎么实现 usePrevious 的?
最近发现了一个有趣的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
的值和它现在实际存的值是不一样的。
上面官网的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
能够满足需求。