React状态管理 | 青训营笔记

128 阅读16分钟

思考:

  • 为什么不能用 window 对象?

    window.color ="green"

    window.theme = "York"

    • 全局污染,重复声明
    • 直接取值赋值,数据变更不清晰
    • 渲染粒度不可控
    • 无法进行事件旅行
    • ...
  • react hooks 给状态管理库的设计带来了哪些新思路?

1 什么是状态管理

从React诞生之后,前端组件化的方案深入人心,React遵循的是单向数据流的原则,属性通过 Props 上而下的传递。当页面的比较简单,组件之间的层级关系比较浅时,这种自上而下的单向数据流的方式是不会有问题的。如果页面一复杂,组件的嵌套层级一深,这种单向数据流的传递方式,将会使你陷入到“嵌套地狱”。

状态管理本身,解决的就是这种“嵌套”地狱的问题,解决的是跨层级组件之间的数据通信和状态共享

状态管理工具的本质:管理共享内存中的状态。

1.共享内存

2.管理状态

3.页面通信

4.组件通信

5.刷新失效?

详细定义: 单页应用的各个组件本身是共享内存的,如果将状态保存在内存中,就可以读写统一内存中的变量,从而达到状态共享的目的。

为什么 React 有这么多状态管理工具?

  • Vue: Vuex(Pinia)
  • Angular:Service和Rxjs
  • React: Flux、Redux、Mobx、Rxjs、RecoilJotai、Zustand
  • 跟不同前端框架的定义有关,Vue和Angular双向数据绑定,计算属性等,数据是响应式的. 控制视图刷新,拥有计算属性等,这些使得Vue和Anaular需要状态管理的场景减少,此外其 本身就包含了完整的状态管理工具,比如Vue的Vuex和Pinia,Anaular的Service(RXis)等,从官方定调。
  • React不一样,React是一个纯UI层的前端框架,Ul=fn(state),React将状态的变动完全交给开发者。

一言以蔽之:React 是一个纯UI 层的前端框架 状态的变动完全交给了开发者。Vue和 Anaular 是双向数据绑定,计算属性等,数据是响应式的,其本身就包含了完整的状态管理工具,控制视图的场景减少,官方定调。

2 React 状态管理简介

2.1 React 状态管理工具可以分为以下几类:

  • React自带:LocalState(props)和Context
  • 单向数据流:Flux、Redux(Redux-toolkit)
  • 双向数据绑定:Mobx
  • 原子型状态管理:Recoil、Jotai
  • 异步操作密集型:Rxjs

每一种状态管理工具都有其不同的适用性,不同场景下需要合理的选择状态管理工具。

2.1 Context

  • 官方API
  • 渲染粒度无法控制,经常引起不必要的渲染
  • 耦合度高,层层嵌套

2.2 Flux

  • 早期经典,已被 redux 平替
  • dispatch 和 action 耦合, store 和 axtion 耦合
  • 模板代码量大,不易理解

2.3 Redux

  • 单向数据流
  • 纯函数
  • 时间旅行
  • 模板代码复杂,副作用处理等
  • 支持类组件和函数组件

2.4 Redux-toolkit

  • Redux 的 hook 版
  • 样板代码少
  • 支持 React 新特性

2.5 Mobx

  • 双向绑定,响应式
  • api 少,学习成本低,模板代码少
  • 太灵活,很难保证代码风格统一

2.6 Recoil

  • 原子性 Atom, 任意组合 selector,细粒度更新(相比于context)
  • 天然支持 React Suspense 等新特性
  • 有快照功能

2.7 Jotai

  • j简化版本的 Recoil, 2.4 kb
  • 细粒度更新
  • 不需要 Provider 包裹,只有 2个核心api: atom 和 useAtom

2.8 Zustand

  • 轻量级,不到1kb, 适合移动端
  • 只支持hooks 组件

2.9 Rxjs

  • 异步复杂逻辑处理
  • 多值 promise ,丰富操作符,时间调度器
  • 异步流程编排

2.10 xstate

  • 基于状态机

2.11 valtio

  • 基于 proxy 实现,类似 mobx 开发体验
  • 变更历史 和快照

2.2 React 状态管理简介

  • 1、Local State (props)

    local State顾名思义,就是组件级别的局部状态,比如:

    import {useState} from 'react'
    const Hello =()=> {
    	const [name,setName] =useState('Jony')
    	return <>Hello,{name}</>
    }
    

    上述的 name 就是一个最简单的局部 localState。只在 Hello 这个组件中生效,当组件创建时 初始化和生效,组件销毁时失效。

    我们知道React的数据流是自上而下的,大部分情况下localState就能满足我们的需求,但是也有例外,比如:

    //Child1
    const Child1 =() => {
    	const [namel,setNamel] = useState("Tom")
    	return <>{namel}</>
    }
    
    //Child2
    const Child2 =() => {
    	const [name2,setName2]=useState("Jerry")
    	return <>{name2}</>
    }
    
    //Parent
    const Parent =() => {
    	return <>
    		<Child1/>
    		<child2/>
    	</>
    }
    

    遇到这种问题,我们不需要马上去引入状态管理,对于这种情况我们优先考虑,将状态向上一级,放在父组件中,由父组件自上而下的传递:

    //Child1
    const Child1=({namel,..otherProps})=> {
    	return <>{namel}</>
    }
    
    //Child2
    const Child2 =({name2})=> {
    	return <>{name2}</>
    }
    
    //Parent
    const Parent =() => {
    	const [name1,setName1]=useState("Tom")
    	const [name2,setName2]=useState("Jerry")
    	return <>
    		<Child1 namel={name1} name2={name2}>
    		<Child2 name2={name2}/>
    	</>
    }
    

    当然这种向上延伸的方法,不是无限的,如果一直往上延伸,会出现一个父组件嵌套10几层子组件的情况,必须要有一个“度",超过这个“度”后,我们就认为localState的方式就不太实用了。 这个“度”,在前端开发中,大部分情况下我们认为就是子页面。我们一般认为,单页应用中,子页面以及子页面之下的组件都是可以用localState来解决状态管理问题的,而子页面和子页面之间,是不需要再往上延伸。那么子页面和子页面之间如何通信呢?

    答案是:子页面和子页面之间的通信,React本身提供了Context

    2、Context

    function useCounter(){
    	let [count,setCount]=useState(0)
    	let decrement=(()=> setCount(count-1)
    	let increment=(=> setCount(count+1)
    	return { count, decrement, increment }
    }
    let Counter = createContext(null)
    function CounterDisplay(){
    	let counter=useContext(Counter)
    	return(
    		<div>
    			<button onClick={counter.decrement}>-</button>
    			<p>You clicked {countercount times</p>
    			<button onclick={counter.increment}>+</button>
    		</div>
    	)
    }
    function App() {
    	let counter=useCounter()
    	return(
    		<Counter.Provider value={counter}>
    		<CounterDisplay />
    		<OtherDisplay />
    		</Counter.Provider>
    	)
    }
    

    OtherDisplay 没有用到 Context 里的 value,但是 Context 的值变换,otherDisplay 也会重新渲染。 有什么办法解决呢?

    function Provider(props){
    	let counter = useCounter();
    	return <Counter.Provider value={counter}>
    		{props.child}
    	</Counter.Provider>
    const App = () => {
    	return
    		<Provider>
    		<CounterDisplay></CounterDisplay>
    		<0therDisplay></0therDisplay>
    		</Provider>
        )
    }
    

    在这个例子中通过 createContext 和 useContext,可以在 App 的子组件 CounterDisplay 中使用context 从而实现一定意义上的购组件通信,context 的值一日变化,所有调用了 useContext() 的组件均会里新触发遭染更新,由干 context API 并不能细粒度地分析某个组件依赖了 context 里的哪个属性,并且它可以穿透 React.memo 和 dependence 的对比,所以针对频繁引起数据变化的场景,在使用时需格外谨慎。

React 中的 Context 解决了 react 中,props 或者 state 进行多级数据传递,则数据需要自顶下流经过每一级组件,无法跨级的问题。但是 Context 在页面间共享数据的时候同样有很多问题:

  • Context 相当于全局变量,难以追溯数据的变更情况
  • 使用 Context 的组件内部耦合度太高,不利于组件的复用和单元测试
  • 会产生不必要的更新(比如会穿透 memo 和 dependicies 等)
  • Context 只能存储单一值,无法存储多个各自拥有消费者的值的集合。
  • 粒度也不太好控制,不能细粒度的区分组件依赖了哪一个 Context
  • 多个 Context 会存在层层嵌套的问题

上述的缺点,有部分其实都是能够解决的。

我们在 React 业务代码的开发中,要多思考,其实大部分场景下,我们都不需要三方状态管理工具。Props 和 Context 能解决我们很多问题。

Context 的使用场号很多,一些全局的不需要经常变更的配詈我们经常放到 Context 中比如主题语言等

import React from 'react';
import { ConfigProvider } from '@arco-design/web-react";
import enuS from "@arco-design/web-react/es/locale/en-US';

ReactDOM.render(
	<ConfigProvider locale={enUS}>
	<YourApp />
	</ConfigProvider>,
	CONTAINER
);

此外,比如不同的页面中有一些相同的属性,我们也可以放在Context中。

export const ViewProvider = ({
	children,
}:PropsWithChildren<Record<string,unknown>>)=> {
	const [View, setView]= useState<ViewCountry[]>([]);
	useEffect(() =>{
		(async () => {
			const res: ListviewsResponse = await iRequest.get('', {
				params:{ Action: api.schedule.listviews },
			});
			if(res.Countries){
				setView(res.Countries);
			})();
	},[]);
	return <ViewContext.Provider value={View}>{children}</ViewContext.Provider>;
};
export function useCountries(){
	const value = useContext<ViewCountry[]>(ViewContext);
	return value;
}
export function useIsps(){
	const value = useContext<ViewCountry[]>(ViewContext);
	return value.find(view => view.Code === 'CN')?.Isps || [];
}
export function useProvinces() {
	const value = useContext<ViewCountry[]>(ViewContext);
	return value.find(view => view.Code === 'CN')?.Provinces || [];
}
const AlarmRecord =() => (
	<ViewProvider
	<AlarmList />
	</ViewProvider
);
const AlarmDetail: React.FC = () =>(
	<ViewProvider>
	<Detail />
	</ViewProvider>
);

3、Redux

​ 我们前面讲到了 props 和 context,以及他们的优缺点,在讲 React 状态管理工具的时候,最经典的要属 Redux 了,我们详细介绍一下 Redux.

​ Redux 是从Flux演变而来的,Flux 它是 Facebook 官方给出的应用架构,利用数据的单向流动的形式对公共状态进行管理,不过现在已经被淘汰了,不过其设计思想还是可以参考和借鉴的,在聊 Redux 之前,我们先聊一下 Flux 状态管理。

​ Flux状态管理的架构图如下所示:

​ Flux利用数据的单向流动的形式对公共状态进行管理。

  • View:视图层
  • Action:视图发出的消息
  • Dispatcher:派发者,用来接收Action,执行回调函数
  • Store:数据层,存放状态,一旦发生改动,就会更新数据以及emit相关事件等

我们简单举个例子: 1.在 UI 页面中出发 action

<button onClick={this.handler.bind(this)}>click</button>

2.在 Flux 的 Action 中使用 dispatcher.dispatch 将 Action 发送给 Flux 的 dispatcher

handler(){
	let action = {
		type:"add"
	};
	dispatcher.dispatch(action)
}

3.dispatcher通过register注册事件,然后根据传递过来的action,来改变store中的state

import {Dispatcher} from 'flux'
import store from './index'
const dispatcher=new Dispatcher(();
dispatcher.register((action)=>{
	switch(action.type){
		case "add":
			store.handleAdd();
		break;
}})
export default dispatcher;

4.在store中进行数据更新

import observer from '../observer
let store = Object,assign(observer,{
	state:{
		n:10
	},
	getState(){
		return this.state;
	},
	handleAdd(){
		this.state.n ++;
		this.semit("update")
	},
})
export default store;

5.在 UI 中监听 store 并触发更新

constructor(){
	super();
	this.state = store.getState();
	store.$on("update",this.handleUpdate.bind(this))
}
handleUpdate(){
	this.setstate(store.getState());
}

Flux 的缺点: (1)UI 组件和容器组件的拆分过于复杂 (2) Action 和 Dispatcher 绑定在一起 (3)不支持多个 store (4) store 被频繁的引入和调用

我们简单看看 Redux 是如何解决上述问题的。 首先 Redux 解构了 Action 和 Dispatcher:

export function addTodo(text) {
	return {
		type: ActionTypes.ADD,
		text:text
	};
}

上述就是一个Redux中的一个action,它是独立的,如果用Flux

需要和 dispatcher 耦合在一起

let action = {
	type: "add"
};
dispatcher.dispatch(action)


dispatcher.register((action)=>{
	switch(action.type){
		case "add":
		...
		break;
}})

Redux 中的 store 同样也解除了 dispatcher 的耦合,提供了一个 Reducer 来处理 Store 的更新:

const initialstate = {n:10 };
export default function TodoStore(state = initialState, action) {
	switch (action.type){
	case ActionTypes.ADD:
		return { n: state.n++ };
	default:
	return state;
}

Redux 的三大原则:单一数据源,只有一个 store、store 中的 state 是只读的、使用纯函数来执行修改。

1.单一数据源: 在 redux 中,整个应用的全局 State (再次注意是全局 state )都会保存在一个 store 中,一个单一数据源 statetree 也简化了应用的调试和和监控;它也让你在开发中能将应用数据持久化到本地,从而加速开发周期。此外,有一些功能以前很难实现,比如“撤销/重做”,在单一数据源的原则下,使用 Redux 实现将非常容易。

2. Store 中的 State 是只读的: 我们不能直接修改 store 中的 state,store 中的 state 是只读的。唯一能改变 store 中的 state 的方式就是通过 action

3.使用纯函数来执行修改: 接受纯函数来接受 aciton,该纯函数叫 reducer ,可以改变 store 中的 state

​ 因为Redux的上述特性,使得Redux可以做时间旅行。时间旅行:顾名思义,就是可以随时穿越到以前和未来。让应用程序切换到任意时间的状态。因此,如果复杂的场景,特别是存在页面组件间复杂的通信的场景非常适合用 Redux来管理状态。

​ Redux比较适合用于大型Web项目,尤具是一些交互足够复杂、组件通信频繁的场景,状态可预测和回溯是非常有价值的。还有一种场景,比如需要事故重现,这种定义和上报事故异常和重现的场景,Redux也很有意义。

​ Redux 的缺点也很明显,首先为了实现纯函数的 Reducer,Redux 必须处理各种各样的副作用,需要引入一系列的副作用中间件,加重的心智负担,此外 Action,Dispatch,Reducer 的模式需要写过多的样版代码,虽然通过 React hooks 和 Redux toolkit 可以减少一定的样板代码,但是复杂度还是摆在哪里。因此中小项目,也不太推荐使用 Redux ,可能 Context 或者 Reacthooks 中的 useReducer 就能满足你的需求。

4、Mobx

它通过透明的函数响应式编程使得状态管理变得简单和可扩展 Mobx 跟 Vue 的设计比较相似,是一个响应式的状态管理库。Mobx 借助于装饰器的实现,使得代码更加简洁易懂。由于使用了可观察对象,所以 Mobx 可以做到直接修改状态,而不必像 Redux 一样编写繁琐的 actions 和 reducers。

image (6).png

简单的概括一下,一共有这么几个步骤:

  • 页面事件(生命周期、点击事件等等)触发action的执行。
  • 通过 action 来修改状态。
  • 状态更新后,computed 计算属性也会根据依赖的状态重新计算属性值。
  • 状态更新后会触发 reaction,从而响应这次状态变化来进行一此操作(消染组件,打印日志等等)。
import { observable, computed } from "mobx";
class OrderLine{
	@observable price = 0;
	@observable amount = 1;
	@computed get total(){
return this.price * this.amount;

​ Mobx的优势在于上手简单,可以直接修改状态,不需要编写繁琐的Action和Reducer,也不需要引入各种复杂的中间件,局部精确更新,免去了粒度控制烦恼,自始至终一份引用,不需要immutable,也没有复制对象的额外开销。因此前端数据流不太复杂的情况,使用Mobx,因为更加清晰,也便于维护。但是正是因为Mobx的灵活,Mobx的代码风格很难统一。

​ 不过 Mobx 是不能实现时间旅行和回溯的,因此不太适合前端数据流比较复杂的场景,此外,随着 React hooks,比如 useReducer 等的,以及 React 自身的原子型状态管理工具 Recoil。Mobx 的使用场景会被进一步压缩, 目前的项目中使用 Mobx 的场景已经越来越小。

5、Recoil

Recoil 是 React 官方内置的状态管理工具,一定程度上解决了 LocalState 和 Context 的局限性,且能够兼容 React 的新特性比如 Concurrent模 式等。

解决的问题:

​ 1.组件间的状态共享只能通过将 state 提升至它们的公共祖先来实现,但这样做可能导致重新渲染一颗巨大组件树。

  1. Context 只能存储单一值,无法存储多个各自拥有消费者的值的集合。

Recoil侧是更加的具有原子性,比如在Recoil的状态都是Atom,可以进行任意组合等。

3 实现一个建议的状态管理工具

上一章节,我们介绍了很多状态管理工具,几乎所有的状态管理工具,都是基于发布/订阅模式来实现的。我们首先回顾一下什么是发布/订阅模式。

Store --发布--> 中介媒介 --发布--> React UI 层渲染

Store <--订阅-- 中介媒介 <--订阅-- React UI 层渲染

基于发布/订阅模式,我们来实现一个简单的 Store:

export default class Createstore (
	constructor(reducer, initialstate){
		this.currentReducer = reducer;
		this.currentstate = initialstate;
		this.listeners = [];
		this.isDispatching = false;
	}
	getstate(){
		return this.currentstate;
    }
	subscribe(listener){
		this.listeners.push(listener);
		return function unsubscribe() {
			var index=this.listeners.indexof(listener);
			this.listeners.splice(index,1);
		};
	dispatch(action) {
		try {
			this.isDispatching = true;
			this.currentstate = currentReducer(currentstate, action);
        } finally {
			this.isDispatching = false;
		}
		this.listeners.slice().forEach(listener => listener());
		return action;
    }
}

接着我们来看如何使用这个 CreateStore 来创建一个全局的状态:

import CreateStore from './createStore.js

function todos(state = [], action) {
	switch (action.type){
		case 'ADD_TODO':
			return state.concat( [action.text])
		default:
			return state
    }
}
    
const store = createStore(todos, ['Use Redux'])

store.dispatch({
	type: 'ADD_TODO',
	text: 'Read the docs'
})

console.log(store.getState())

// [ 'Use Redux', 'Read the docs']

我们也可以通过 Store.subscribe 来监听状态变化,重新渲染 ReactUI 层等

不够优雅?Reducer异步的处理?....

4 Redux 在项目中的实践

  • 1.如何使用 Redux 首先要明确为什么要使用 redux,这一点很重要,如果不知道为什么使用redux,那么在开发的过程中肯定不能合理的使用 redux 首先来看 redux 的本质:

  • redux 做为一款状态管理工具,主要是为了解决组件间通信的问题

  • 既然是组件间的通信问题,那么显然将所有页面的状态都放入 redux 中,是不合理的,复杂度也很高。

image (7).png

减少局部状态和 redux 状态的不合理混用:

​ 全量使用 redux 的复杂度很高,我们当然考虑将一部分状态放在 redux 中,一部分状态放在local state 中,但是这种情况下,很容易产生一个问题,就是如果 local State 跟 redux 中的 state 存在状态依赖。

2、Redux 复杂的模版代码

​ redux 是遵循函数式编程的规则,上述的数据流中,action 是一个原始 js 对象 (plain object) 且reducer 是一个纯函数,对于同步且没有副作用的操作,上述的数据流起到可以管理数据,从而控制视图层更新的目的。

​ 如果存在副作用函数,那么我们需要首先处理副作用函数,,然后生成原始的 is 对象。如何处理副作用操作,在 redux 中选择在发出 action,到 reducer 处理函数之间使用中间件处理副作用。

image (8).png

在有副作用的 action 和原始的 action 之间增加中间件处理,从图中我们也可以看出,中间件的作用就是:

转换异步操作,生成原始的 action,这样,reducer 函数就能处理相应的 action,从而改变state,更新UI

​ 因为中间件,纯函数 Reducer 等使得 Redux 需要写很多样板代码,使用起来越来越复杂,早期我们使用 redux-thunk,或者 redux-saga 等,但是复杂度还是在那里,因此在项目中不推荐使用如此复杂的Redux以及相关逻辑。

2 Redux toolkit 用redux需要有太多的样板代码,中间件代码等等,还需要区别同步和异步操作,及其复杂。早期Dvais通过封装,能够解决部分上述的问题。早期的Redux,我们也需要引入很多Redux相关的包,比如React-redux等等,显得复杂而繁琐,而Reduxtoolkit的出现则是完全解决了上述的问题,使得Redux的开发可以简单明了。Redux toolkit是官方推荐的高校的redux状态管理工具集。

​ Redux toolkit 可以简化 Redux 开发包括配置 store,定义 reducer 不可变的更新逻辑,其至可以立即创建敕个状态的“切片slice”,而无需手动编写任何 action creator 或者 action type。此外,Redux toolkit 提供了完整的 React 的 hooks,可以方便 React 函数组件中使用 Redux toolkit。

​ 我们的例子是红色框的筛选条件,会在整个系统中,6-7个页面中会用到。这里早期是用 Context的。Context 的数据的类型为:

export interface IFilterContext{
	filter: IFilter;
	selectedDomains:{ DomainId: string;Vendor: string}[];
	onChangeSelectedDomains:(
		arr:{ DomainId:string;Vendor:string}[],
	)=> void;
	onChangeFilter:(arg:IFilter)=> void;
	isCompare:boolean;
	onChangeIsCompare:(arg:boolean)=>void:
	compareTime: ITime;
	onChangeCompareTime:(arg:ITime)=> void;
	curTimeRange: keyof typeof TimeRangeEnum;
	onChangeTimeRange: (arg: keyof typeof TimeRangeEnum)=> void;
	selectVendorNames:string[];
	onChangeSelectVendorNames:(arg:string[])=> void:
	traceDomains:{ DomainId: string; Vendor: string; DomainName:string}[];
	onChangeTraceDomains:(
		arg:{ DomainId: string; Vendor: string;DomainName:string}[],
	) => void;
}

上述的数据就是我们需要保存在 context 中的,然后通过 Provider 注入到根组件:

const{
	filter,
	setFilter,
	isCompare,
	setIsCompare,
	curTimeRange,
	setCurTimeRange,
	selectVendorNames,
	setSelectVendorNames,
	compareTime,
	setCompareTime,
	selectedDomains,
	setSelectedDomains,
	traceDomains,
	setTraceDomains,
} = useInitContext();
return(
	<FilterContext.Provider
	value = {{
		filter,
		selectedDomains,
		onChangeSelectedDomains: setSelectedDomains,
		onChangeFilter: setFilter,
		isCompare,
		onChangeIsCompare: setIsCompare,
		compareTime,
		onChangeCompareTime:setCompareTime.
		curTimeRange,
		onChangeTimeRange: setCurTimeRange,
		selectVendorNames,
		onChangeSelectVendorNames: setSelectVendorNanes,
		traceDomains,
		onChangeTraceDomains: setTraceDomains,
}}>
	<div>{children}</div>
	</FilterContext.Provider>
)

最后注入之后,需要使用 context 数据的使用 context 中的数据:

const filterContextInfo = useContext(FilterContext);
const preSelectedDomains = filterContextInfo.selectedDomains;
const setPreSelectedDomains = filterContextInfo.onChangeSelectedDomains
const{traceDomains,onChangeTraceDomains} = filterContextInfo;

我们发现,整体来看,当 context 中的数据一复杂之后,context 的使用也会变得比较负责, 代码的可读性一定程度下会下降,此外最主要的是我们不太好追踪数据的变化。

ReduxToolkit 改写后

import { configureStore } from '@reduxjs/toolkit';
import type { TypedUseSelectorHook } from 'react-redux';
import { useDispatch, useSelector } from "react-redux';
import { statisticSlice } from './statistic';
export const store = configurestore({
	reducer:{
		statistic: statisticslice?.reducer,
	}
	middleware:getDefaultMiddleware
	getDefaultMiddleware({
		immutableCheck: false,
		serializableCheck: false,
	}),
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch =typeof store.dispatch;
export const useAppDispatch: ()=> AppDispatch=useDispatch;
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;