跳到主要内容

认识

2023年02月22日
柏拉文
越努力,越幸运

一、认识


useState 允许在函数组件中定义一个状态变量。useState 返回一个数组, 该数组中含有两个元素: 当前状态值和一个更新该状态的函数。useState 可以传入一个初始值或者一个回调函数, 回调函数会得到一个先前状态的参数。当调用更新状态的函数时, React 会将传入的值与当前状态进行 Object.is 比较, 如果状态发生变化, React 将会重新渲染组件。useState 与类组件中 this.setState 不同的是, useState 会直接替换之前值, 而不是合并。React 会对 useState 调用进行批量处理已优化性能。

二、语法


const [state, setState] = useState<T>(initialState)
  • state: 当前的 state。在首次渲染时,它将与你传递的 initialState 相匹配

  • setState: 返回 dispatch 方法,可以让你将 state 更新为不同的值并触发重新渲染。它可以是任何类型的值,但对于函数有特殊的行为。

    • 对象:

    • 函数: 如果你将函数作为 nextState 传递,它将被视为更新函数。它必须是纯函数,只接受待定的 state 作为其唯一参数,并应返回下一个状态。React 将把你的更新函数放入队列中并重新渲染组件。在下一次渲染期间,React 将通过把队列中所有更新函数应用于先前的状态来计算下一个状态。

  • initialState: state 初始化的值, 它可以是任何类型的值,但对于函数有特殊的行为。在初始渲染后,此参数将被忽略。如果传递函数作为 initialState,则它将被视为 初始化函数。它应该是纯函数,不应该接受任何参数,并且应该返回一个任何类型的值。当初始化组件时,React 将调用你的初始化函数,并将其返回值存储为初始状态。

    const [state, setState] = useState<T>(xx)

    const [state, setState] = useState<T>(()=> { return xx })

    注意, 直接传递初始化函数与传递调用初始化函数的结果的区别如下所示:

    const calculate = ()=>{
    return xxx;
    }

    const [state, setState] = useState<T>(calculate); // 这个例子传递了初始化函数,因此 calculate 函数仅在初始化期间运行。当组件重新渲染,它不会再次运行。

    const [state, setState] = useState<T>(calculate()); // 这个例子 没有 传递初始化函数,因此 calculate 函数会在每次渲染时运行,这种行为没有什么明显的差异,但这种代码是不那么高效的。

    因此, 我们如果使用函数时, 必须直接将函数作为初始值传入(函数本身), 而不是函数的调用结果。 这样 React 只在初次渲染时调用函数,后续渲染时将其忽略, 可以避免重复创建初始状态。

2.1 对象

const [value,setValue] = useState(0);

setValue(xx);

2.2 函数

const [value,setValue] = useState(0);

setValue((prev)=> xx);

三、更新


React.js 18.x 之前, React合成事件生命周期钩子(除 componentDidUpdate 除外)将会批量处理更新。但是在 Promise.thensetTimeout 或者原生事件处理程序中, 更新将会以同步的方式处理。

React.js 18.x 之后, 几乎所有更新, 包括 Promise.thensetTimeout 或者原生事件处理程序等, 都将自动批处理。因此, 在 React.js 18.x 之后, 如果需要强制同步更新 useState dispatch, 可以将更新包装在 flushSync 中, 但是这样会损害性能。

useState dispatch 批量更新策略: 多次进行 useState dispatch: 如果传入值, 会对其进行覆盖, 取最后一次执行。如果传入函数, 更新函数放入队列, 依次执行, 并计算。

3.1 React.js 18 之前值更新

3.2 React.js 18 之后值更新

import { useEffect, useState } from 'react';

function App() {
const [value, setValue] = useState(0);

useEffect(() => {
setValue(value + 1);
console.log(value); // 0
setValue(value + 1);
console.log(value); // 0

setTimeout(() => {
setValue(value + 2);
console.log(value); // 0
setValue(value + 1);
console.log(value); // 0
}, 0);
}, []);

return <div>{value}</div>; // 1
}

export default App;

3.3 React.js 18 之前函数更新

3.4 React.js 18 之后函数更新

import { useEffect, useState } from "react";

function App() {
const [value, setValue] = useState(0);

useEffect(() => {
setValue(prevValue => prevValue + 1);
console.log(value); // 0
setValue(prevValue => prevValue + 1);
console.log(value); // 0

setTimeout(() => {
setValue(prevValue => prevValue + 2);
console.log(value); // 0
setValue(prevValue => prevValue + 1);
console.log(value); // 0
}, 0);
}, []);

return <div>{value}</div>; // 5
}

export default App;

3.5 React.js 18 之前同步更新

3.6 React.js 18 之后同步更新

可以通过 flushSync 进行同步更新, 多次渲染。flushSync 函数内部的多个 setState 仍然为批量更新,这样可以精准控制哪些不需要的批量更新。因此, 想要同步更新, 多次渲染, 需要将多个 useState dispatch 分开包裹。

function App() {
const [value, setValue] = useState(0);

useEffect(() => {
flushSync(() => setValue(value + 1));
console.log(value); // 0
flushSync(() => setValue(value + 1));
console.log(value); // 0
}, []);

console.log('更新'); // 执行两次
return <div>{value}</div>; // 1
}

export default App;

四、总结沉淀


4.1 useState 实现原理?

useState 允许在函数组件中定义一个状态变量。useState 返回一个数组, 该数组中含有两个元素: 当前状态值和一个更新该状态的函数。useState 可以传入一个初始值或者一个回调函数, 回调函数会得到一个先前状态的参数。useState 与类组件中 this.setState 不同的是, useState 会直接替换之前值, 而不是合并。React 会对 useState 调用进行批量处理已优化性能。

Mount 阶段: 调用 mountState, 创建一个 Hook 对象, 存储在 当前处理的 fiber.memoizedState 中。Hook 对象中也有一个 memoizedState, 用于存储 state 值。 另外还有 nextbaseQueuebaseStateupdateQueue。此时的 Hook.memoizedState 为传入的初始值。随后创建 dispatchAction 函数, 封装了调度更新的逻辑, 里面的主要逻辑为: 确定该更新所属的优先级(即 Lane), 并生成一个 Update 对象, 其中包含了此次更新的 action(可能是新状态值或用于计算状态的函数), 基于 eagerState 策略, 通过 Object.is 比较最新结果与之前结果, 如果状态发生变化, 将该 Update 插入到对应 Hook 的更新队列 Hook.UpdateQueue 中, 调用 scheduleUpdateOnFiber,标记当前 Fiber 需要更新,并由调度器安排后续渲染任务。最后, mountState 返回 Hook.memoizedStatedispatchAction 用于触发更新的函数。为了后续使用解构方便, 返回的数组。

Update 阶段: 从当前的 Fiber 中取出 Hook 链表, 依次循环遍历执行, 执行到 useState 链时, 执行 updateState, 检查 Hook UpdateQueue更新队列, 如果队列中有更新, 从 Hook.baseState 触发, 遍历并处理 UpdateQueue 中的所有更新,根据优先级选择性地应用更新,并合并计算出最终的新状态,同时保留未处理的低优先级更新作为下次更新的基础。最终的计算结果会赋值给组件 FibermemoizedStateupdateState 最终会返回 Hook.memoizedStatedispatchAction 用于触发更新的函数。为了后续使用解构方便, 返回的数组。

4.2 使用 useState 需要注意什么?

  1. 状态更新是异步的, React 的状态更新是异步且会进行批量合并,所以在调用 setState 后,不能立即依赖新的状态值。如果需要基于之前的状态计算新状态,应使用函数式更新

  2. 保持状态不可变, 当状态是对象或数组时,切记不要直接修改原有状态,而是要生成新的副本。这样可以确保 React 能检测到状态变化并触发重新渲染。如果是复杂对象, 嵌套比较深或者是数组, 可以只用 immer 不可变来实现, 节省深拷贝的性能损耗。Immer 实现的原理是: 基于 Proxy 来代理对象的各种操作,然后在数据进行操作时,Proxy 会去拷贝被修改对象,然后再进行数据操作,返回拷贝后并被修改的数据。 当数据被修改时,会返回一个对象,但是新的对象会尽可能的利用之前的数据结构而不会对内存造成浪费, 也就是使用旧数据创建新数据时,要保证旧数据同时可用且不变,同时为了避免 deepCopy 把所有节点都复制一遍带来的性能损耗,Immer 使用了(结构共享)。如果对象树中一个节点发生变化,只修改这个节点和受它影响的父节点,其它节点则进行共享

  3. 初始状态与 props 关系, 初始状态只在组件首次渲染时使用,如果初始值依赖于 props,并且后续 props 可能发生变化,需要注意同步更新状态或者通过 useEffect 进行处理,避免状态不一致。

  4. 避免无限循环渲染, 使用 useState 中的 dispatchAction 函数更新状态时,一定要注意必须在一定的条件下执行,否则容易造成循环渲染

  5. 懒加载计算, 如果初始状态需要进行复杂计算,可以传入一个函数来延迟计算(懒初始化),确保只有在首次渲染时才执行计算。例如:

const [value, setValue] = useState(() => expensiveComputation());

4.3 useState 为什么返回的是数组,而不是一个对象?

答: 因为对象解构需要跟解构的属性名一模一样; 数组解构时,名字随意;

答: const [a,b] = [1,2] 可以更简洁的随意起名,但是 const {a: a1,b:b2} 增加了代码量。

4.4 为什么 setState() 传入两次相同的状态,函数组件不更新?

基于 useState dispatchAction 传入两次相同的状态, dispatchAction 函数, 封装了调度更新的逻辑, 里面的主要逻辑为: 确定该更新所属的优先级(即 Lane), 并生成一个 Update 对象, 其中包含了此次更新的 action(可能是新状态值或用于计算状态的函数), 基于 eagerState 策略, 通过 Object.is 比较最新结果与之前结果, 如果状态发生变化, 将该 Update 插入到对应 Hook 的更新队列 Hook.UpdateQueue 中, 调用 scheduleUpdateOnFiber,标记当前 Fiber 需要更新,并由调度器安排后续渲染任务。如果没有变化, 不做任何处理。

4.5 类组件中的 setState 和函数组件中的 useState 有什么异同?

setState: 调用更新方法触发调度更新。接收传入函数形式, 函数形式接受先前的状态和 props。传入的状态对象会与现有状态进行浅合并。这使得你只需更新状态中发生变化的部分,不需要覆盖整个状态。通过, 接受第二个参数作为更新后的回调函数,便于在状态更新后执行某些操作。

useState: 调用更新方法触发调度更新。接收传入函数形式, 函数形式接受先前的状态, 接受不到之前的 props。更新状态时并不会自动合并原有状态,新状态会完全替换旧状态。因此,如果状态是对象且只更新其中某个属性,需要手动合并。useState 没有提供回调机制。

4.6 useState 的传参方式,有什么区别?

useState() 的传参有两种方式: 纯数据和回调函数。这两者在初始化时,除了传入方式不同,没啥区别。但在调用时,不同的调用方式和所在环境,输出的结果也是不一样的。

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

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

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

上面的两种传入方式,最后得到的 count 结果是不一样的。为什么呢?因为在以数据的格式传参时,这 3 个使用的是同一个 count 变量,数值是一样的。相当于setCount(0 + 1),调用了 3 次; 但以回调函数的传参方式,React 则一般地会直接该回调函数,然后得到最新结果并存储到 React 内部,下次使用时就是最新的了。注意:这个最新值是保存在 React 内部的,外部的 count 并不会马上更新,只有在下次渲染后才会更新。

4.7 多次执行 useState(),会触发多次更新吗?

多次执行 useState, 会将 useState 对应的更新放入批量处理队列, 通过调度器在合适的时机进行调度更新。

批处理是指,当 React 在一个单独的重渲染事件中批量处理多个状态更新以此实现优化性能。如果没有自动批处理的话,我们仅能够在 React 事件处理程序中批量更新。在 React 18 之前,默认情况下 promisesetTimeout、原生应用的事件处理程序以及任何其他事件中的更新都不会被批量处理;但现在,这些更新内容都会被自动批处理:

React.js 18.x 之前, React合成事件生命周期钩子(除 componentDidUpdate 除外) 将会批量处理更新。但是在 Promise 结果setTimeout 或者原生事件处理程序中, 更新将会以同步的方式处理。批量处理原理为: 通过全局变量 executionContext 控制 React 执行上下文,指示 React 开启同步或者异步更新。executionContext 一开始被初始化为 NoContext,因此 React 默认是同步更新的。在合成事件生命周期钩子(除 componentDidUpdate 除外), 中, 一开始这个变量会赋值为一个 BatchedContext, 在此期间, 如果多次同步调用更新函数, 会走批量更新逻辑, 构造更新队列, 将多个更新函数加入到更新队列, 等到当前环境执行完毕后, 将 executionContext 恢复为之前状态, 根据更新队列进行调度更新。如果异步调用更新函数, 此时 executionContext 已经恢复之前状态, 直接开始更新任务。

React.js 18.x 之后, 几乎所有更新, 包括 Promise.thensetTimeout 或者原生事件处理程序等, 都将自动批处理。批量处理逻辑为: 在同一环境中, 无论同步调用还是异步调用, 多次更新函数任务的优先级是相同的, 属于同一批任务。这一批任务都通过 scheduleUpdateOnFiber 标记相同的优先级之后, 开始通过 ensureRootIsScheduled 进行调度更新, 对比上次等待的更新和本次更新的优先级, 如果相等, 则会终止这个这任务的调度流程, 复用已有的调度任务。因此, 多次触发更新只有第一次会进入到调度中

4.8 useState() 的 state 是否可以直接修改?是否可以引起组件渲染?

首先声明,我们不应当直接修改 state 的值,一方面是无法刷新组件(无法将新数据渲染到页面中),再有可能会对下次的更新产生影响。

唯一有影响的,就是后续要使用该变量的地方,会使用到新数据。但若其他 useState() 导致了组件的刷新,刚才变量的值,若是基本类型(比如数字、字符串等),会重置为修改之前的值;若是复杂类型,基于 js 的 对象引用 特性,也会同步修改 React 内部存储的数据,但不会引起视图的变化。

只有使用 useState 提供的 dispatchAction 可以触发调度更新。

4.9 useState 更新状态时,如果对象嵌套过深,会发生更新异常的情况,如何解决呢?

当你使用 useState 管理深层嵌套的对象时,容易遇到更新不生效或意外的问题,主要原因是 React 对状态的比较是基于引用(shallow equality),而深层嵌套对象的更新往往需要逐层创建新的对象引用,否则 React 无法检测到变化。解决方案如下:

  1. 扁平化状态结构: 尽量避免状态嵌套过深,可以将状态拆分成多个独立的 state。这样不仅便于更新,还能让组件逻辑更加清晰。

  2. 手动深拷贝更新: 通过手动深拷贝来更新嵌套对象

  3. 利用不可变更新库: 可以使用类似 Immer 的库, Immer 实现的原理是: 基于 Proxy 来代理对象的各种操作,然后在数据进行操作时,Proxy 会去拷贝被修改对象,然后再进行数据操作,返回拷贝后并被修改的数据。 当数据被修改时,会返回一个对象,但是新的对象会尽可能的利用之前的数据结构而不会对内存造成浪费, 也就是使用旧数据创建新数据时,要保证旧数据同时可用且不变,同时为了避免 deepCopy 把所有节点都复制一遍带来的性能损耗,Immer 使用了(结构共享)。如果对象树中一个节点发生变化,只修改这个节点和受它影响的父节点,其它节点则进行共享