React-状态管理

MVC 和 MVVM 模型

  1. MVC:Model-View-Controller,模型-视图-控制器。
  2. MVVM:Model-View-ViewModel,模型-视图-视图模型。视图模型负责将模型的数据转换为试图可以使用的格式,并且负责处理试图交互逻辑。

MVC 和 MVVM 都是组织和管理应用程序代码结构的模式,具体实现和使用上有一些不同。MVC 更关注控制器和模型的交互,MVVM 更关注视图模型和试图的交互。

状态管理的概念

状态管理一般处于 MVC 中的模型层,MVVM 中的视图模型层。

  1. 状态:指的是应用程序中的数据或状态信息,可以是用户信息、界面状态、网络请求状态等等。在状态管理中,状态通常被集中存储和管理,以确保整个应用程序的一致性和可维护性。
  2. 管理:指的是对状态的管理和控制。状态管理工具通常提供了一套 API 或机制,用于更新、访问和监听状态的变化。这些工具还可以帮助开发人员组织和结构化状态,以便更容易地维护和调试应用程序。
  3. 通信:在大型应用中,不同组件之间经常需要共享状态或进行通信。状态管理工具通常提供了一种机制来实现组件之间的状态共享和通信,以简化组件之间的耦合度和数据传递。
    副作用管理:应用程序中的一些操作可能会引起副作用,如网络请求、定时器、本地存储等。状态管理工具通常也提供了一种机制来管理和控制这些副作用,以确保应用程序的稳定性和可预测性。

React 中的状态管理

  • 状态提升:通过将状态提升到最近的祖先组件中,通过 props 一级级传递获取状态。
  • Reducer: 用于管理组件状态的一种函数。接收当前状态和一个描述操作的动作对象,然后返回一个新的状态。
  • Provider:通过 Provider 包裹组件,被包裹的所有子组件可以通过 context 直接获取状态。

心智模型

  1. 不可变性:React 鼓励使用不可变的数据结构来管理状态。这意味着在更新状态时,应该始终创建新的对象,而不是直接修改现有状态对象。这样可以确保状态的可追溯性,避免副作用和意外的状态变化。
  2. 纯函数:状态更新函数应该是纯函数,即给定相同的输入,总是返回相同的输出。这可以确保状态更新的行为是可预测的,并且不会出现意外的情况。
  3. 单一数据源:应用程序的状态应该集中在单一数据源中,这一原则也被称为拥有 “可信单一数据源”。通常是组件的状态或全局状态管理器(如 Redux)。保持状态的唯一性。

状态提升 / 属性下钻

状态提升是指将组件之间共享的状态移动到它们的最近共同祖先组件中进行管理的过程。通过将状态提升到更高层级的组件中,可以使得组件之间共享状态,实现状态的一致性和数据的传递。

使用场景:当多个子组件需要 访问/更新 相同的状态时,可以将这个 状态/状态的更新函数 提升到它们的最近共同祖先组件中进行管理,然后通过 props/回调函数状态/更新函数 传递给子组件。

import React, {useState} from 'react';

const ParentComponent: React.FC = () => {
  const [hiddenChildren, setHiddenChildren] = useState(false);

  return (
    <div>
      <button onClick={() => setHiddenChildren((prev) => !prev)}>
        {hiddenChildren ? '显示' : '隐藏'}
      </button>
      <Child1 hidden={hiddenChildren} />
      <Child2 hidden={hiddenChildren} />
    </div>
  );
};

const Child1: React.FC<{hidden: boolean}> = ({hidden}) => {
  return <div hidden={hidden}>Child1</div>;
};

const Child2: React.FC<{hidden: boolean}> = ({hidden}) => {
  return <div hidden={hidden}>Child2</div>;
};

受控组件和非受控组件

通常我们把包含不受控制状态的组件称为 “非受控组件”。即该组件状态由自己控制,其父组件无法控制状态。

相反,当组件中的重要信息是由 props 而不是其自身状态驱动时,就可以认为该组件是“受控组件”。这就允许父组件完全指定其行为。

非受控组件通常很简单,因为它们不需要太多配置。但是当你想把它们组合在一起使用时,就不那么灵活了。受控组件具有最大的灵活性,但它们需要父组件使用 props 对其进行配置。

在实践中,“受控”和“非受控”并不是严格的技术术语——通常每个组件都同时拥有内部状态和 props。然而,这对于组件该如何设计和提供什么样功能的讨论是有帮助的。

Reducer / Immer

随着组件的不断迭代,组件中的状态会越来越复杂。为了降低状态的复杂程度,可以将逻辑移到组件外的 reducer 函数中。

useState 迁移到 useReducer:

function todosReducer(todos, action){
    switch(action.type){
        case 'add': {
            return [...todos, {id: action.id, text: action.text}];
        }
        case 'update': {
            return todos.map(todo => {
                if (todo.id === action.id) {
                    return {id: action.id, text: action.text};
                } else {
                    return todo;
                }
            });
        }
        case 'delete': {
            return todos.filter(todo => todo.id !== action.id);
        }
        default: {
            throw new Error(`Unhandled action type: ${action.type}`);
        }
    }
}

const Component: React.FC = () => {
  // - const [todos, setTodos] = useState([]);
  const [todos, dispatch] = useReducer(todosReducer, []);
  return (
    <div>
      <button
        onClick={() => dispatch({type: 'add', id: uuid(), text: 'todo1'})}
      >
        添加
      </button>
      {todos.map((todo) => (
        <div key={todo.id}>
          {todo.text}
          <button onClick={() => dispatch({type: 'delete', id: todo.id})}>
            删除
          </button>
          <button
            onClick={() => {
              dispatch({type: 'update', id: todo.id, text: 'newText'});
            }}
          >
            修改
          </button>
        </div>
      ))}
    </div>
  );
};

使用 useReducer 的一些注意事项:

  • reducers 必须是纯粹的。 这一点和 状态更新函数 是相似的,reducers 是在渲染时运行的!(actions 会排队直到下一次渲染)。 这就意味着 reducers 必须纯净,即当输入相同时,输出也是相同的。它们不应该包含异步请求、定时器或者任何副作用(对组件外部有影响的操作)。它们应该以不可变值的方式去更新 对象 和 数组。

  • 每个 action 都描述了一个单一的用户交互,即使它会引发数据的多个变化。 举个例子,如果用户在一个由 reducer 管理的表单(包含五个表单项)中点击了 重置按钮,那么 action 应该一次重置所有 fields 而不是每个 field 更新都使用单独的 action。

使用 Immer 代替 Reducer 和 State

对于修改对象和数组来说,可以使用 Immer 来简化 reducer。Immer 为你提供了一种特殊的 draft 对象,你可以通过它安全的修改 state。在底层,Immer 会基于当前 state 创建一个副本。这就是为什么通过 useImmerReducer 来管理 reducers 时,可以修改第一个参数,且不需要返回一个新的 state 的原因。

import React from "react";
import { useImmerReducer } from "use-immer";

function todosReducer(draft, action) {
    switch (action.type) {
        case 'add': {
            return void draft.push({id: action.id, text: action.text});
        }
        case 'update': {
            const index = draft.findIndex((todo) => todo.id === action.id);
            return void draft.splice(index, 1, {id: action.id, text: action.text});
        }
        case 'delete': {
            const index = draft.findIndex((todo) => todo.id === action.id);
            return void draft.splice(index, 1);
        }
        default: {
            throw new Error(`Unhandled action type: ${action.type}`);
        }
    }
}

...

import React from "react";
import { useImmer } from "use-immer";


function App() {
  const [todos, updateTodos] = useImmer();

  function addTodo(todo) {
    updatePerson(draft => {
      draft.push(todo);
    });
  }

  function deleteTodo(index) {
    updatePerson(draft => {
      draft.splice(index, 1);
    });
  }

  return (
    <div>
      ...
    </div>
  );
}

Provider / Context

当父组件需要传递给子组件时,如果子组件层级过深,会导致需要通过许多中间组件传递 props,这不利于代码的可读性和维护。Context 允许父组件向无论多深的任何组件传递状态,而不需要 props 传递,子组件可以直接从 Context 中获取。

react-context.png

使用 Context:

  1. 创建 Context
import {createContext} from 'react';

export const LevelContext = createContext(0);
  1. 使用 Context
import { useContext } from 'react';
import { LevelContext } from './LevelContext.js';
export default function Heading({ children }) {
  const level = useContext(LevelContext);
  // ...
}
  1. 使用 Provider 包裹
import { LevelContext } from './LevelContext.js';

export default function Section({ level, children }) {
  return (
    <LevelContext.Provider value={level}>
        {children}
    </LevelContext.Provider>
  );
}

完整示例可以查看React Context

状态管理库

Redux

Flux 架构和函数式编程原理,状态可预测,可跟踪,允许在组件外使用(不依赖上下文)。
使用方式:创建并维护一个 store ,状态通知视图,action 触发 reducer 更新 store,视图通过订阅 store 获取状态。

三大原则

  • 单一数据源:整个应用的 state 存储在单个对象中,且只存在于唯一一个 store 中。
  • state 只读:只有触发 action 才能改变 state。
  • 纯函数修改:通过 reducer 修改状态,接收的参数是 state 和 action,返回的是新 state 而不是修改旧 state。

简单示例:(摘自 Redux 官方文档,完整代码可以在 Redux 官方示例 中查看)

// ! 整个应用应保证只有一个 Store
import {configureStore, ThunkAction, Action} from '@reduxjs/toolkit';
import counterReducer from './counterSlice';

export const store = configureStore({
  reducer: {
    counter: counterReducer,
  },
});

export type AppDispatch = typeof store.dispatch;
export type RootState = ReturnType<typeof store.getState>;
export type AppThunk<ReturnType = void> = ThunkAction<
  ReturnType,
  RootState,
  unknown,
  Action<string>
>;
import {createAsyncThunk, createSlice} from '@reduxjs/toolkit';
import {RootState} from './store';

export interface CounterState {
  value: number;
  status: 'idle' | 'loading' | 'failed';
}

const initialState: CounterState = {
  value: 0,
  status: 'idle',
};

// redux 本身没有规定异步操作的处理方式,一般使用中间件来支持异步。
export const incrementAsync = createAsyncThunk(
  'counter/fetchCount',
  async (amount: number) => {
    const response = await fetchCount(amount);
    return response.data;
  },
);

export const counterSlice = createSlice({
  name: 'counter',
  initialState,
  reducers: {
    increment: (state) => {
      state.value += 1;
    },
    decrement: (state) => {
      state.value -= 1;
    },
  },
  extraReducers: (builder) => {
    builder
      .addCase(incrementAsync.pending, (state) => {
        state.status = 'loading';
      })
      .addCase(incrementAsync.fulfilled, (state, action) => {
        state.status = 'idle';
        state.value += action.payload;
      })
      .addCase(incrementAsync.rejected, (state) => {
        state.status = 'failed';
      });
  },
});

export const {increment, decrement} = counterSlice.actions;

export const selectCount = (state: RootState) => state.counter.value;

export default counterSlice.reducer;

function fetchCount(amount = 1) {
  return new Promise<{data: number}>((resolve) =>
    setTimeout(() => resolve({data: amount}), 500),
  );
}

import {useDispatch, useSelector} from 'react-redux';
import {AppDispatch, RootState} from '@/src/store/store';
import {decrement, increment, selectCount} from '@/src/store/counterSlice';

export default function App() {
  const useAppSelector = useSelector.withTypes<RootState>();
  const useAppDispatch = useDispatch.withTypes<AppDispatch>();
  const dispatch = useAppDispatch();         // action
  const count = useAppSelector(selectCount); // 监听数据

  return (
    <div>
      <button
        className='button'
        aria-label='Decrement value'
        onClick={() => dispatch(decrement())}
      >
        -
      </button>
      <span className='text-sm font-semibold'>{count}</span>
      <button
        className='button'
        aria-label='Increment value'
        onClick={() => dispatch(increment())}
      >
        +
      </button>
    </div>
  );
}

jotai

小型全局状态管理库,Context 和订阅机制的结合。useState+useContext 的替代品,在组件颗粒度较细的情况下很有优势。

特点

  • 简单的 API:使用模式和 useState 类似。
import {atom, useAtom} from 'jotai';
const countAtom = atom(0);
const [count, setCount] = useAtom(countAtom);
  • 灵活:可以通过 atom 衍生出另外的 atom。也可以被任意的其他 atom 更新。
import { atom } from 'jotai'
const countAtom = atom(0)
const readOnlyAtom = atom((get) => get(countAtom) * 2)
const writeOnlyAtom = atom(
  null,
  (get, set, update) => {
    set(countAtom, get(countAtom) - update.count)
  },
)

简单示例:

import {atom, useAtom} from 'jotai';

const countAtom = atom(0);
export default function App() {
 const [count, setCount] = useAtom(countAtom);

  return (
    <div>
      <button
        className='button'
        aria-label='Decrement value'
        onClick={() => setCount(count - 1)}
      >
        -
      </button>
      <span className='text-sm font-semibold'>{count}</span>
      <button
        className='button'
        aria-label='Increment value'
        onClick={() => setCount(count + 1)}
      >
        +
      </button>
    </div>
  );
}

状态管理不是必须的,不应该滥用状态管理。

更多关于 React 状态管理可以查阅 here