重新认识 React.useState

721 阅读9分钟

介绍

返回一个 state,以及更新 state 的函数。

const [state, setState] = useState(initialState);

useState 实现原理

去了解原理的最好方式就是去实现它 useMyState 🐶

我们先声明一下 useMyState 函数,返回一个数组,接收初始值,定义一个state变量存储初始值。注意useState方法只能执行一次,所以我们还需要处理一下,将state放在外面,如果有值不设置默认值,如果没有值再设置默认值。如何可以把一个变量保护起来不让外届可以随便修改?相信大家的第一想法就是「闭包」了。

function useMyState (initialState) {
    const state = initialState;
    function setState(newState) {
        state = newState;
        // render
    }
    return [state, setState]
}

把 从 react 中引入的 useState 替换成自己实现的,查看在线 demo

import React from 'react'
import { render } from 'react-dom'

function useState(initialValue) {
    let state = initialValue
    function dispatch(newState) {
        state = newState
        render(<App />, document.getElementById('root'))
    }
    return [state, dispatch]
}

const App: React.FC = () => {
    const [count, setCount] = useState(0)
    const [name, setName] = useState('airing')
    const [age, setAge] = useState(18)

    return (
        <>
            <p>You clicked {count} times</p>
            <p>Your age is {age}</p>
            <p>Your name is {name}</p>
            <button onClick={() => {
                setCount(count + 1)
                setAge(age + 1)
            }}>
                Click me
            </button>
        </>
    )
}

export default App

这个时候我们发现点击按钮不会有任何响应,count 和 age 都没有变化。因为我们实现的 useState 并不具备存储功能,每次重新渲染上一次的 state 就重置了。这里想到可以在外部用个变量来存储之前的值。基于此,我们优化一下刚才实现的 useMyState:

let state
function useMyState(initialValue) {
    state = state || initialValue
    function setState(newState) {
        state = newState
        // render
    }
    return [state, setState]
}

虽然按钮点击有变化了,但是效果不太对。如果我们删掉 age 和 name 这两个 useState 会发现效果是正常的。这是因为我们只用了单个变量去储存,那自然只能存储一个 useState 的值。那我们想到可以用数组,去储存所有的 state,但同时我们需要维护好数组的索引。再次优化 useMyState:

let memoizedState = [] // hooks 的值存放在这个数组里
let cursor = 0 // 当前 memoizedState 的索引

function useMyState(initialValue) {
    memoizedState[cursor] = memoizedState[cursor] || initialValue
    const currentCursor = cursor
    function setState(newState) {
        memoizedState[currentCursor] = newState
        cursor = 0 // 每次 setState 后会重新触发 render,需要重置下标
        // render
    }
    return [memoizedState[cursor++], setState] // 返回当前 state,并把 cursor 加 1
}

看起来已经可以很好的运行起来了,这里是使用了数组来存储所有的 state,实际上 React 是使用「链表」的数据结构来存储 hooks,有兴趣的同学也可以去阅读一下相关的源码。这也是为什么在不能用 if...else...,while 等条件语句包住 hook,因为只要顺序不对,读写就会开始错乱。

setState 是异步的还是同步的?

一道 React 很经典的面试题~众所周知,如果没有合并更新,在每次执行 setState 的时候,组件都要重新 render 一次,会造成很多无效渲染(因为最后一次渲染会覆盖掉前面所有的渲染效果)。 所以 react 会把一些可以一起更新的 useState/setState 放在一起,进行合并更新(batch update)。因为无法在 setState 后马上从 state 上获取更新后的值,所以也会把这种情况说 setState 是异步的。在线 Demo

来个🌰

function App() {
  const [count, setCount] = useState(0);

  const handleClick = () => {
    // 有很多次 setState
    setCount(preCount => preCount + 1);
    setCount(preCount => preCount + 1);
    setCount(preCount => preCount + 1);
    setCount(preCount => preCount + 1);
  };
  
  // 点击按钮后只会打印一次
  console.log('render', count); 

  return (
    <div className="App">
      <button onClick={handleClick}>点击</button>
    </div>
  );
}

但也会存在一些特殊情况的

1. setTimeout / setInterval

function App() {
  const [count, setCount] = useState(0);

  const handleClick = () => {
    setTimeout(() => {
      setCount(preCount => preCount + 1);
      setCount(preCount => preCount + 1);
      setCount(preCount => preCount + 1);
      setCount(preCount => preCount + 1);
    })
  };
  
  // 点击按钮后只会打印 4 次
  console.log('render', count);

  return (
    <div className="App">
      <button onClick={handleClick}>点击</button>
    </div>
  );
}

2. Promise / Fetch 的回调中

function App() {
  const [count, setCount] = useState(0);

  const handleClick = () => {
    Promise.resolve().then(() => {
      setCount(preCount => preCount + 1);
      setCount(preCount => preCount + 1);
      setCount(preCount => preCount + 1);
      setCount(preCount => preCount + 1);
    })
  };
  
  // 点击按钮后只会打印 4 次
  console.log('render', count);

  return (
    <div className="App">
      <button onClick={handleClick}>点击</button>
    </div>
  );
}

3. 手动绑定原生事件

function App() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const buttonNode = document.getElementById('button');
    buttonNode.addEventListener('click', handleClick);

    return () => {
      buttonNode.removeEventListener('click', handleClick);
    };
  }, []);

  const handleClick = () => {
    setCount((preCount) => preCount + 1);
    setCount((preCount) => preCount + 1);
    setCount((preCount) => preCount + 1);
    setCount((preCount) => preCount + 1);
  };

  // 点击按钮后只会打印 4 次
  console.log('render', count);

  return (
    <div className="App">
      <button id="button">点击</button>
    </div>
  );
}

WHY ?

为什么会有不同的情况发生呢?这里就涉及到了 batch update 是怎么运作的了,可以先看下官方的图

*                       wrappers (injected at creation time)
*                                      +        +
*                                      |        |
*                    +-----------------|--------|--------------+
*                    |                 v        |              |
*                    |      +---------------+   |              |
*                    |   +--|    wrapper1   |---|----+         |
*                    |   |  +---------------+   v    |         |
*                    |   |          +-------------+  |         |
*                    |   |     +----|   wrapper2  |--------+   |
*                    |   |     |    +-------------+  |     |   |
*                    |   |     |                     |     |   |
*                    |   v     v                     v     v   | wrapper
*                    | +---+ +---+   +---------+   +---+ +---+ | invariants
* perform(anyMethod) | |   | |   |   |         |   |   | |   | | maintained
* +----------------->|-|---|-|---|-->|anyMethod|---|---|-|---|-|-------->
*                    | |   | |   |   |         |   |   | |   | |
*                    | |   | |   |   |         |   |   | |   | |
*                    | |   | |   |   |         |   |   | |   | |
*                    | +---+ +---+   +---------+   +---+ +---+ |
*                    |  initialize                    close    |
*                    +-----------------------------------------+

用大白话将就是:

  • 在 react的生命周期和合成事件中, react仍然处于他的更新机制中,这时 isBranchUpdate 为 true。按照上述过程,这时无论调用多少次 setState,都会不会执行更新,而是将要更新的 state 存入 _pendingStateQueue,将要更新的组件存入 dirtyComponent。当上一次更新机制执行完毕,以生命周期为例,所有组件,即最顶层组件 didmount 后会将 isBranchUpdate 设置为 false。这时将执行之前累积的 setState。
  • 由执行机制看, setState 本身并不是异步的,而是如果在调用 setState 时,如果 react正处于更新过程,当前更新会被暂存(EventLoop),等上一次更新执行后在执行,这个过程给人一种异步的假象。在生命周期,根据JS的事件循环机制,会将微任务和宏任务放到任务队列中,等所有同步代码执行完毕后再依次执行,这时上一次更新过程已经执行完毕, isBranchUpdate 被设置为 false,根据上面的流程,这时再调用 setState 由于不处理更新过程即可立即执行更新。

如何马上获取 state 最新的值?

在 class component 中,可以通过 setState 的第二个参数,或者在同步模式中可以直接获取到最新 state 的值,但在 function component 中的表现却不一样。

来个🌰

function App() {
  const [count, setCount] = useState(0);

  const handleClick = () => {
    setCount(count + 1);
    func(); 
  };

  const func = () => {
    // 点击后打印 0
    console.log(count);
  };

  return (
    <div className="App">
      <button onClick={handleClick}>点击</button>
    </div>
  );
}
  • 我们的组件第一次渲染的时候,从useState()拿到count的初始值0。当我们调用setCount(1),React会再次渲染组件,这一次count是1。
  • 我们发现count在每一次函数调用中都是一个常量值。值得强调的是 — 我们的组件函数每次渲染都会被调用,但是每一次调用中 count 值都是常量,并且它被赋予了当前渲染中的状态值。

1. 直接把值传给下个函数

function App() {
  const [count, setCount] = useState(0);

  const handleClick = () => {
    const newCount = count + 1
    setCount(newCount);
    // 将最新的值当作函数参数进行传递
    func(newCount); 
  };

  const func = (newCount) => {
    console.log(newCount);
  };

  return (
    <div className="App">
      <button onClick={handleClick}>点击</button>
    </div>
  );
}

2. 使用useEffect 监听 state 的变化

function App() {
  const [count, setCount] = useState(0);

  const handleClick = () => {
    setCount(count + 1);
  };
  
  useEffect(() => {
    console.log(count)
    function act (){
      ...
    }
    act()
  }, [count])

  return (
    <div className="App">
      <button onClick={handleClick}>点击</button>
    </div>
  );
}

3. 使用 useRef 将值保存起来

function App() {
  const [count, setCount] = useState(0);
  const lastCount = useRef()

  const handleClick = () => {
    const newCount = count + 1
    setCount(newCount);
    lastCount.current = newCount;
    func()
  };
  
  const func = () => {
    console.log(lastCount.current);
  };

  return (
    <div className="App">
      <button onClick={handleClick}>点击</button>
    </div>
  );
}

将通用的部分抽成一个 hook

const useSyncState = (initState) => {
  const [state, setStateValue] = useState(initState)
  const stateRef = useRef(state)
  
  const setState = (value) => {
    stateRef.current = value
    setStateValue(value)
  }
  
  const getSyncState = useCallback(() => stateRef.current, [])
  
  return [state, setState, getSyncState]
}


function App() {
  const [count, setCount] = useSyncState(0);

  const handleClick = () => {
    setCount(count.current + 1);
    func()
  };
  
  const func = () => {
    console.log(getSyncState());
  };

  return (
    <div className="App">
      <button onClick={handleClick}>点击</button>
    </div>
  );
}

4. 等值变化后在调用 func

因为useEffect是在react组件render之后才会执行,所以在useEffect获取的状态一定是最新的,所以利用这一点,把我们写的函数放到useEffect执行,函数里获取的状态就一定是最新的。

首先,在useSyncCallback中创建一个标示proxyState,初始的时候会把proxyState的current值赋成false,在callback执行之前会先判断current是否为true,如果为true就允许callback执行,若果为false,就跳过不执行,因为useEffect在组件render之后,只要依赖项有变化就会执行,所以我们无法掌控我们的函数执行,在useSyncCallback中创建一个新的函数Func,并返回,通过这个Func来模拟函数调用。

const useSyncCallback = callback => {
    const [proxyState, setProxyState] = useState({ current: false });

    const Func = useCallback(() => {
        setProxyState({ current: true });
    }, [proxyState])

    useEffect(() => {
        if (proxyState.current === true) setProxyState({ current: false });
    }, [proxyState])

    useEffect(() => {
        proxyState.current && callback();
    })

    return Func
}


function App() {
  const [count, setCount] = useState(0);

  const handleClick = () => {
    setCount(count + 1);
    func()
  };
  
  const func = useSyncCallback(() => {
    console.log(count);
  });

  return (
    <div className="App">
      <button onClick={handleClick}>点击</button>
    </div>
  );
}

5. 模拟实现 setState 的回调函数

import { useEffect, useRef, SetStateAction } from 'react';
import { useSafeState } from 'ahooks';

type DispatchWithCallback<A> = (value: SetStateAction<A>, callback?: Callback<A>) => void;
type Callback<S = any> = (state: S) => void | (() => void | undefined);


const useStateWithCallbackLazy = <S>(initialValue: S): [S, DispatchWithCallback<S>] => {
  const callbacksRef = useRef<Callback<S>[]>([]);

  const [value, setValue] = useSafeState<S>(initialValue);

  useEffect(() => {
    if (callbacksRef.current.length > 0) {
      callbacksRef.current.forEach(refItem => {
        refItem.call(null, value);
      });
      // callback依次调用之后,清空callbackRef
      callbacksRef.current = [];
    }
  }, [value]);

  const setValueWithCallback = (newValue: SetStateAction<S>, callback?: Callback<S>) => {
    // 过滤处理 1. callback 为空 2. callback为重复传入(浅比较去重)
    if (
      callback &&
      !callbacksRef.current.some(refItem => {
        if (refItem === callback) {
          console.warn('WARNING: 此次传入的 callback 已存在与回调队列,会进行过滤处理,请勿传入重复 callback');
          return true;
        } else {
          return false;
        }
      })
    ) {
      callbacksRef.current.push(callback);
    }
    return setValue(newValue);
  };

  return [value, setValueWithCallback];
};

export default useStateWithCallbackLazy;

batch update 成长史

在 react 18 新特性已经实现了 Automatic batching,即不在 react 控制范围内也可以实现 batch update,请看对比的例子

那么 为什么 react 18 原理又和 17 有什么区别呢?

v18实现**「自动批处理」**的关键在于两点:

  • 增加调度的流程
  • 不以全局变量「executionContext」为批处理依据,而是以更新的**「优先级」**为依据

在组件对应fiber挂载update后,就会进入**「调度流程」**。

试想,一个大型应用,在某一时刻,应用的不同组件都触发了更新。

那么在不同组件对应的 fiber 中会存在不同优先级的 update。

**「调度流程」**的作用就是:选出这些 update 中优先级最高的那个,以该优先级进入更新流程。

  1. 获取当前所有优先级中最高的优先级
  2. 将步骤1的优先级作为本次调度的优先级
  3. 看是否已经存在一个调度
  4. 如果已经存在调度,且和当前要调度的优先级一致,则 return
  5. 不一致的话就进入调度流程

由于每次执行 setState 都会创建 update 并挂载在 fiber 上。并且每个 setState 的优先级都是一样的

所以即使只执行一次更新流程,还是能将状态更新到最新。

总结

  • useState 最底层的原理是使用了链表结构并把对应的值存储在 fiber 节点上,对顺序有要求,所以不可以在条件语句 & 循环语句中使用 hook
  • React 17 及之前 setState 在 react 控制范围内是 异步的,不在 react 控制范围内是 同步的。
    • 以一个全局变量 isBranchUpdate 来判断是否需要合并
  • React 18 之后无论是否在 react 的控制范围内都实现了 batchUpdate
    • 不再以 全局变量来判断是否需要合并了,而是以「优先级」来判断是否需要进行合并

友情链接