useRef为什么可以用来封装成usePrevious?
9 个回答
【专栏:精读React Hooks】我用16篇文章详细解读16个React官方的Hook,每一篇都尽力做到比官方文档更仔细且更易读,同时提供了开源demo作为演示。如果你是新手,可以把这个专栏当作学习材料,如果你有一定经验了,可以把这份专栏当作查缺补漏的资料。 专栏首发地址:J实验室 - React Hooks
// 定义
const inputRef = useRef(null);
// 使用
console.log(inputRef.current)
这是useRef
的使用示例,useRef
返回一个可变的 ref 对象,通过.current
可以获取保存在useRef
的值。看起来像是一个复杂版的useState
,那么useState
和useRef
有什么区别?为什么需要useRef
呢?
主要原因有两个:
- 持久性:
useRef
的返回对象在组件的整个生命周期中都是持久的,而不是每次渲染都重新创建。 - 不会触发渲染:当
useState
中的状态改变时,组件会重新渲染。而当useRef
的.current
属性改变时,组件不会重新渲染。
总结来说,useRef
既能保存状态,还不会在更新时触发渲染。本文我们就来盘点一下useRef
的使用场景。
本文首发于我的博客 「 J实验室」
我正在参加年度人气作者打榜,快来 「 支持我」
回归正题,继续讲 useRef
。
useRef 的常见用途
访问 DOM 元素
当我们需要直接与 DOM 元素进行交互(例如,手动获取焦点或测量元素尺寸)时,可以使用 useRef
。
function TextInput() {
const inputRef = useRef(null);
function focusInput() {
inputRef.current.focus();
}
return (
<div>
<input ref={inputRef} type="text" />
<button onClick={focusInput}>Focus the input</button>
</div>
);
}
我们还可以在组件嵌套的场景使用useRef
import { forwardRef, useRef } from 'react';
const MyInput = forwardRef((props, ref) => {
return <input {...props} ref={ref} />;
});
export default function Form() {
const inputRef = useRef(null);
function handleClick() {
inputRef.current.focus();
}
return (
<>
<MyInput ref={inputRef} />
<button onClick={handleClick}>
Focus the input
</button>
</>
);
}
保存状态但不触发渲染
有时,你可能需要在组件中保存某些值,而不希望每次该值更改时都重新渲染组件。在这种情况下,useRef
很有用。
function Timer() {
const count = useRef(0);
useEffect(() => {
const intervalId = setInterval(() => {
count.current += 1;
console.log(`Elapsed time: ${count.current} seconds`);
}, 1000);
return () => clearInterval(intervalId);
}, []);
return <div>Check the console to see the elapsed time!</div>;
}
这个示例完美说明了可以把useRef
视为一个能够在组件的整个生命周期中持久保存数据的“盒子”,而不会引起组件的重新渲染。
保存上一次的 props 或 state
在某些情况下,你可能需要知道 props 或 state 的上一次值。这时可以使用useRef
结合useEffect
来达到目的。
function DisplayValue({ value }) {
const [prevValue, setPrevValue] = useState(null); // 初始时,没有前一个值
const previousValue = useRef(value);
useEffect(() => {
setPrevValue(currentRef.current);
previousValue.current = value;
}, [value]);
return (
<div>
Current Value: {value} <br />
Previous Value: {prevValue}
</div>
);
}
当组件首次渲染时,previousValue.current
会被初始化为value
的当前值。随后,每当value
发生变化时,useEffect
都会运行并更新previousValue.current
为新的value
。
但这里有一个微妙之处:由于useEffect
是在组件渲染之后运行的,因此在组件的渲染过程中,previousValue.current
的值是从前一次渲染中保持不变的。只有当useEffect
被调用并执行完毕后,previousValue.current
才会更新为新的value
。
高级技巧
避免在渲染期间读/写 ref
function DisplayValue({ value }) {
const previousValue = useRef(value);
// 错误:在渲染期间修改 ref
if (previousValue.current !== value) {
previousValue.current = value;
}
return (
<div>
Current Value: {value} <br />
{/* 错误:在渲染期间读 ref */}
Previous Value: {previousValue.current}
</div>
);
}
这里,我们尝试在组件的渲染期间更新previousValue.current
。这违反了 React 的工作方式,并可能导致不可预测的行为。例如:
- 不稳定的 UI:由于 React 在多次渲染中可能使用异步和优化技术,直接在渲染期间修改 refs 可能导致 UI 不一致。
- 依赖更新:如果其他效应或钩子依赖于 ref 的值,它们可能不会在期望的时刻运行,因为直接修改 ref 不会触发重新渲染或其他效应。
这是为什么我们通常在useEffect
内部更新 refs。在useEffect
内部,我们可以确保组件已经完成渲染,并且不会在渲染期间发生任何不期望的副作用。
避免重复创建 ref
如果我们在创建 ref 时,想要通过计算或有副作用的方法获取初值,可能会用下面这种写法。这种写法会导致getInitialCount()
在每次组建渲染的时候都被调用。虽然useRef
的设计让它只从首次渲染的时候获取初值,但这种做法仍然会造成不必要的性能损耗。
function ClickCounter() {
// bad。这里的问题是,每次组件渲染时,getInitialCount都会被调用,尽管它的返回值只在第一次渲染时被使用。
const countRef = useRef(getInitialCount());
function handleClick() {
countRef.current += 1;
console.log(`Button clicked ${countRef.current} times.`);
}
return <button onClick={handleClick}>Click me!</button>;
}
解决这种场景下的 ref 创建也很简单,那就是用null
作为初始值,渲染的过程判断仅在null时去计算或调用有副作用的方法。
function ClickCounter() {
// good
const countRef = useRef(null);
// good
if (countRef.current === null) {
countRef.current = getInitialCount();
}
function handleClick() {
countRef.current += 1;
console.log(`Button clicked ${countRef.current} times.`);
}
return <button onClick={handleClick}>Click me!</button>;
}
与 useReducer 使用
当我们需要复杂的状态逻辑且希望避免额外的渲染时,可以考虑将useRef
与useReducer
结合使用。
例如:跟踪useReducer
的 action 数量。
const initialState = { count: 0 };
function reducer(state, action) {
switch (action.type) {
case "increment":
return { count: state.count + 1 };
default:
throw new Error();
}
}
function Counter() {
const [state, dispatch] = useReducer(reducer, initialState);
const actionsCountRef = useRef(0);
function handleIncrement() {
dispatch({ type: "increment" });
actionsCountRef.current += 1;
console.log(`Actions count: ${actionsCountRef.current}`);
}
return (
<>
Count: {state.count}
<button onClick={handleIncrement}>Increment</button>
</>
);
}
与第三方库集成
在使用非 React 库(如 D3、jQuery)时,我们可能需要使用useRef
来获得对真实 DOM 节点的引用。
例如:结合D3
import { useRef, useEffect } from 'react';
import * as d3 from 'd3';
function BarChart() {
const chartRef = useRef(null);
useEffect(() => {
const svg = d3.select(chartRef.current);
// ... 使用 D3 进行图表绘制
}, []);
return <svg ref={chartRef}></svg>;
}
动画处理
通过useRef
获取元素并使用 Web API 如requestAnimationFrame
可以实现复杂的动画效果。
import { useEffect, useRef } from "react";
function MovingBox() {
const boxRef = useRef(null);
const animationFrameRef = useRef(null);
useEffect(() => {
const boxElem = boxRef.current;
let position = 0;
const animate = () => {
position += 1;
if (position > window.innerWidth) {
position = -100; // 如果方块移动到屏幕的右侧,则从左侧重新开始
}
boxElem.style.transform = `translateX(${position}px)`;
animationFrameRef.current = requestAnimationFrame(animate);
};
animationFrameRef.current = requestAnimationFrame(animate);
return () => {
cancelAnimationFrame(animationFrameRef.current); // 在组件卸载时取消动画
};
}, []);
return (
<div
ref={boxRef}
style={{ width: "100px", height: "100px", background: "blue" }}
></div>
);
}
export default MovingBox;
事件监听
使用useRef
监听不由 React 管理的 DOM 事件。
例如:窗口大小变化
function WindowSize() {
const widthRef = useRef(window.innerWidth);
useEffect(() => {
const handleResize = () => {
widthRef.current = window.innerWidth;
console.log(`Width: ${widthRef.current}`);
};
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
return <div>Check the console for window width updates!</div>;
}
结语
在本篇文章中,我们从基本的 DOM 引用出发,探讨了各种实际的应用场景,包括性能优化和动画方面。通过深入了解并有效使用 useRef
,我们可以更灵活地管理组件内部的状态,而不必担心触发不必要的渲染。希望这篇文章能帮助你更好地理解useRef
并能让你有所启发。
以上多个重要示例的实际效果都可以在我的示例站查看,TypeScript版的源码也已发布到我的Github:useRef分支。
专栏资源
专栏首发地址: 精读React Hooks
专栏演示地址: React Hooks Demos
进群交流: 加入「前端&Node交流群」
useRef 为什么可以用来封装成 usePrevious?
原因如下:
- “useRef() 比 ref 属性更有用。它通过类似在 class 中使用实例字段的方式,非常方便地 保存任何可变值。” —— React 文档
- “当 ref 对象内容发生变化时,useRef 并不会通知变更。变更 .current 属性不会引发组件重新渲染。” —— React 文档
- Refs 被视为访问组件的基础 DOM 元素的正确方法。
如何用 useRef 在实际开发中修复 React 性能问题
Refs 是 React 中很少会使用到的特性。如果你已经读过了官方的 React Ref Guide,你会从中了解到 Refs 被描述为重要的 React 数据流的 “逃生舱门”,需谨慎使用。Refs 被视为访问组件的基础 DOM 元素的正确方法。
伴随着 React Hooks 的到来,React 团队引入了 useRef Hook,它扩展了这个功能:
“useRef()
比 ref 属性更有用。它通过类似在 class 中使用实例字段的方式,非常方便地 保存任何可变值。” —— React 文档
新的 React Hooks API 发布的时候,我的确忽略了这一点,事实证明 useRef 真的非常有用。
面临的问题
我是一名 Firetable 的软件开发工程师。Firetable 是一个开源的 React 电子表格应用,结合了 Firestore 和 Firebase 的主要功能。其中有一个主要功能是侧面抽屉,它是一种类似于窗体的 UI,用于编辑在主表上滑动的那一行。
我是一名 Firetable 的软件开发工程师。Firetable 是一个开源的 React 电子表格应用,结合了 Firestore 和 Firebase 的主要功能。其中有一个主要功能是侧面抽屉,它是一种类似于窗体的 UI,用于编辑在主表上滑动的那一当用户单击选中表格中的某一个单元格时,可以通过打开侧抽屉的方式编辑该单元格所对应的行数据。 换句话说,我们在侧边抽屉中渲染的内容取决于当前选择的行 —— 我们需要将这行的数据状态记录下来。
将这行数据的状态的放在侧抽屉组件内部是最符合逻辑的,因为当用户选择其他单元格时,它应该仅影响侧边的抽屉组件。 然而:
- 我们需要在表格组件里设置这个数据状态。我们用的是react-data-grid渲染表格,并且它接收一个当用户点击一个单元格时会触发的回调。就目前来看,这是我们能从表格中获取选中行数据的唯一途径。
- 但是侧边抽屉组件和表格组件是同级(兄弟)组件,所以不能直接访问彼此的数据状态。
React 的推荐做法是 提升状态 到俩组件最近的父级节点 (以这个为例,父级节点为 TablePage
)。但是我们决定不将状态迁移到这个组件,理由是:
TablePage
不保存状态,主要是放置 table 和 side drawer 组件的容器, 两者都不接收任何的 props。我们倾向于保持这种做法。- 我们已经在组件树的顶层使用 React Context 来共享了许多的全局数据,并且我们觉得应该将这个状态上升到全局 store。
注意:即使我们将数据状态放在了 TablePage
,无论如何我们都将面临下面这个相同的问题。
问题就是每当用户选择一个单元格或打开侧面抽屉时,全局 context 的更新会使得整个应用发生重新渲染。table 组件可以一次显示数十个单元格,并且每个单元格都有自己的编辑器组件。这会导致大约 650ms 的渲染时间,这个时间太长以至于在打开侧边抽屉的时候会感受到明显的延迟。
罪魁祸首是 context —— 这就是为什么要在 React 中使用而不是在全局 JavaScript 对象中使用:
”只要提供给 Provider 的值发生变化,所有消费到了 Provider 的后代组件都会发生重渲染。“ — React Context
到目前为止,虽然我们已经足够了解 React 的状态和生命周期,但现在看来我们依旧陷入了困境。
顿悟时刻
在决定使用 useRef
之前,我们尝试了几种不同的解决方案。(Dan Abramov 的文章) :
- 拆分 context (也就是创建新的
SideDrawerContext
) —— table 组件仍然会消费到新的 context,在打开侧边抽屉的时候依旧会 导致 table 组件的不必要的重新渲染。 - 将 table 组件放在
React.memo
或useMemo
中 —— table 组件依旧是需要通过useContext
拿到侧边抽屉组件的状态,两种 API 均无法阻止其重新渲染。 - 将用于渲染表格的
react-data-grid
组件进行 memo —— 这将使我们的代码更加的冗长。我们还发现它阻止了 “必要” 的重新渲染,要求我们花费更多的时间完全修复或者重构我们的代码来实现侧边抽屉。
当再次阅读 Hook APIs 和 useMemo
文档的时候,我终于遇到了 useRef
相关内容。
“useRef()
比 ref 属性更有用。它通过像在 class 中使用实例字段的方式,非常方便地 保存任何可变值。” —— React 文档
更重要的是:
“当 ref 对象内容发生变化时,useRef
并不会通知变更。变更.current
属性不会引发组件重新渲染。” —— React 文档
此时:我们不需要存储侧抽屉的状态。我们只需要引用设置该状态的函数即可。
解决方案
- 将打开状态和单元状态保存在侧面抽屉组件中。
- 创建这些状态的 ref,并将其存储在 context 中。
- 当用户单击单元格时,使用之前说的表中的回调去调用 ref 设置数据状态的函数(在侧抽屉内)。
以下代码是在 Firetable 使用的代码缩写版,其中包括了 ref 和 TypeScript 的类型:
import { SideDrawerRef } from 'SideDrawer'
export function FiretableContextProvider({ children }) {
const sideDrawerRef = useRef<SideDrawerRef>();
return (
<FiretableContext.Provider value={{ sideDrawerRef }}>
{children}
</FiretableContext.Provider>
)
}
注意:由于函数组件在重新渲染时会运行整个函数体,所以每当 “单元” 或 “打开” 状态更新(并导致重新渲染)时,“sideDrawerRef” 总是能在 “.current” 中获取到最新值。
事实证明,此解决方案是最佳的:
- 当前的单元格和打开的状态存储在侧面抽屉组件中 —— 这是放置它的最合逻辑的地方。
- 当需要时,表格组件也可以访问其兄弟组件的状态。
- 当前单元格或打开状态更新时,它只会触发侧抽屉组件的重新渲染,而不触发整个应用程序中的其他组件重新渲染。
你可以在 Firetable 源码中看它是如何被使用的 GitHub.
什么时候使用 useRef
不过,这并不意味着您可以在应用中随意使用。当您需要在特定时间访问或更新另一个组件的状态,但是您的其他组件不依赖于该状态或基于该状态进行呈现时,这是最好的办法。 React 的提升状态和单向数据流的核心概念足以覆盖大多数应用程序架构。
今天的文章分享就到这里啦,如果喜欢这篇文章的话请点赞、Star、关注我吧
原文地址:How to useRef to Fix React Performance Issues 原文作者:Sidney Alcantara 译文出自:掘金翻译计划 本文永久链接:https://github.com/xitu/gold-miner/blob/master/article/2020/how-to-useref-to-fix-react-performance-issues.md 译者:NieZhuZhu「弹铁蛋同学」 校对者:regon-cao、zenblo