使用 requestAnimationFrame 与 React Hooks

Avatar of Hunor Márton Borbély
Hunor Márton Borbély

DigitalOcean 为您的旅程各个阶段提供云产品。立即开始使用 价值 200 美元的免费积分!

使用 requestAnimationFrame 进行动画应该很容易,但是如果您没有仔细阅读 React 的文档,您可能会遇到一些让您头疼的事情。以下是我通过苦难学到的三个陷阱时刻。

TLDR: 将空数组作为 useEffect 的第二个参数传递,以 避免它运行多次,并将函数传递给您状态的设置器函数,以确保您始终拥有正确的状态。另外,使用 useRef 存储时间戳和请求 ID 之类的东西。

useRef 不仅仅用于 DOM 引用

在函数组件中存储变量有三种方法

  1. 我们可以定义一个简单的 constlet,其值将在每次组件重新渲染时始终重新初始化。
  2. 我们可以使用 useState,其值在重新渲染之间持续存在,如果您更改它,它也会触发重新渲染。
  3. 我们可以使用 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 中来进行元编程。这将带来两个好处

  1. 它极大地简化了我们的组件,隐藏了与动画相关的技术变量,但与我们的主要逻辑无关
  2. 自定义 Hook 是可重用的。如果您在另一个组件中需要动画,您也可以在那里使用它

自定义 Hook 乍一看似乎是一个高级主题,但最终我们只是将代码的一部分从组件移动到函数中,然后像其他任何函数一样在组件中调用该函数。按照惯例,自定义 Hook 的名称应以 use 关键字开头,并且 Hook 规则适用,但在其他方面,它们只是我们可以使用输入进行自定义并且可能返回某些内容的简单函数。

在我们的案例中,要为 requestAnimationFrame 创建一个通用 Hook,我们可以传递一个回调,我们的 自定义 Hook 将在每个动画循环中调用它。这样,我们的主要动画逻辑将保留在我们的组件中,但组件本身将更加专注。

查看 Pen
使用自定义 React Hook 的 requestAnimationFrame
,作者为 Hunor Marton Borbely (@HunorMarton)
CodePen 上。