前言
状态(State)是 React 中最为重要的概念之一,可以说是 React 的核心 🏆。React 之所以被称为 React,是因为"React reacts to state changes by re-rendering the UI"。在 React 中,所有的事情都围绕着状态展开,因此每个 React 开发者都必须熟练掌握状态的概念和使用。本文通过回答 5 个问题来详细介绍关于状态的内容,以对 React 中的状态有更深入的理解:
- What is State?
- What type of state we need?
- When to use state?
- Where to place state?
- How to manage state?
1、What is State?
当一个组件需要保存自己的数据,并且希望在整个生命周期中一直保留这些数据时,React 提供了一种解决方案,那就是使用状态(State)。状态是组件中用于存储和管理数据的机制。它可以包含简单的内容,例如输入框的文本,也可以是复杂的数据结构,比如一个完整的表单。React 是一种声明式的编程模型,我们不直接操作 DOM,而是通过操作状态来更新视图,当我们更新组件的状态时,React 会重新渲染该组件,从而触发视图的更新。状态在 React 中起到了保持用户界面与数据同步的重要作用,用户界面的变化和发展正是由状态随着时间的推移而变化得到的。总结而言,状态让我们能够完成两件重要的事情:
- 状态允许我们在组件中保存和管理数据,使组件在不同的时间点保持一致的数据状态;
- 通过更新状态,我们可以触发组件的重新渲染,从而实现视图的更新;
2、What type of state we need?
👉 根据状态可访问性可以分为两种,1️⃣ 局部状态 Local State 和 2️⃣ 全局状态 Global State:
- 局部状态,由某个组件创建且只有该组件可以访问,但是可以通过 props 向子组件传递;
- 全局状态,整个应用程序中所有组件都能访问,会被各种处于不同层级的组件所使用;
不要过早考虑全局状态,始终从本地状态开始,只有当真正需要时才使用它!
👉 从状态域来说状态可以分为两种类型,1️⃣ UI 状态 UI State 和 2️⃣ 远程状态 Remote State:
- 远程状态,它是存在于服务器上的状态,我们从远程服务器中请求得到,这是异步的,数据也可能经常需要重新获取和更新;
- UI 状态,除远程状态之外的都是 UI 状态,用于展现视图的内容,其通常是同步的并且存储在应用中,不与服务器进行交互;
3、When to use state?
在组件中,并不是每个数据都需要作为状态。事实上,我们应该谨慎判断何时使用状态,以避免不必要的重新渲染和潜在的性能问题:
- 如果数据在某个时刻不会发生改变,那么我们可以将其作为"常规变量"存储,例如使用 const 来声明数据;
- 如果我们的数据可以通过已有的状态或属性进行计算或加工,那么我们可以将其视为"派生状态",避免重复存储相似的数据;
- 如果我们的数据发生改变时,并不需要触发组件的重新渲染来更新视图,那么我们可以使用"Ref(useRef)";
- 当上述条件均不满足时,我们就需要使用状态了 🏆
派生状态
JSX
const [cart, setCart] = useState([
{ name: 'bag', price: 10 },
{ name: 'shirt', price: 20 },
]);
// 派生状态
const numItems = cart.length;
const totalPrice = cart.reduce((acc, cur) => acc + cur.price, 0);
所谓的派生状态(Derive State)指的是通过对已有的状态(State)或属性(Props)进行计算而得到的值。当一些值可以直接从状态或属性中计算而来时,就没有必要创建额外状态,只需使用常规变量来存储即可。派生状态和原始状态会保持同步,当状态发生改变时,组件会重新渲染,并重新调用相关函数,派生状态将会重新计算得到。通过这种方式,可以减少状态的数量,从而减少了一些不必要的重新渲染。这种方式能够使代码更加简洁、可读性更强,并提高了应用程序的可维护性。
useRef
JSX
const myRef = useRef(0);
// 设置 ref
myRef.current = 1;
// 读取 ref
console.log(my.current);
在 React 中,我们可以使用 useRef 钩子来创建一个称为 Ref 的对象。那么,什么是 Ref 呢?Ref 提供了一个名为 .current 的可变属性,用于存储和读取数据。从本质上讲,它像是一个盒子,我们可以将任何需要在渲染期间保留的数据放入其中。与普通变量不同,Ref 在渲染时不会被重置,而是保留其之前的值。然而,需要注意的是,当 Ref 的值发生变化时,并不会导致组件重新渲染。因此,Ref 通常只在事件处理程序或副作用(effect)中使用。如果需要写在 JSX 中作为视图所对应的数据,那你应该使用状态,而不是 Ref(当然,除非你需要获得 DOM 元素)。以下列举两个 Ref 非常常见的使用场景:
- 创建渲染之间保持不变的变量,例如 previous state、setTimeout id 等;
- 选择和存储 DOM 元素(最常使用!)
JSX
// Ref 选择和存储 DOM 元素示例
function RefDemo() {
const inputEl = useRef<null | HTMLInputElement>(null);
return <input ref={inputRef} />;
}
4、Where to place state?
当确定需要使用状态来存储数据时,我们需要思考下一个问题:"状态应该存储在哪里?":
- 当状态数据仅在当前组件内使用时,可以将状态存储在当前组件中,这样的状态仅在当前组件的生命周期内可见和可用;
- 当状态数据不仅在当前组件使用,还需要传递给子组件时,可以通过 Props 传递给子组件,这样子组件可以访问和使用父组件的状态数据;
- 当组件及其少数几个相邻组件需要共享状态数据时,可以将状态提升至它们的最近共同父组件中,并通过 Props 向下传递给子组件;
- 当上述条件不适用时(例如,数据需要被位于不同层级的组件使用),我们可以考虑使用全局状态或浏览器存储。
具体来讲,我们主要有 6 种选择来存储状态:
- Local state
- Lifting up state
- Global state (preferably UI state)
- Global state (remote or UI state)
- Global state (passing between pages)
- Storing data in user's browser
状态提升
假设组件 A 包含了组件 B 和组件 C 两个子组件,并且存在一项状态数据,这个状态数据需要被组件 B 和组件 C 同时使用。在 React 中,数据流是单向的,只能从父组件向子组件进行传递,而不能在同级组件之间进行传递。这时候,我们需要使用"状态提升🚀"的技术,将这个状态放置在离组件 B 和组件 C 最近的共同父组件上,并通过 Props 向下传递,直至需要使用这个状态的组件。通常情况下,我们不建议在状态提升的过程中超过 3 个层级。
此外,我们知道 Props 是只读的,子组件无法直接修改父组件传递过来的 Props。那么,如果子组件需要更改这个 Props 应该怎么处理呢?解决方法是将更新状态的方法同步传递给子组件,子组件可以通过调用这个方法来请求更新状态。这种数据流方式也被称为逆向数据流(Child-to-parent communication / inverse data flow)。
5、How to manage state?
在"2、What type of state we need?"中我们介绍了状态可以分为局部/全局状态和远程/UI状态,根据状态分类,如上图所示,不同的状态有着不同的状态管理方案,以下简单介绍最常使用的几种类型,第三方库内容不再展开,查看官方文档即可。
useState
JSX
import { useState } from 'react';
// Creating state
const [state, setState] = useState(0);
const [state, setState] = useState(() => {
return localStorage.getItem('state');
});
// Updating state
setState(1);
setState((c) => c + 1);
使用 State 最常用也是最简单的方法是通过 useState hook,其主要包含两部分内容:
- 创建 State:如上示例,我们可以直接设置值创建 State,也可以基于回调函数创建 State,回调函数必须是纯函数且不允许有入参,这个回调函数只会在初始化渲染的时候被调用,后续的重新渲染不会被调用,当初始值依赖于某些计算时我们就可以使用回调函数的方法;
- 更新 State:如上示例,我们可以直接在 useState 返回的 setter 函数中传递一个值作为下一个 State 值,而当我们需要根据当前 State 值来更新State 值时,我们需要给 setter 函数传递一个回调函数,回调函数的第一个入参就是当前 State 值,返回值为下一个 State 值;
注意,当 State 为数组或对象时,不要直接更新这个数组或对象,而是创建一个新的数组或对象来代替当前值,不然不会发生变更!!!
JSX
function Demo() {
const [state, setState] = useState([0]);
const [state2, setState2] = useState({ a: 1 });
const handleClick = () => {
// 以下都是无效变更!
setState((c) => {
c.push(2);
return c;
});
setState2((c) => {
c.a = 2;
return c;
});
};
return (
<div>
<div> {JSON.stringify(state)}-{JSON.stringify(state2)} </div>
<button onClick={handleClick}>click</button>
</div>
);
}
useReducer
一般情况下使用 useState 足够管理状态,但随着组件和状态更新变得越来越复杂,使用 useState 来管理所有状态是不够的,这个时候使用 useReducer 不失为一种选择,例如以下场景:
- 当组件具有大量的状态变量和状态更新,并且这些状态变量和状态更新分散在组件的许多事件处理程序中时;
- 当需要同时进行多个状态更新,且是作为对同一事件的反应时;
- 当更新一个状态依赖于一个或多个其他状态时;
JSX
// 最重要!!!reducer 函数包含了更新状态的所有逻辑,且不含任何副作用。它实现了状态逻辑与组件的解耦。reducer 函数的参数 action 描述了如何更新状态,通常包含一个动作类型和一个有效载荷(一般为输入数据)
function reducer(state, action) {
switch(action.type) {
case 'dec':
return state - 1;
case 'inc':
return state + 1;
case 'updateDay':
return action.payload;
default:
throw new Error('Unknown');
}
}
const initalState = 0;
// state 存储了一组相关联的状态,initState 是初始值
const [state, dispatch] = useReducer(reducer, initalState);
// dispatch 用于触发状态更新,将一个操作从事件处理程序分派给 reducer 来更新状态。
dispatch({ type: 'dec' });
dispatch({ type: 'inc' });
dispatch({ type: 'updateDay', payload: 10 });
当需要更新组件的状态时,我们可以通过调用从 useReducer 钩子函数中获取的 dispatch 函数来实现。dispatch 函数会发送一个 action 给 reducer,这个 action 包含了指示 reducer 如何更新状态的信息。在 reducer 函数中,它会将当前状态和接收到的 action 结合起来,生成一个全新的状态。当状态更新时,组件实例会触发重新渲染,从而更新视图。整个过程可以类比于数组的 reduce 方法,采用了一种累积的思想。在每次调用 reducer 函数时,它会根据当前的状态和 action 来生成新的状态,而不是直接修改原有的状态。通过使用 useReducer,我们可以更好地管理和更新组件的状态。它提供了一种可预测和一致的状态更新方式,将状态逻辑与组件的其余部分解耦,使代码更易于维护和理解。
useReducer 简单来说是一组 useState,那么应该如何选择使用 useState 还是 useReducer 呢?
- 如果我们只需要一个状态,那毋庸置疑只需要使用 useState;
- 如果有一组状态经常需要一起更新,那可以考虑使用 useReducer 一起管理这一组状态;
- 如果超过三四个状态是相关联的,并且存在 Objects,也可以考虑使用 useReducer;
- 如果我们觉得太多事件处理程序使得组件大而复杂,可以考虑使用 useReducer;
- 否则,我们就使用 useState 即可,注意,useState 应该始终作为管理状态的首先,不要过早考虑 useReducer!
Context API
useReducer 用于处理一组状态更新的场景,而 Context API 则用于解决状态的另一个问题,"PROP DRILLING"。当我们需要将状态传递到嵌套较深的子组件中时,通过 Props 会一层一层往下传递,同时可能中间组件根本不需要使用这个状态,但为了传递状态依旧需要加入这个 Props,这个对状态管理来说无疑增加了非常大的成本。
JSX
/**
* 创建 Context
*/
import { createContext, useState, useContext } from "react";
const MyContext = createContext(null);
// Provider 是特殊的响应组件,允许所有子组件来访问 context 中的值
export function ContextProvider({ children }: { children: React.ReactNode }) {
const [name, setName] = useState("sherwin");
// value 是我们需要提供进行广播的状态数据
return <MyContext.Provider value={{ name, setName }}>{children}</MyContext.Provider>;
}
export function useMyContext() {
const context = useContext(MyContext);
if (context === undefined) throw new Error("Context was used outside the Provider");
return context;
}
JSX
/**
* 使用 Context
*/
import { ContextProvider, useMyContext } from 'XXX';
function ChildrenComponent() {
// 通过 useContext 来消费所提供的上下文值
const { name } = useMyContext();
return <div>{name}</div>;
}
export default function ExampleComponent() {
return (
<ContextProvider>
<ChildrenComponent></ChildrenComponent>
</ContextProvider>
);
}
Context API 是一种可以在整个应用程序中传递数据的解决方案,它允许组件树中的各个组件读取状态,而无需逐层传递数据。实质上,Context API 将全局状态广播到使用上下文的所有组件。需要注意的是,这个全局状态只对使用同一个上下文的组件可见,对于上下文外层的组件来说是无法获取的。使用 Context API,我们可以避免通过 Props 层层传递数据的繁琐过程。相反,我们可以将需要共享的数据放入上下文中,然后在需要访问该数据的组件中直接读取。这样可以简化组件之间的通信,提高代码的可读性和可维护性。另外,当上下文的值发生改变时,所有订阅该上下文的组件都会触发重新渲染。这意味着当共享的上下文值发生变化时,相关的组件会相应地更新其视图以反映最新的数据。
URL
URL 是一个极好的存储状态(尤其是 UI 状态)的位置,比如表格筛选信息、展示模块信息等内容。在开发中,结合 React-Router 库,URL 成为了一个不可忽视的状态管理方案。通过将状态放置在 URL 上,我们可以将其变为全局状态,使应用中的所有组件都能轻松访问到。同时,这也是一种将页面数据传递到另一个页面的方法。此外,当我们将页面添加为书签或分享时,可以保留当时的状态,以便打开页面时恢复到相应的状态。通过将状态编码到 URL 中,我们可以实现可持久化的状态管理。用户可以通过直接修改 URL 或通过页面导航来改变应用的状态。这种方式使得用户可以轻松地分享特定状态的页面,并且在打开链接时能够保留原始的状态。使用 URL 作为状态的存储位置有助于简化组件间的通信和数据传递,同时提供了一种可靠的方法来保存和恢复应用的状态。React-Router 库提供了强大的路由功能,可以帮助我们管理和解析 URL 中的状态信息,并将其传递给相应的组件。
总结
状态对于 React 的重要性不言而喻,本文从什么是状态、状态的类型、什么时候以及什么地方去使用状态、如何使用和管理状态等各方面详细介绍了状态相关的内容,构建 React 应用主要就是围绕状态来进行思考,总而言之,React 开发者的视角不再是基于传统的 DOM 元素,而是思考状态随着时间的转换而发生的变化:
- Step1: 将所需构建的内容分解成组件并形成组件树
- Step2: 构建一个不包含状态的静态版本页面/组件
- Step3: 开始思考状态 👉 什么时候去使用状态?使用什么类型的 State?State 应该放置在哪里?用什么方式管理状态?......
- Step4: 建立状态数据流 👉 One-way data flow、Child-to-parent communication 或 Accessing global state 等。