React Hooks 踩坑之-- Capture Value 特性

2,971 阅读3分钟

导语: Capture Value 是 React Hooks 中很重要的细节点,本篇文章将从一个实际需求的例子出发,对 Capture Value 进行介绍。

一、从一个例子说起

在 React 应用中异步需求很常见。现在有一个小需求:实现一个按钮默认显示 false,点击后立即更改为 true,两秒后变回 false。

代码如下,自己试试

const Demo = (props) => {
  const [flag, setFlag] = useState(false);
  let timer;
  function handleClick() {
      setFlag(!flag);

      timer = setTimeout(() => {
          setFlag(!flag);
      }, 2000);
  }
  useEffect(() => {
     return () => {
       clearTimeout(timer)
     } 
  })
  return (
    <button onClick={handleClick}>{flag ? "true" : "false"}</button>
  );
}

二、 Capture Value 介绍

Capture Value 从字面上可以理解为固化的值。

flag 作为 useState 的返回值,被上升为了状态。 React.useState 返回的实际是 [hook.memorizedState, dispatch],分别对应了我们接收的值和变更方法。当 setFlag 被调用时,hook.memorizedState 重新指向了 newState(注意:不是修改,而是重新指向)。但在 setTimeout 中的 flag 依然指向了旧的状态,因此得不到新的值。(即读的是旧值,但写的是新值,不是同一个

若对源码了解不多也没有关系,可以把每一次 render 理解为一次快照。

Each Render Has Its Own Props and State.

这句话很好理解,以下面计数器为例:

// useState中的Capture Value特性
function Counter(props) {
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>当前count值: {count}</p>
      <button onClick={() => setCount(count + 1)}>点击</button>
    </div>
  );
}

点击两次后,发生了两次 rerender:

// first render(初始)
function Counter(props) {
  count = 0
  // ...
  <p>当前count值: 0</p>
}

// second render
function Counter(props) {
  count = 1
  // ...
  <p>当前count值: 1</p>
}

// third render
function Counter(props) {
  count = 2
  // ...
  <p>当前count值: 2</p>
}

初始状态下 count 值为 0。随着按钮被点击,在每次 Render 过程中,count 的值都会被固化为 1、2。每一次 Render 都是一个独立的过程,这个特性就是 "Capture Value"

当然,除了 useState,事件处理函数以及useEffect都有自己的Capture Value特性。

// useEffect(还是以上面的计数器为例)
function Counter(props) {
  const [count, setCount] = useState(0);

  useEffect(() => {
    console.log('useEffect count: ', count)
  })
  
  return (
    //...
  );
}

同上,随着点击,count 的状态在 useEffect 中也被固化为 1, 2, 3, ...

总结一下:只要变量上升为了状态,把每一次 Render 理解为一次快照,每个快照独立,而每一次状态都被固化在了这个快照中(无论是在处理函数中还是在useEffect中)。

三、如何绕过 Capture Value

以文章开头的需求为例,按照上面的理解,我们现在可以用最简单的方式来解释这一 bug 的原因。

const [flag, setFlag] = useState(false)
function handleClick() {
    setFlag(!flag);

    timer = setTimeout(() => {
        setFlag(!flag);
    }, 2000);
}

首次点击按钮后,产生一个快照 :

// ...
falg = false;

function handleClick() {
    setFlag(true);

    timer = setTimeout(() => {
        setFlag(true);
    }, 2000);
}
// ...

所以,2s 后 flag 依然 true。

要解决这个问题,很容易想到把上次的状态保存起来。

useRef 在这个时候就能派上用场啦~

自己试试

const Demo = (props) => {
  // ...
  const flagRef = useRef(flag);
  flagRef.current = flag;
  
  function handleClick() {
     setFlag(!flagRef.current);

     setTimeout(() => {
       setFlag(!flagRef.current);
    }, 2000);
  }

  //...
}

问题解决。(当然Demo只是用于展示 flag 的 Capture Value,还有些细节在此没有多做考虑)。

四、原理

掌握了 Capture Value,对 hooks 的工作原理也就熟知大半,帮助我们开发更加优质的代码。

有兴趣的话,还可以深究一下底层原理。

一个简易版的 React Hooks 实现:

let memorizedState = [] // 存放hooks
let cursor = 0
let lastRef

function useState(intialState) {
    memorizedState[cursor] = memeorizedState[cursor] || initialState
    const currentCursor = cursor;
    function setState(newState) {
        memorizedState[currentCursor] = newState
        render()
    }
    
    return [ memorizedState[cursor++], setState]
}

function useEffect(callback, depArr) {
    const noDepArr = !depArr
    const deps = memorizedState[cursor]
    const hasDepsChanged = deps
    ? !depArr.every((el, i) => el === deps[i])
    : true
    if (noDepArr || hasDepsChanged) {
        callback()
        memorizedState[cursor] = depArr
    }
    cursor++
}

function useRef(value){   
  lastRef = lastRef || { current: value }   
  return lastRef 
}

所以产生 Capture Value 的原因,正是每一次 ReRender 的时候,都是重新去执行函数组件了,对于之前已经执行过的函数组件,并不会做任何操作。

五、参考

Hooks探秘

《useEffect完全指南》

扫码关注我们吧~