使用 React useRef 实现高级的 usePrevious hook

码道人 2022-06-23 13:36:46 阅读数:142

react使用实现高级useref

在 Context 之后,ref 可能是 React 中最神秘的部分了。我们几乎已经习惯了组件上的 ref 属性,但并不是每个人都知道,它的使用不仅限于在组件之间来回传递它并附加到 DOM 节点上。我们实际上可以在那里存储数据!甚至实现诸如usePrevious 钩子之类的东西来获取先前的状态或 prop 或任何其他值。

顺便说一句,如果你曾经以 React 文档中的方式使用过这个钩子,你有没有研究过它是如何工作的?它返回什么值,为什么?结果可能会让你大吃一惊

所以这正是我在这篇文章中想要做的:看一下 ref 以及当它没有附加到 DOM 节点时它是如何工作的;调查其usePrevious工作原理并说明为什么按正常方式使用它并不总是一个好主意;同时我也会实现一个更高级的 hook 版本。

首先,什么是ref?

让我们首先记住一些基础知识,以充分理解它。

想象一下,你需要在组件中存储和操作一些数据。通常,我们有两个选择:要么将其放入变量中,要么放入状态中。在变量中,你需要在每次重新渲染时重新计算一些内容,例如任何依赖于 prop 值的中间值:

const Form = ({ price }) => {
const discount = 0.1 * price;
return <>Discount: {discount}</>;
};

创建新变量或更改该变量不会导致Form组件重新渲染。

在 state 中,我们通常会在重新渲染之间放置需要保存的值,通常来自与我们的 UI 交互的用户:

const Form = () => {
const [name, setName] = useState();
return <input value={name} onChange={(e) => setName(e.target.value)} />;
};

更改状态将导致Form组件重新渲染自身。

然而,还有第三个鲜为人知的选择:ref。它合并了这两者的行为:它本质上是一个不会导致组件重新渲染的变量,但它的值在重新渲染之间保留。

让我们实现一个计数器(我保证,这是本博客中的第一个也是最后一个计数器示例)来说明所有这三种行为。

const Counter = () => {
let counter = 0;
const onClick = () => {
counter = counter + 1;
console.log(counter);
};
return (
<>
<button onClick={onClick}>click to update counter</button>
Counter value: {counter}
</>
);
};

这当然行不通。在我们的console.log中,我们将看到更新后的计数器值,但在屏幕上渲染的值不会改变 —— 变量不会导致重新渲染,因此我们的渲染输出将永远不会更新。

另一方面,状态将按预期工作:这正是状态的用途。

const Counter = () => {
const [counter, setCounter] = useState(0);
const onClick = () => {
setCounter(counter + 1);
};
return (
<>
<button onClick={onClick}>click to update counter</button>
Counter value: {counter}
</>
);
};

现在有趣的部分:与 ref 相同。

const Counter = () => {
// set ref's initial value, same as state
const ref = useRef(0);
const onClick = () => {
// ref.current is where our counter value is stored
ref.current = ref.current + 1;
};
return (
<>
<button onClick={onClick}>click to update counter</button>
Counter value: {ref.curent}
</>
);
};

这也行不通。每次单击按钮时,ref 中的值都会发生变化,但更改 ref 值不会导致重新渲染,因此不会再次更新渲染输出。但是!如果在此之后有其他原因导致渲染周期,渲染输出将使用ref.current。例如,如果我将两个计数器都添加到同一个函数中:

const Counter = () => {
const ref = useRef(0);
const [stateCounter, setStateCounter] = useState(0);
return (
<>
<button onClick={() => setStateCounter(stateCounter + 1)}>update state counter</button>
<button
onClick={() => {
ref.current = ref.current + 1;
}}
>
update ref counter
</button>
State counter value: {stateCounter}
Ref counter value: {ref.curent}
</>
);
};

这将产生一个有趣的效果:每次单击“更新引用计数器”按钮时,什么都不会发生。但是,如果之后单击“更新状态计数器”按钮,渲染输出将使用这两个值进行更新。

Counter 显然不是 refs 的最佳用途。然而,它们有一个非常有趣的用例,甚至在 React 文档本身中也推荐:实现一个钩子 usePrevious ,它返回以前的状态或 props。让我们来实现它吧!

来自 React 文档的 usePrevious 钩子

在开始重新发明轮子之前,让我们看看文档必须提供什么:

const usePrevious = (value) => {
const ref = useRef();
useEffect(() => {
ref.current = value;
});
return ref.current;
};

看起来很简单。现在,在深入了解它的实际工作原理之前,我们先在一个简单的表单上尝试一下。

我们将有一个设置页面,你需要在其中输入姓名并为你未来的产品选择价格。在页面底部,我将有一个简单的“显示价格变化”组件,它将显示当前选择的价格,以及与之前的值相比这个价格是上涨还是下跌 —— 这就是我要使用usePrevious钩子的地方。

让我们从仅用价格实现表单开始,因为它是我们功能中最重要的部分。

const prices = [100, 200, 300, 400, 500, 600, 700];
const Page = () => {
const [price, setPrice] = useState(100);
const onPriceChange = (e) => setPrice(Number(e.target.value));
return (
<>
<select value={price} onChange={onPriceChange}>
{prices.map((price) => (<option value={price}>{price}$</option>))}
</select>
<Price price={price} />
</div>
);
}

价格组件:

export const Price = ({ price }) => {
const prevPrice = usePrevious(price);
const icon = prevPrice && prevPrice < price ? '' : '';
return (
<div>
Current price: {price}; <br />
Previous price: {prevPrice} {icon}
</div>
);
};

现在是最后一个小步骤:将名称输入字段添加到表单中,以完成功能。

const Page = () => {
const [name, setName] = useState("");
const onNameChange = (e) => setName(e.target.value);
// the rest of the code is the same
return (
<>
<input type="text" value={name} onChange={onNameChange} />
<!-- the rest is the same -->
</div>
);
}

可以正常工作吗?不! 当我选择价格时,一切正常。但是,一旦我开始输入名称输入 ——Price组件中的值会将自身重置为最新选择的值,而不是之前的值。为什么呢?

现在是时候仔细看看usePrevious 的实现了,记住 ref 的行为方式,以及 React 生命周期和重新渲染是如何工作的。

const usePrevious = (value) => {
const ref = useRef();
useEffect(() => {
ref.current = value;
});
return ref.current;
};

首先,在Price组件的初始渲染期间,我们调用我们的usePrevious钩子。在那里,我们创建了一个空值的 ref。之后,我们立即返回创建的 ref 的值,在这种情况下将是null(这是有意的,初始渲染中没有先前的值)。初始渲染完成后,useEffect将触发 ,在其中我们ref.current使用传递给钩子的值更新。而且,由于它是一个引用,而不是状态,所以值只是在那里发生了变异,而不会导致钩子重新渲染自身,因此它的消费者组件没有获得最新的引用值。

如果从文字中难以想象,这里有一张图帮助你理解:

那么当我开始在名称字段中输入时会发生什么?父Form组件更新其状态 → 触发其子组件的重新渲染 → Price组件开始重新渲染 → usePrevious以相同的价格值调用钩子(我们只更改了名称)→ 钩子返回我们在上一个渲染周期中改变的更新值 → 渲染完成,useEffect被触发,完成。在我们将值300转换为300,这将导致Price组件中呈现的值被更新。

所以这个钩子在它当前的实现中所做的事情,是从上一个渲染周期返回一个值。当然,有以这种方式使用它的用例。也许你只需要在值更改时触发一些数据获取,而多次重新渲染后发生的情况并不重要。但是如果你想在 UI 的任何地方显示“前一个”值,这里更可靠的方法是让钩子返回实际的前一个值。

让我们实现这一点。

usePrevious 钩子返回实际的先前值

为了做到这一点,我们只需要在 ref 中保存两个值 —— previous 和 current。并且仅在值实际更改时才切换它们。在这里 ref 可以派上用场:

export const usePreviousPersistent = (value) => {
// initialise the ref with previous and current values
const ref = useRef({
value: value,
prev: null,
});
const current = ref.current.value;
// if the value passed into hook doesn't match what we store as "current"
// move the "current" to the "previous"
// and store the passed value as "current"
if (value !== current) {
ref.current = {
value: value,
prev: current,
};
}
// return the previous value only
return ref

实现甚至变得稍微简单一些:我们摆脱了依赖useEffect并接受一个值、执行一个 if 语句并返回一个值。

现在,最大的问题是:我们真的需要 refs 吗?我们不能只用状态实现完全相同的东西而不求助于逃生舱口(实际上是哪个 ref )?好吧,从技术上讲是的,代码几乎相同:

export const usePreviousPersistent = (value) => {
const [state, setState] = useState({
value: value,
prev: null,
});
const current = state.value;
if (value !== current) {
setState({
value: value,
prev: current,
});
}
return state.prev;
};

这样做有一个问题:每次值变化都会触发状态更新,进而触发“宿主”组件的重新渲染。这将导致组件在每次价格 prop 更改时Price重新渲染两次 —— 第一次是因为实际的 prop 更改,第二次是因为挂钩中的状态更新。对于我们的小案例来说并不重要,但作为一个可以在任何地方使用的通用解决方案 —— 这不是一个好主意。

usePrevious hook:正确处理对象

如果我试图传递一个对象会发生什么?例如所有的 prop?

export const Price = (props) => {
// with the current implementation only primitive values are supported
const prevProps = usePreviousPersistent(props);
...
};

我们在这里进行了浅比较:(value !== current),因此if检查将始终返回true。为了解决这个问题,我们可以只引入深度相等比较。

import isEqual from 'lodash/isEqual';
export const usePreviousPersistent = (value) => {
...
if (!isEqual(value, current)) {
...
}
return state.prev;
};

就个人而言,我不是这个解决方案的忠实拥护者:在大数据集上它可能会变得很慢,加上依赖于外部库(或我自己实现深度相等),这样的钩子似乎不太理想。

另一种方法是,由于钩子只是函数并且可以接受任何参数,因此引入了一个“匹配器”函数。像这样的东西:

export const usePreviousPersistent = (value, isEqualFunc) => {
...
if (isEqualFunc ? !isEqualFunc(value, current) : value !== current) {
...
}
return state.prev;
};

这样我们仍然可以在没有函数的情况下使用钩子 —— 它将回退到浅比较。现在还可以为钩子提供一种比较值的方法:

export const Price = (props) => {
const prevPrice = usePrevious(
price,
(prev, current) => prev.price === current.price
);
...
};

它可能看起来对 prop 没那么有用,但想象一下来自外部来源的一些数据的巨大对象。通常它会有某种 id。因此,你可以这样做,而不是像之前的示例中那样进行缓慢的深度比较:

const prevData = usePrevious(price, (prev, current) => prev.id === current.id);

这就是今天的全部内容。希望你发现这篇文章很有用,能够更自信地使用 refs 和 usePrevious

版权声明:本文为[码道人]所创,转载请带上原文链接,感谢。 https://markdowner.net/v1/article/by_user/327377480144928768