第一章:什么是使用React 钩子 实现 微状态管理

状态管理 是 开发React应用时,最重要的话题之一。一般来说,传统的React状态管理涉及到一个庞大的、统一的为用户提供状态管理能力的库。

但是自React 钩子面世后,这个情况改变了。我们可以使用React自身的钩子进行可复用的状态管理,也可以用这些钩子构建功能更加丰富的模块。这些特性使得状态管理变得更加轻量,换言之,更微小。微状态管理 可以更加 目标导向 而且 可以配合特定编程模式使用,相比之下,宏状态管理 则提供 更加普遍的特性。

在这本书,我们将尝试使用React 钩子实现几种状态管理的模式。我们主要聚焦在全局状态。什么是全局状态呢,就是多个组件可以共享的状态。React的钩子函数已经为我们提供了用于的本地状态的钩子。而 全局状态,是比较麻烦的,因为React 没有直接提供 能够提供全局状态的能力:这项能力由React社区及相关生态库来处理了。我们将会探索一些现存的 微状态管理库,探讨其目的与模式。在这本书,会讨论的库有 Zustand,Jotai,和 Valtio 和 React Tracked。

在这一章,我们会定义什么是 微状态管理,讨论React 钩子如何赋予了 微状态管理能力,以及为什么 全局状态是有争议的。我们将会回顾两个用于微状态管理的基础钩子,并比较其异同。

在这一章,我们会讨论这些主题:

  • 理解微状态管理
  • 使用钩子
  • 探索全局状态
  • 使用useState
  • 使用useReducer
  • 探索useState与useReducer异同

技术要求

为了运行书里的代码,你想要一个React运行环境 ------ 比如说,Create React App(create-react-app.dev) 或者 CodeSandbox (codesandbox.io)。%25E3%2580%2582 "https://codesandbox.io)%E3%80%82")

你需要能够使用React和React 钩子函数。更准确的说,你需要熟悉React的官方文档(reactjs.org/docs/gettin...

我们不会使用类组件。

这一章的代码在GitHub是(github.com/PacktPublis...

理解 微状态管理

什么是 微状态管理?目前还没有官方定义;我们可以试着定义一下。

重要提示:这个定义也许与未来的社区标准不一样。

在React中, 状态 (State)是任何能够代表 用户界面(user interface) 的数据。当状态变化时,React会用新的状态进行重新渲染。

在React钩子面世前,使用 宏状态管理 库是一种流行的方案。为了更好的开发体验,一个状态可能覆盖了很多目的,但有时 宏状态管理 的代码看起来让人觉得多此一举,因为 宏状态管理 有很多 没用的函数。有了钩子,我们有了一个新的途径来创造状态。这让我们可以依据需制作更加定制化的状态。这是一些例子:

  • 表单的状态应该与 全局状态区分开来,另作管理。表单状态是不太适合走单例模式的。
  • 缓存在浏览器的状态会有一些独特特性,如再次获取等。
  • 导航状态有一个特殊的要求,即原始状态位于浏览器端,而且单一状态解决方案不适用。

解决这些问题,正是React 钩子函数存在的意义。React 钩子函数的趋势,便是使用各种方案来处理变化多端的状态。现在已经有很多基于钩子函数的库来处理 状态,服务端缓存状态等。

但是,我们仍然需要全局状态管理方案,因为有些数据并不是被个性化处理等。处理非全局状态的工作的比例,依应用的特性而定。比如说,如果一个应用主要处理服务端的数据,那这个应用只需要一些些全局状态。与之相反,如果一个应用富含大量的图标,那就比处理服务端数据的应用需要更多的 全局状态了。

因此,普遍状态管理方案应该是轻量的,这样开发者可以依据其需求进行选择。这就是我们所说的 微状态管理。微状态管理 的定义: React 中的轻量级状态管理 是各种解决方案都有不同的特点,开发者可以根据应用程序的需求从多种可能的解决方案中选择合适的 的 状态管理方案。

微状态管理 还有 一些要求,来满足开发者的需求。这是 微状态管理的基本要求:

  • 阅读状态
  • 更新状态
  • 使用状态进行渲染

当然,还有额外要求:

  • 优化重新渲染
  • 与其他系统进行交互
  • 支持异步操作
  • 衍生状态
  • 简单的语法

然而,我们并不需要满足所有的特性。因此,一个微状态管理方案不能成为一个单独的方案,针对不同的需求会有不同的方案。

另一个关于 微状态管理的话题,便是学习曲线。对于状态管理方案来说,平缓的学习曲线是很重要的,但是微状态管理覆盖的用例会更小,所以应当更加容易学习。一个 更容易的学习曲线,会带来更好的开发体验和 更高的效率。

在这个部分,我们会讨论什么是 微状态管理。我们将会看看几个处理状态的钩子。

使用钩子

React的钩子函数是 微状态管理 的重要组成。React 提供了一些能够处理状态的基础钩子:

  • useState是生成状态的基础钩子。感谢React钩子的可组合性,我们可以通过使用useState构造出性能各异的自定义钩子。
  • useReducer也可以生成本地状态。后面我们会比对 useState 和 useReducer 二者的差异。
  • useEffect钩子允许我们在React渲染过程外,运行一些逻辑。为全局状态进行状态管理是非常重要的,因为这样我们就可以结合React组件的生命周期来实现一些特性。

React Hooks 的创新之处在于它们允许你从 UI 组件中抽取逻辑。比如,下面的例子是用useState实现的一个简单的计数器:

js 复制代码
const Component = () => {
    const [count, setCount] = useState(0);
    return (
        <div>
            {count}
            <button onClick={ (c) => c + 1}> +1
            </button>
        </div>
    )
}

然后,让我们看看如何提取逻辑。我们可以自定义一个钩子,专门用来计数。

js 复制代码
const useCount = () => {
    const [count, setCount] = useState(0);
    return [count, sdtCount]
}
js 复制代码
const Component = () => {
    const [count, setCount] = uesCount(0);
    return (
        <div>
            {count}
            <button onClick={ (c) => c + 1}> +1
            </button>
        </div>
    )
}

代码这样改造看起来也许没什么改动,你们也许还觉得很多地方都是一样的。然而,这个改动有两点值得注意:

  • 我们有了一个更加清晰的命名 -useCount。
  • 组件的实现与 useCount的实现是互相独立的。

第一点对编码来说是很重要的。如果我们提示了变量命名的语义性,则提升了代码的可读性。除了useCount,你可以把它命名为 useScore,usePercentage,或者 usePrice。虽然它们的实现逻辑是一样的,但是当我们看到命名不同时,就会把它当作不同的钩子。命名是非常重要的。

第二点也是很重要的,尤其是与 微状态管理库相关时。因为useCount是从组件中提取出来的,我们可以在不破坏组件的前提下添加这个功能。

比如,我们在控制台打印count,我们可以这么写代码:

js 复制代码
const useCount = () => {
    const [count, setCount] = useState(0);
    useEffect(() => {
        console.log('count is changed to', count);
    }, [count])
    return [count, sdtCount]
}

通过修改useCount,我们可以为这个钩子添加打日志功能。如此一来,我们就不用改组件的代码了。这就是把逻辑提取为钩子的好处。

我们还可以为这个钩子添加功能。假设我们不希望count被随意添加,而只是一个一个地递增,我们可以这样做:

js 复制代码
const useCount = () => {
    const [count, setCount] = useState(0);
    const inc = () => setCount((c) => c +1 );
    return [count, inc];
}

这为React 生态系统提供了构造用于各种目的自定义钩子的能力。这些自定义钩子,也许封装了一个添加功能,或者封装了更加庞大的工作。

在Npm 和 GitHub上,你会发现很多自定义钩子库。

我们还会讨论 suspense和并发更新,因为React 钩子函数就是被设计来处理这些问题的。

获取数据时的Suspense 与 并发更新

获取数据时的Suspense 与 并发更新 目前 还没有被 React实现,但是还是要知道它们

获取数据时的Suspense 是一个让你在写组件时无需为异步更新而烦恼的机制。

并发更新 是把渲染过程切分成更小的块,以避免阻塞CPU太久的机制。

React 钩子函数是要处理这些问题的,但是,你也应该避免误用它们。

要遵守的第一个规则,是你应该避免更改已经存在的state对象 或者 ref对象。如果更改过于频繁,或引发一些不可预期的行为:未按照预期重新渲染,过多的重新渲染,以及部分的重新渲染(一些不该重新渲染的组件被重新渲染了)。

要遵守的第二个规则,钩子函数和组件函数需要是尽可能的"纯"函数,因为钩子函数和组件函数会被多次触发。

但是,人们经常会违反这两个规则。遵守这两个规则很难落实,因为即使你违反了这两个原则,你的代码在非并发更新的模式下依然运行。所以,人们会忽视已经误用了。甚至在并发更新模式下,这样也能运行。所以很多新手,并没有意识到已经违反了这个规则。

如果你对这些概念不熟悉,那最好还是使用已经成熟的 微状态管理库。

在这个部分,我们会回顾一些基础的React钩子函数,并理解一些基础概念。开始吧,我们要探讨全局状态了。

探索全局状态

React提供了像useState这样的原生钩子,它们在组件内被定义,并在组件树内被消费。

js 复制代码
const Component = () => {
    const [state, setState] = useState();
    
    return (
        <div>
            {JSON.stringify(state)}
            <Child state={state} setState={setState} / >
        </div>
    )
}
js 复制代码
const Child = ({ state, setState}) => {
    const setFoo = () =>  setState(
        (prev) => ({ ...prev, foo: 'foo' })
    );
    
    return (
        <div>
            {JSON.stringify(state)}
            <Child state={state} setState={setState} / >
        </div>
    )
}

然而,全局状态是会在多个地方被消费的,很有可能会离被定义处很远。一个全局状态并不一定要走单例模式,我们称呼全局状态为共享状态,以区别开单例模式。

下面这个例子,就是定义React全局状态的例子:

js 复制代码
const Component1 = () => {
    const [state, setState] = useGlobalState();
    
    return (
        <div>
            {JSON.stringify(state)}
        </div>
    )
}

const Component2 = () => {
    const [state, setState] = useGlobalState();
    
    return (
        <div>
            {JSON.stringify(state)}
        </div>
    )
}

因为我们没有定义useGlobalState,这段代码并不会运行。这个例子中,我们希望 Component1 和 Component2 共享相同的状态。

在 React 中实现全局状态并非易事。这主要是因为 React 基于组件模型。在组件模型中,局部性很关键,这意味着一个组件应该是隔离的且可复用。

React并没有提供直接的全局状态管理方案,这些都是由开发者和社区实现的。已经有很多方案被实现了,而每个方案各有其优点和缺点。而在后面几章,我们会讨论每个方案的优点与缺点:

  • 第三章,使用Context共享组件状态
  • 第四章,使用订阅共享模块状态
  • 第五章,使用Context和订阅共享组件状态

在这个部分,我们已经学习了用React钩子实现的全局状态会是什么样的。接下来,我们会学习useState的基本用法,以为后面的掌机做准备。

使用useState

在这个部分,我们会学习如何使用useState,从useState的初级用法到高级用法。我们会从一个最简单的表单开始。我们会在这个表单用值来更新状态,然后用函数来更新状态。用函数更新状态是很强的一个特性,之后,我们会讨论懒初始化。

使用一个值来更新状态

我们可以为useState传入一个新值来更新状态。你只要在useState传入一个值,就可以替代原来的值了。

这是相关的例子:

js 复制代码
const Component = () => {
    const [count, setCount] = useState(0);
    
    return (
        <div>
            {count}
            <button onClick={ () => setCount(1)}>
                Set Count to 1
            </button> 
        </div>
    )
}

当你点击setCount时,count就为1了。

如果你再次点击这个按钮时,会发生什么?它会再次调用setCount(1),但状态的值是一样。但由于传入的值和之前一样,所以它会 "跳出" 这个过程,组件不会重新渲染。"跳出(Bailout)" 是 React 中的一个专业术语,其基本含义是避免触发重新渲染。

让我们看看另一个例子:

js 复制代码
const Component = () => {
    const [state, setState] = useState({ count: 0 });
    
    return (
        <div>
            {state.count}
            <button onClick={ () => setCount({ count: 1 })}>
                Set Count to 1
            </button> 
        </div>
    )
}

你一次点击时,页面的表现是一样的;然而,如果你再次点击,这个组件就会重新渲染。你看不到屏幕上的变化,因为count并没有变。会重新渲染,是因为第二次点击时,又重新创造了一个新的对象{ count: 1 }, 而这个对象和之前的对象不同。

所以,有了这样的一个糟糕实践:

js 复制代码
const Component = () => {
    const [state, setState] = useState({ count: 0 });
    
    return (
        <div>
            {state.count}
            <button onClick={ () => { state.count = 1, setState(state)} }>
                Set Count to 1
            </button> 
        </div>
    )
}

这对代码,无法按照预期运行。即使你点击了按钮,它不会重新渲染。这是因为这个对象的引用没有发生变化,这会"跳出",这个本身也不会触发重新渲染。

最后,我们来看一个有趣的更新方式:

js 复制代码
const Component = () => {
    const [count, setCount] = useState(0);
    
    return (
        <div>
            {count}
            <button onClick={ () => setCount(count + 1)}>
                Set Count to 1
            </button> 
        </div>
    )
}

当你点击时,count确实会递增;然而,如果你连续点击过快,它会只递增一次。要解决这个问题的话,需要一个更新函数。

使用函数来更新一个状态

另一个用useState来更新状态的方法是使用函数。

这是一个例子:

js 复制代码
const Component = () => {
    const [count, setCount] = useState(0);
    
    return (
        <div>
            {count}
            <button onClick={ () => setCount((c) => c + 1)}>
                Set Count to 1
            </button> 
        </div>
    )
}

这种写法,可以确保点击多少次,就增加多少次。这是因为,(c) => c + 1 是被序列化触发的。正如我们在上一部分中所看到的,值的更新与 "将计数设置为 {count + 1}" 这一功能有着相同的应用场景。在大多数应用场景中,如果更新是基于先前的值,那么函数式更新的效果会更好。"将计数设置为 {count + 1}" 这一功能实际上意味着它并不依赖于先前的值,而是依赖于所显示的值。

但是,使用函数式更新时,也有可能发生:

js 复制代码
const Component = () => {
    const [count, setCount] = useState(0);
    
    useEffect(() => {
        const id = setInterval(
            () => setCount((c) => c + 1),
            1000
        );
        return () => clearInterval(id);
    }, []);
    
    return (
        <div>
            {count}
            <button
                onClick={() =>
                    setCount((c) => c % 2 === 0 ? c : c +1)}
            >
                Increment Count if it makes the result even
            </button>
        </div>
    )
}

如果更新函数返回的状态与之前的状态完全相同,那么它就会进行 "跳过" 操作,并且这个组件不会重新渲染。例如,如果你调用 setCount((c) => c) ,那么该组件将永远不会重新渲染。

懒初始化

useState可以接受一个函数,作为第一次渲染时的初始化函数,我们可以这么做:

js 复制代码
const init = () => 0;

const Component = () => {
    const [count, setCount] = useState(init);
    
    return (
        <div>
            {count}
            <button onClick={ () => setCount((c) => c + 1)}>
                Set Count to 1
            </button> 
        </div>
    )
}

init函数的例子,看起来不是很显著,因为返回一个0并不需要太多计算,但懒初始化的重点在于init 函数可以进行非常复杂的计算,而且只在 获取初始值时调用。这个 init 函数是被 懒 执行的,不会在 调用 useState前使用;换句话说,它只会在挂载的那一次被调用。

我们已经学习了如何使用useState;下一个是useReducer。

使用useReducer

在这个部分,我们会学习如何使用useReducer。我们会学到其典型用法,如何产生跳出,如何使用原始值,以及懒初始化。

典型用法:

在处理复杂状态时,使用reducer是很好的。这是一个多属性对象的例子:

javascript 复制代码
const reducer = (state, action) => {
    switch (action.type) {
        case 'INCREMENT':
            return { ...state, count: state.count + 1 };
        case 'SET_TEXT':
            return { ...state, text: action.text };
        default:
            throw new Error('unknown action type');
    }
}

const Component = () => {
    const [state, dispatch] = useReducer(
        reducer,
        { count: 0, text: 'hi' },
    );
    
    return (
        <div>
            {state.count}
            <button
                oncClick={() => dispatch({ type: 'INCREMENT' })}
            >
                Increment count
            </button>
            <input
                value={state.text}
                onChange={(e) =>
                    dispatch({ type: 'SET_TEXT', text: e.target.value })
                }
            />
        </div>
    )
}

useReducer允许我们提前定义一个reducer函数,在这个reducer函数内,要定义好操作类型和初始值。在useReducer钩子外定义一个reducer的好处在于,这样便于分割代码和测试。因为reducer函数是一个纯函数,它是更加容易测试的。

跳出问题:

正如useState一样,useReducer也有跳出问题。用刚刚的例子,让reducer可以处理action.text为空的场景,会出现跳出问题:

js 复制代码
const reducer = (state, action) => {
    switch (action.type) {
        case 'INCREMENT':
            return { ...state, count: state.count + 1 };
        case 'SET_TEXT':
            if (!action.text) {
                // bail out
                return state
            }
            return { ...state, text: action.text };
        default:
            throw new Error('unknown action type');
    }
};

注意,返回的 state本身很重要。如果你返回的是 { ...state, text: action.text || state.text },就不会有跳出问题了,因为它返回的是一个新对象。

原始值

useReducer可以处理原始值,例如字符串、数字这些。useReducer + 原始值依然是有用的,因为我们可以用它定义复杂的逻辑,处理reducer外的状态。

下面是一个处理原始值的reducer:

js 复制代码
const reducer = (count, delta) => {
    if (delta < 0) {
        throw new Error('delta cannot be negative');
    }

    if (delta > 10) {
        // too big, just ignore
        return count
    }

    if (count < 100) {
        // add bonus
        return count + delta + 10
    }
    return count + delta
}

注意,action参数在这里并不是对象。在这个例子中,state是一个原始值,但其中的逻辑复杂一些,因为有很多条件要处理。

懒初始化

useReducer有两个必填参数。第一个参数是reducer函数,第二个是初始值。它含有第三个、选填的参数,我们称他为用于懒初始化的init 参数。

我们可以看看下面这个例子:

js 复制代码
const init = (count) => ({ count, text: 'hi' });

const reducer = (state, action) => {
    switch (action.type) {
        case 'INCREMENT':
            return { ...state, count: state.count + 1 };
        case 'SET_TEXT':
            return { ...state, text: action.text };
        default:
            throw new Error('unknown action type');
    }
};

return (
    <div>
        {state.count}
        <button
            onClick={() => dispatch({ type: 'INCREMENT' })}
        >
            Increment count
        </button>
        <input
            value={state.text}
            onChange={(e) => dispatch({ 
            type: 'SET_TEXT', 
            text: e.target.value,
            })}
        />
    </div>
    );
 };

这个init函数,之后在被挂载那一次被触发,所以它可以进行复杂的计算。不像useState,useReducer的init函数可以接收useReducer的第二个参数--initialArg,在刚刚的例子中就是0.

目前为止,我们在分别地学习useState和useReducer,现在是时候比较他们二者了。

比较useState和useReducer的异同

在这个部分,我们要比较这二者的异同

用useReducer来实现useState

完全可以用 useReducer 来实现 useState 的功能。实际上,众所周知,在 React 内部,useState 就是通过 useReducer 来实现的。

下面这个例子,就是展示如何用useReducer来实现useState:

js 复制代码
const useState = (initialState) => {
    const [sate, dispatch] = useReducer(
        (prev, action) => 
            typeof action === 'function' ? action(prev): action,
        initialState
    );
    return [state, dispatch];
}

这段代码可以简化成下面这样:

js 复制代码
const reducer (prev, action) =>
    typeof action === 'function' ? action(prev) : prev;
 
const useState = (initialState) =>
    useReducer(reducer, initialState)

如此一来,我们已经证明了我们可以使用useReducer来实现useState。所以当你需要使用useState时,你可以使用useReducer来代替他。

用useState来实现useReducer

用useState来实现useReducer也是可以的:

js 复制代码
const useReducer = (reducer, initialState) => {
    const [state, setState] = useState(initialState);
    const dispatch = (action) => 
        setState(prev => reducer(prev, action));
    return [state, dispatch];
}

除了这些基本能力外,我们还可以实现懒初始化。让我们借助useCallback的能力,来创建稳定的dispatch:

js 复制代码
const useReducer = (reducer, initialArg, init) => {
    const [state, setState] = useState(
        init ? ()=> init(initialArg) : initialArg,
    );
    
    const dispatch = useCalback(
        (action) => setState(prev => reducer(prev, action)),
        [reducer]
    );
    
    return [state, dispatch];
}

这样一个useReducer几乎可以覆盖你的大部分用例。

但是这两个钩子之间还是有希望区别的,在下一个部分,我们要讨论这两个钩子的区别。

使用init函数

第一个不同是,在使用useReducer时,我们可以把reducer和init函数定义在钩子或者组件之外,而useState不能。

这是一个例子:

js 复制代码
const init = (count) => ({ count });
const reducer = (prev, delta) => prev + delta;

const ComponentWithUseReducer = ({ initialCount }) => {
    const [state, dispatch] = useReducer(
        reducer,
        initialCount,
        init
    )
    
    return (
        <div>
            {state}
            <button onClick={() => dispatch(1)}> +1</button>
        </div>
    )
}

const ComponentWithUseState = ({ initialCount }) => {
    const [state, setState] = useState(() => init(initalCount));
    
    const dispatch = (delta) => setState((prev) => redcuer, delta);
    
    return [state, dispatch];
}

如你所见,在ComponentWithUseState中,useState需要两个行内函数,而ComponentWithUseReducer不需要。

使用行内reducer

行内函数可能依赖外部变量。这个只能发生在useReducer中国,而不可能在useState。这是useReducer部分的能力。

所以,这个例子技术上是可行的:

js 复制代码
const useScore = (bonus) =>
    useReducer((prev, delta) => prev + delta + bonus, 0);

这段代码可以良好运行,即使在bonus和delta都更新时。

在对 useState 进行模拟实现的情况下,这并不能正确地工作。它会在之前的一次渲染中使用旧的 bonus值。这是因为 useReducer 会在渲染阶段调用 reducer函数。

如前所述,这种情况通常不会被用到,所以总的来说,如果我们忽略这种特殊行为,我们可以认为 useReducer和useState 基本上是一样的,并且可以相互替换。你只需要根据自己的偏好或者编程风格来选。

概要

在这一章节,我们讨论并定义了微状态管理,以及React的钩子函数如何发挥作用。为了给阅读后面几章打基础,我们还学习了可以进行微状态管理的钩子 useState 和 useReducer。

在下一章,我们将学习全局状态。为了理解全局状态,我们会讨论本地状态,本地状态的工作过程,之后,我们会讨论何时需要全局状态。

相关推荐
mldong2 小时前
你的 Vue3 项目也能有钉钉同款审批流设计器:npm 装包,10 分钟画出第一条审批流
前端·vue.js
2分钟速写快排2 小时前
什么是 RAG?如何用 RAG 实现一个用户记忆?
前端·后端·ai编程
passerby60613 小时前
如何自己造一个时间处理库
前端·javascript·github
走到天涯海角4 小时前
react里面的长列表渲染优化
前端·react.js·前端框架
小羊没烦恼!4 小时前
Hello Web API系列教程——Web API与国际化
java·服务器·前端·javascript·php
北岛贰4 小时前
迷茫焦虑期,我做了一个带支付带官网的 AI 聊天虚拟恋人 App
前端·人工智能·后端
mayaairi6 小时前
Vue2 组件通讯(三):全局事件总线、PubSub、插槽与组件实例属性
前端·javascript·vue.js
kyriewen6 小时前
面试官问我:AI 都能写代码了,前端凭什么还值 25K
前端·javascript·人工智能
风骏时光牛马7 小时前
AI源码分析:拆解模型底层实现逻辑
前端
BillKu7 小时前
全局样式变量:CSS 自定义属性(--border-color)和SCSS 变量($border-color)的说明
javascript·css·scss