使用 requestAnimationFrame
进行动画应该很容易,但是如果您没有仔细阅读 React 的文档,您可能会遇到一些让您头疼的事情。以下是我通过苦难学到的三个陷阱时刻。
TLDR: 将空数组作为 useEffect
的第二个参数传递,以 避免它运行多次,并将函数传递给您状态的设置器函数,以确保您始终拥有正确的状态。另外,使用 useRef
存储时间戳和请求 ID 之类的东西。
useRef 不仅仅用于 DOM 引用
在函数组件中存储变量有三种方法
- 我们可以定义一个简单的
const
或let
,其值将在每次组件重新渲染时始终重新初始化。 - 我们可以使用
useState
,其值在重新渲染之间持续存在,如果您更改它,它也会触发重新渲染。 - 我们可以使用
useRef
。
useRef
钩子主要用于访问 DOM,但它不仅仅是 DOM 引用。它是一个可变对象,可以在多次重新渲染之间持续存在值。它与 useState
钩子非常相似,除了您可以通过其 .current
属性读取和写入其值,并且更改其值不会重新渲染组件。
例如,以下示例将始终显示 5,即使父组件重新渲染该组件。
function Component() {
let variable = 5;
setTimeout(() => {
variable = variable + 3;
}, 100)
return <div>{variable}</div>
}
…而这个示例会将数字增加三,并不断重新渲染,即使父组件没有改变。
function Component() {
const [variable, setVariable] = React.useState(5);
setTimeout(() => {
setVariable(variable + 3);
}, 100)
return <div>{variable}</div>
}
最后,这个示例返回五,不会重新渲染。但是,如果父组件触发重新渲染,那么每次它都会增加一个值(假设重新渲染在 100 毫秒后发生)。
function Component() {
const variable = React.useRef(5);
setTimeout(() => {
variable.current = variable.current + 3;
}, 100)
return <div>{variable.current}</div>
}
如果我们有一些可变的值,我们希望在下次或以后的渲染中记住它们,并且我们不希望它们在更改时触发重新渲染,那么我们应该使用 useRef
。在我们的案例中,我们需要在清理时不断更改的请求动画帧 ID,如果我们基于周期之间经过的时间进行动画,那么我们需要记住上一次动画的时间戳。这两个变量应该存储为引用。
useEffect 的副作用
我们可以使用 useEffect
钩子来初始化和清理我们的请求,尽管我们希望确保它只运行一次;否则它最终将在每次渲染时创建、取消和重新创建动画帧请求。以下是一个可行的,但糟糕的示例
function App() {
const [state, setState] = React.useState(0)
const requestRef = React.useRef()
const animate = time => {
// Change the state according to the animation
requestRef.current = requestAnimationFrame(animate);
}
// DON’T DO THIS
React.useEffect(() => {
requestRef.current = requestAnimationFrame(animate);
return () => cancelAnimationFrame(requestRef.current);
});
return <div>{state}</div>;
}
为什么它很糟糕?如果您运行它,useEffect
将触发 animate 函数,该函数将更改状态并请求一个新的动画帧。听起来不错,除了状态更改将通过再次运行整个函数来重新渲染组件,包括 useEffect
钩子,它将首先作为清理来取消在先前循环中 animate 函数发出的请求,然后启动一个新的请求。这最终取代了 animate 函数发出的请求,完全没有必要。我们可以通过不在 animate 函数中启动新的请求来避免这种情况,但这仍然不太好。它仍然会让我们在每一轮都进行不必要的清理,并且如果组件由于其他原因而重新渲染——例如父组件重新渲染它或其他一些状态发生了变化——那么不必要的取消和请求重新创建仍然会发生。更好的模式是只初始化一次请求,通过 animate 函数保持它们旋转,然后在组件卸载时进行一次清理。
为了确保 useEffect
钩子只运行一次,我们可以将空数组作为第二个参数传递给它。将空数组传递给它有一个副作用,即它会阻止我们在动画过程中获得正确的状态。第二个参数是更改值的列表,效果需要对这些值做出反应。我们不想对任何事情做出反应——我们只想初始化动画——因此我们有一个空数组。但是 React 会将此解释为意味着此效果不必与状态保持最新。这包括 animate 函数,因为它最初是从效果中调用的。因此,如果我们尝试在 animate 函数中获取状态的值,它将始终是初始值。如果我们想根据其先前值和经过的时间来更改状态,那么它可能无法正常工作。
function App() {
const [state, setState] = React.useState(0)
const requestRef = React.useRef()
const animate = time => {
// The 'state' will always be the initial value here
requestRef.current = requestAnimationFrame(animate);
}
React.useEffect(() => {
requestRef.current = requestAnimationFrame(animate);
return () => cancelAnimationFrame(requestRef.current);
}, []); // Make sure the effect runs only once
return <div>{state}</div>;
}
状态的设置器函数也接受函数
即使 useEffect
钩子将我们的状态锁定到其初始值,也有一种方法可以使用我们的最新状态。useState
钩子的设置器函数也可以接受函数。因此,与其像您通常那样传递基于当前状态的值
setState(state + delta)
…您也可以传递一个函数,该函数将先前值作为参数接收。而且,是的,这将在我们的情况下返回正确的值
setState(prevState => prevState + delta)
将所有内容整合在一起
以下是一个简单的示例来总结所有内容。我们将把上述所有内容整合在一起,以创建一个计数器,它计数到 100,然后从头开始。我们想持久化和修改但不想重新渲染整个组件的技术变量将使用 useRef
存储。我们确保 useEffect
只运行一次,方法是将空数组作为其第二个参数传递。我们通过将函数传递给 useState
的设置器来修改状态,以确保我们始终拥有正确的状态。
查看 Pen
使用 React hooks 的 requestAnimationFrame,作者为 Hunor Marton Borbely (@HunorMarton)
在 CodePen 上。
更新:使用自定义 Hook 迈向更远
一旦基本原理变得清晰,我们还可以通过将大多数逻辑提取到 自定义 Hook
中来进行元编程。这将带来两个好处
- 它极大地简化了我们的组件,隐藏了与动画相关的技术变量,但与我们的主要逻辑无关
- 自定义 Hook 是可重用的。如果您在另一个组件中需要动画,您也可以在那里使用它
自定义 Hook
乍一看似乎是一个高级主题,但最终我们只是将代码的一部分从组件移动到函数中,然后像其他任何函数一样在组件中调用该函数。按照惯例,自定义 Hook 的名称应以 use 关键字开头,并且 Hook 规则适用,但在其他方面,它们只是我们可以使用输入进行自定义并且可能返回某些内容的简单函数。
在我们的案例中,要为 requestAnimationFrame
创建一个通用 Hook,我们可以传递一个回调,我们的 自定义 Hook
将在每个动画循环中调用它。这样,我们的主要动画逻辑将保留在我们的组件中,但组件本身将更加专注。
查看 Pen
使用自定义 React Hook 的 requestAnimationFrame,作者为 Hunor Marton Borbely (@HunorMarton)
在 CodePen 上。
感谢您的阅读。React 新手。很高兴直接投入学习。很高兴看到 CSS-Tricks 了解其生态系统!
一个不错的补充是将 RAF 逻辑提取到一个可重用钩子中:https://codepen.io/testerez/pen/QWLGzee?editors=0010
最初,我确实觉得已经够复杂了,但后来我又加了一个自定义 Hook 版本。最终我得到的解决方案与你的非常相似,除了你是从头开始计算经过的时间,而我则结合了上一次的状态和两次循环之间经过的时间。在你的案例中,确实可以在设置状态时对数字进行四舍五入,但在我的案例中,实际上并不需要。
此外,如果在设置状态时进行四舍五入,可以减少不必要的渲染。
感谢你的这篇文章。问题是,即使你想要做一个简单的动画,也会非常卡顿。我不知道 React 渲染背后的确切机制,但我猜想它们使用了 Promise。在浏览器中,rAF 在 CSS 和渲染元素之前就已处理完毕。但 React 渲染与浏览器渲染不同。因此我认为我们无法期待在这里看到相同的结果。
使用这种方法实现一个简单的 SVG 动画
https://codesandbox.io/s/requestanimationframereact-dgfk5
你的动画示例是使用 `requestAnimationFrame` 创建一个从 1 到 100 的计数器的非常具体的用例。当我想到动画时,我想到的是物体的移动。我不清楚如何使用你的文章中的 `requestAnimationFrame` 在 React 中实现一个从左到右移动盒子的动画。