前端状态管理

2,478 阅读12分钟

背景

为什么前端需要状态管理?现代主流的前端框架,不管是 react 还是 vue,都是组件化开发。组件化虽然可以提高代码复用率,但是同时也带来一个问题,那就是组件之间的通信。

父子组件之间的通信我们可以从容应对,但是当组件之间的关系变得复杂,甚至组件之间没有关系时,要在他们之间通信就变得不太容易。

这个时候我们就需要把这些在多个组件中都用到的数据提取出来,独立于组件树,放在一个新的地方。这些数据往往会因为组件上事件的触发而发生变更,同时这些数据的变更又会作用到组件上,引起视图的变更。

因此,针对这些数据的管理就变更尤为重要,这里的数据也被称为状态(state),因此也叫状态管理。

截屏2021-04-25 上午10.19.19.png

Redux

说到状态管理,那就不得不说 Redux。Redux是将整个应用的状态存储到到一个地方,称为 store。所有的数据都存在 state 中。各个组件可以派发 dispatch 行为 action 给 store,而不是直接通知其它组件。其它组件可以通过订阅 store 中的状态(state)来刷新自己的视图。没错,这就是典型的发布订阅模式。

image.png

image.png

Redux有以下几个特征:

  • 整个应用的 state 被储存在一个 object 中
  • state 是只读的,惟一改变 state 的方法就是触发 action
  • action 是一个用于描述已发生事件的普通对象,使用纯函数来执行修改
  • 为了描述 action 如何改变state ,需要编写 reducers
  • 单一数据源的设计让 React 的组件之间的通信更加方便,同时也便于状态的统一管理

主要有以下几个方法:

  • createStore /* 创建store*/
  • combineReducers /* 组合多个reducers*/
  • bindActionCreators /* 绑定action和store.dispatch*/
  • applyMiddleware /* 中间件机制*/
  • compose

Redux实现原理

// 创建state仓库
function createStore(reducer,preloadedState){

  // reducer :action 改变 state 的动作
  let currentReducer = reducer;
  // 初始化状态
  let currentState = preloadedState;
  // 订阅的事件,当 state 变更时触发
  let currentListeners = [];
  
  // 获取当前的 state
  function getState() {
    return currentState;
  }

  // 事件订阅
  function subscribe(listener) {
    currentListeners.push(listener)
    // 返回取消事件订阅
    return function unsubscribe() {
      const index = currentListeners.indexOf(listener)
      currentListeners.splice(index, 1)
    }
  }

  // 派发 action
  function dispatch(action) {
    currentState = currentReducer(currentState, action)
    for (let i = 0; i < currentListeners.length; i++) {
      const listener = currentListeners[i];
      listener();
    }
    return action;
  }

  // 初始化状态
  dispatch({ type: ActionTypes.INIT });

  const store = ({
    dispatch,
    subscribe,
    getState
  })
  
  // 返回store
  return store
}

Redux使用案例

import { createStore} from 'redux';

const INCREMENT = 'INCREMENT';
const DECREMENT = 'DECREMENT';
let initState = { number: 0 };

const reducer = (state = initState, action) => {
    switch (action.type) {
        case INCREMENT:
            return { number: state.number + 1 };
        case DECREMENT:
            return { number: state.number - 1 };
        default:
            return state;
    }
}
let store = createStore(reducer);
function render() {
    console.log(store.getState().number);
}
store.subscribe(render);
render();
store.dispatch({ type: INCREMENT });
store.dispatch({ type: DECREMENT });

总结一下 Redux 的用法:

  • 1.定义 reducer,建立 action 改变 state 的规则
  • 2.createStore 创建 store
  • 3.subscribe 订阅函数,当 state 变化时触发
  • 4.dispatch 方法派发 action

React-Redux

image.png

Redux 是一个状态管理的库,但是它不针对于任何一个框架。如果我们要在 react 中使用 store,就需要在每一个用到的组件中引入,这样用起来比较麻烦。因此针对 React 在 Redux 的基础上诞生了 React-Redux。React-Redux 主要由以下几个部分组成:

  • ReactReduxContext 函数

创建全局上下文

import React from 'react';
export const ReactReduxContext = React.createContext(null)
export default ReactReduxContext;
  • Provider 组件

把 store 挂载到全局上下文


import React from 'react'
import ReactReduxContext from './ReactReduxContext';

export default function(props){
  return (
    <ReactReduxContext.Provider value={{ store: props.store }}>
      {props.children}
    </ReactReduxContext.Provider>
  )
}
  • connect 函数

connect 函数接受两个参数:mapStateToProps 和 mapDispatchToProps。mapStateToProps 是从state 对象中筛选出当前需要的属性;mapDispatchToProps 当前需要用到的 actions。返回的是一个高阶函数,它接受一个组件为参数,返回一个函数组件。相当于把原来的组件经过包装之后,变成了拥有 store 中特定 state 和 action 的组件。

import React, { useContext, useMemo, useLayoutEffect, useReducer } from "react";
import { bindActionCreators } from "../redux";
import ReactReduxContext from "./ReactReduxContext";

export default function (mapStateToProps, mapDispatchToProps) {
  return function (WrappedComponent) {
    return function (props) {
      const { store } = useContext(ReactReduxContext);
      const { getState, dispatch, subscribe } = store;
      const prevState = getState();
      const stateProps = useMemo(() => mapStateToProps(prevState), [prevState]);
      const dispatchProps = useMemo(() => {
        let dispatchProps;
        if (typeof mapDispatchToProps === "object") {
          dispatchProps = bindActionCreators(mapDispatchToProps, dispatch);
        } else if (typeof mapDispatchToProps === "function") {
          dispatchProps = mapDispatchToProps(dispatch, props);
        } else {
          dispatchProps = { dispatch };
        }
        return dispatchProps;
      }, [dispatch,props]);
      const [, forceUpdate] = useReducer(x => x + 1, 0)
      useLayoutEffect(() => subscribe(forceUpdate), [subscribe]);
      return <WrappedComponent {...props} {...stateProps} {...dispatchProps} />;
    }
  };
}

总结一下 React-Redux:

  • 1.借助 Provider 组件,使得子组件可以获得 store 实例
  • 2.通过 connect 函数,以高阶组件的方式传递特定的 state 和 actions。

截止目前,Redux 和 React-Redux 中所接触的概念还是比较容易理解的,并不算太难。可是 Redux 中还有一个不成文的规定,那就是 reducers 函数必须是纯函数。

纯函数是函数式编程的概念,只要是同样的输入,必定得到同样的输出。同时函数执行过程中不能产生副作用,比如修改参数,或者向后端发起请求等。Reducer 是纯函数,就可以保证同样的 state,必定得到同样的 view。但也正因为这一点,reducer 函数里面不能改变 State,必须返回一个全新的对象。

那该如何解决副作用的问题呢?Redux 中间件就此诞生。

Redux 中间件

如果没有中间件的运用,redux 的工作流程是这样 action -> reducer,这是相当于同步操作,由dispatch 触发action后,直接去reducer执行相应的动作。

yuque_diagram.jpg

但是在某些比较复杂的业务逻辑中,这种同步的实现方式并不能很好的解决我们的问题。比如我们有一个这样的需求,点击按钮 -> 获取服务器数据 -> 渲染视图,因为获取服务器数据是需要异步实现,所以这时候我就需要引入中间件,改变 redux 同步执行的流程,形成异步流程来实现我们所要的逻辑。

有了中间件,redux 的工作流程就变成这样 action -> middlewares -> reducer,点击按钮就相当于dispatch 触发action,接下去获取服务器数据 middlewares 的执行,当 middlewares 成功获取到服务器就去触发reducer对应的动作,更新需要渲染视图的数据。

中间件的机制可以让我们改变数据流,实现如异步 action ,action 过滤,日志输出,异常报告等功能。

function applyMiddleware(...middlewares){
    return function(createStore){
        return function(reducer){
            let store = createStore(reducer);
            let dispatch;
            let middlewareAPI= {
                getState:store.getState,
                dispatch:(action)=>dispatch(action);
            let chain = middlewares.map(middleware=>middleware(middlewareAPI));
            dispatch  = compose(...chain)(store.dispatch);
            return {
                ...store,
                dispatch
            };
        }
    }
}
export default applyMiddleware;

中间件书写样例 redux-thunk

function createThunkMiddleware(extraArgument) {
  return ({ dispatch, getState }) => (next) => (action) => {
    if (typeof action === 'function') {
      return action(dispatch, getState, extraArgument);
    }

    return next(action);
  };
}

const thunk = createThunkMiddleware();
thunk.withExtraArgument = createThunkMiddleware;

export default thunk;
  • 使用:接受参数用户名,并返回一个函数(参数为dispatch)
const login = (userName) => (dispatch) => {
  dispatch({ type: 'loginStart' })
  request.post('/api/login', { data: userName }, () => {
    dispatch({ type: 'loginSuccess', payload: userName })
  })
}
store.dispatch(login('ant'))

大家是否好奇为什么会出现一个 redux-thunk ?

image.png (3 Jun 2015) 其实,最开始的时候,redux-thunk 还没有独立,而是写在 redux 的 action 分发函数中的一个代码分支而已,和现在的逻辑一样。现在将redux-thunk独立出去,用 middleware 的方式实现,会让 redux 更纯。

Redux-saga

It is a Redux middleware for handling side effects. —— Yassine Elouafi Redux-saga 是 redux 的中间件,主要就是用来处理副作用(异步任务)。

特点:

  • sages 采用 Generator 函数来 yield Effects(包含指令的文本对象)
  • Generator 函数的作用是可以暂停执行,再次执行的时候从上次暂停的地方继续执行
  • Effect 是一个简单的对象,该对象包含了一些给 middleware 解释执行的信息。
  • 可以通过使用 effects API 如 fork,call,take,put,cancel 等来创建 Effect。

与 redux-thunk 不同之处:

  • 将所有的异步流程控制都移入到了 sagas,UI 组件不用执行业务逻辑,只需 dispatch action 就行,增强组件复用性。
  • UI 组件不用执行业务逻辑,只需 dispatch 一个 plain Object 的 action。
  • 借助 Generator 函数和自身的 effect 实现复杂控制流

缺点:

  • 概念太多,不容易理解,上手成本高
  • 在业务中需要写很多 saga

Ps: saga 本意是表示使用分布式事务管理长期运行的业务流程。

The term saga, in relation to distributed systems, was originally defined in the paper "Sagas" by Hector Garcia-Molina and Kenneth Salem. This paper proposes a mechanism that it calls a saga as an alternative to using a distributed transaction for managing a long-running business process.The paper recognizes that business processes are often comprised of multiple steps, each of which involves a transaction, and that overall consistency can be achieved by grouping these individual transactions into a distributed transaction.

MobX

核心思想

MobX背后的哲学很简单: 任何源自应用状态的东西都应该自动地获得。

MobX 支持单向数据流,也就是动作改变状态,而状态的改变会更新所有受影响的视图。

image.png

特点: 不同于其他的框架,MobX 对于如何处理用户事件是完全自由的。可以以最直观、最简单的方式来处理事件,直接修改 state。最后全部归纳为: 状态应该以某种方式来更新。

当状态更新后,MobX 会以一种高效且无障碍的方式处理好剩下的事情。像下面如此简单的语句,已经足够用来自动更新用户界面了。

从技术上层面来讲,并不需要触发事件、调用分派程序或者类似的工作。归根究底 React 组件只是状态的华丽展示,而状态的衍生由 MobX 来管理。

Vuex

Vuex 是一个专为 Vue.js 应用程序开发的状态管理模式。它采用集中式存储管理应用的所有组件的状态,并以相应的规则保证状态以一种可预测的方式发生变化。

image.png

const store = createStore({
  state: {
    count: 0
  },
  mutations: {
    increment (state) {
      state.count++
    }
  },
  actions: {
    increment (context) {
      context.commit('increment')
    }
  }
})

Ps: Vuex 中为什么把异步操作封装在 action,把同步操作放在 mutations?

vuex.png

dva

数据流向

数据的改变发生通常是通过用户交互行为或者浏览器行为(如路由跳转等)触发的,当此类行为会改变数据的时候可以通过 dispatch 发起一个 action,如果是同步行为会直接通过 Reducers 改变 State ,如果是异步行为(副作用)会先触发 Effects 然后流向 Reducers 最终改变 State。

image.png

app.model({
  namespace: 'count',
  state: {
    record: 0,
    current: 0,
  },
  reducers: {
    add(state) {
      const newCurrent = state.current + 1;
      return { ...state,
        record: newCurrent > state.record ? newCurrent : state.record,
        current: newCurrent,
      };
    },
    minus(state) {
      return { ...state, current: state.current - 1};
    },
  },
  effects: {
    *add(action, { call, put }) {
      yield call(delay, 1000);
      yield put({ type: 'minus' });
    },
  },
  subscriptions: {
    keyboardWatcher({ dispatch }) {
      key('⌘+up, ctrl+up', () => { dispatch({type:'add'}) });
    },
  },
});

对比 vuex 和 dva ,我们可以发现,这两者的书写样式还是比较相似的。也是相对来说比较友好的一种方式。

Hooks 时代的 React 状态管理方案

随着 Hooks 的发布,React 社区又掀起了一波基于 Hooks 的造轮子大潮。

在 React Europe 2020 Conference上,Facebook 软件工程师 Dave McCabe 介绍了一个新的状态管理库Recoil。Recoil 现在还处于实验阶段,当前最新版本为 0.2.0,现在已经在 Facebook 一些内部产品中用于生产环境。

像 Redux、Mobx 本身虽然提供了强大的状态管理能力,但是使用的成本非常高,需要编写大量冗长的代码,另外像异步处理或缓存计算也不是这些库本身的能力,甚至需要借助其他的外部库。并且,它们并不能访问 React内部的调度程序,而 Recoil 在后台使用 React 本身的状态,在未来还能提供并发模式这样的能力。

毕竟是 Facebook 官方推出的状态管理框架,其主打的是高性能以及可以利用 React 内部的调度机制,包括其承诺即将会支持的并发模式,所以还是值得期待的。 设计理念

场景:有 List 和 Canvas 两个组件,List 中一个节点更新后,Canvas 中的节点也对应更新。

image.png

常规则做法是将一个state通过父组件分发给List和Canvas两个组件,显然这样的话每次state改变后 所有节点都会全量更新。

Recoil 本身就是为了解决 React 全局数据流管理的问题,采用分散管理原子状态的设计模式。改变一个原子状态只会渲染特定的子组件,并不会让整个父组件重新渲染。

image.png

核心概念

  • Atoms Atom 是最小状态单元。它们可以被订阅和更新:当它更新时,所有订阅它的组件都会使用新数据重绘;它可以在运行时创建;它也可以在局部状态使用;同一个 Atom 可以被多个组件使用与共享。

  • Selectors Selector 是一个入参为 Atom 或者其他 Selector 的纯函数。当它的上游 Atom 或者 Selector 更新时,它会进行重新计算。Selector 可以像 Atom 一样被组件订阅,当它更新时,订阅它的组件将会重新渲染。 Selector 通常用于计算一些基于原始状态的派生数据。因为不需要使用 reducer 来保证数据的一致性和有效性,所以可以避免冗余数据。我们使用 Atom 保存一点原始状态,其他数据都是在其基础上计算得来的。因为 Selector 会追踪使用它们的组件以及它们依赖的数据状态,所以函数式编程会比较高效。

  • RecoilRoot 组件使用 Recoil 状态之前需要在它的外面包裹一层RecoilRoot组件,可以直接短平快地放在根组件外面。

  • useRecoilState 基于 Atom 和 Selector 的 Hooks,返回对应的 state 和修改 state 的方法。


import {
  atom,
  selector,
  RecoilRoot,
  useRecoilState,
  useRecoilValue
} from "recoil";
import React from "react";

// atom
const fontSizeState = atom({
  key: "fontSizeState",
  default: 14
});

// selector
const fontSizeLabelState = selector({
  key: "fontSizeLabelState",
  get: ({ get }) => {
    const fontSize = get(fontSizeState);
    const unit = "px";

    return `${fontSize}${unit}`;
  }
});

function FontButton() {
  const [fontSize, setFontSize] = useRecoilState(fontSizeState);
  const fontSizeLabel = useRecoilValue(fontSizeLabelState);

  return (
    <>
      <div>当前字号(atom): {fontSize}</div>
      <div>当前字号(selector): {fontSizeLabel}</div>

      <button onClick={() => setFontSize(fontSize + 1)} style={{ fontSize }}>
        增大字号
      </button>
    </>
  );
}

function Text() {
  const [fontSize, setFontSize] = useRecoilState(fontSizeState);
  return <p style={{ fontSize }}>这里的字号会同步增大:{fontSize}</p>;
}

// RecoilRoot
export default function App() {
  return (
    <RecoilRoot>
      <FontButton />
      <Text />
    </RecoilRoot>
  );
}