告别繁琐,我写了个状态库替大家吊打 Redux

218 阅读10分钟

大家好,我是 OQ(Open Quoll),是一名 Redux 的老用户,也是 React Mug 的作者。Redux 是一款基于 Flux 架构的老牌函数式状态库,核心是以 reducer 纯函数进行的状态变化。React Mug 则是一款简洁的函数式状态库,核心也是纯函数。今天我来通过对比实现几个常见案例的方式尝试 “吊打” Redux,为大家带来一些函数式状态管理的新思路。

(友情提示:本文中部分 API 仅适用于 react-mug@0.3.x 及以下版本。)

计数器 案例实现

首先以状态管理的经典案例 “计数器” 着手对比。计数器,即展示一个计数并实现增 1 减 1 的简单逻辑。Redux 的实现如下,为了不遮掩 Redux 的实力,这里和后续直接使用 Redux Toolkit:

import { configureStore, createSlice } from '@reduxjs/toolkit';
import { Provider, useDispatch, useSelector } from 'react-redux';

interface CounterState {
  value: number;
}

const initialCounterState: CounterState = {
  value: 0,
};

const counterSlice = createSlice({
  name: 'counter',
  initialState: initialCounterState,
  reducers: {
    increment: (state) => {
      state.value += 1;
    },
    decrement: (state) => {
      state.value -= 1;
    },
  },
});

const { increment, decrement } = counterSlice.actions;

const counterReducer = counterSlice.reducer;

const selectCounter = (state: RootState) => state.counter;

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

type RootState = ReturnType<typeof store.getState>;
type AppDispatch = typeof store.dispatch;

const useAppSelector = useSelector.withTypes<RootState>();
const useAppDispatch = useDispatch.withTypes<AppDispatch>();

function Counter() {
  const dispatch = useAppDispatch();
  const { value: count } = useAppSelector(selectCounter);

  return (
    <>
      <div>Count: {count}</div>
      <button onClick={() => dispatch(increment())}>+1</button>
      <button onClick={() => dispatch(decrement())}>-1</button>
    </>
  );
}

export function App() {
  return (
    <Provider store={store}>
      <Counter />
    </Provider>
  );
}

代码中:

  • 通过帮助函数 createSlice 创建了 reducer 纯函数 counterReducer 以及 action 创建函数 incrementdecrement
  • 通过 configureStorecounterReducer 注册进了 store
  • 通过 selectCounter 定义了从根状态中选择计数器状态的方式,
  • 通过 useAppSelectorselectCounter<Counter> 中访问了计数器状态,
  • 通过 useAppDispatch 返回的函数 dispatch 派发 incrementdecrement 的返回值触发了状态变化,
  • store 注册进了 <Provider>

接下来是 React Mug 实现的同等逻辑:

import { check, construction, Mug, useOperator, w } from 'react-mug';

interface CounterState {
  value: number;
}

const counterMug: Mug<CounterState> = {
  [construction]: {
    value: 0,
  },
};

const increment = w((state: CounterState) => ({ value: state.value + 1 }));
const decrement = w((state: CounterState) => ({ value: state.value - 1 }));

function Counter() {
  const mug = counterMug;

  const { value: count } = useOperator(check, mug);

  return (
    <>
      <div>Count: {count}</div>
      <button onClick={() => increment(mug)}>+1</button>
      <button onClick={() => decrement(mug)}>-1</button>
    </>
  );
}

export function App() {
  return <Counter />;
}

代码中:

  • counterMug 声明了盛装 计数器状态 的容器。Mug,马克杯,是 React Mug 盛装状态的基本模块,[construction] 字段既表示了对象是一个 Mug 也设置了 Mug 所盛装状态的初始值,而帮助类型 Mug 只辅助定义类型。
  • 帮助函数 w 根据传入的纯函数生成了写操作器(Write operator)incrementdecrement。当调用写操作器时,如果入参为 Mug,那么写操作器会把 Mug 里的状态传入内部的纯函数中调用,然后把返回值写回 Mug,之后再返回这个 Mug。如果入参为状态(不包含 [construction] 字段),那么写操作器的行为就会完全等价于内部的纯函数,可以当作纯函数直接调用或自测。
  • check 是内置的读操作器(Read operator),作用是直接返回 Mug 里的状态。自定义的读操作器则可以通过帮助函数 r 根据传入的纯函数生成。当调用读操作器时,如果入参为 Mug,那么读操作器会把 Mug 里的状态传入内部的纯函数调用,然后直接透传返回返回值。如果入参为状态(不包含 [construction] 字段),那么读操作器的行为就会完全等价于内部的纯函数,可以当作纯函数直接调用或自测。
  • useOperatorcheck<Counter> 中持续读取了 counterMug 里的状态。

计数器 实现对比

下面对比分析一下两种实现。

首先是在 函数式 上。所谓函数式,即函数式编程,是以纯函数为主体的编程方式,通过纯函数没有副作用的特性可以写出 易推倒 易测试的代码,从根源上减少 bug。而没有副作用是指一个函数的返回值完全由入参决定并且不会改变任何外部变量。

在 Redux 实现中,counterReducer 是一个 reducer 纯函数,但是由于 createSlice 内部集成了 Immer 的缘故,定义上有点难看出纯函数的样子,阅读代码时需要意识到 Immer 的存在,需要提醒自己 state.value += 1; 不是直接改变了字段,而是生成了等同于改变了旧状态这个字段的新状态,不能完全以阅读纯函数的思维阅读代码推倒逻辑,不过问题不大。行为上则还是 reducer 纯函数,仍然可以单独调用或自测。

在 React Mug 实现中,incrementdecrementcheck 都是操作器,定义是基于纯函数的,可以直接以阅读纯函数的思维阅读代码推倒逻辑。当调用操作器的入参为状态(不包含 [construction] 字段)时,行为就等同于了自身定义内部的纯函数,可以单独调用或自测。

因此在函数式上两者都善用了纯函数 易推倒 易测试 的优点,算是打了个平手。

然后是在 简洁度 上。

在 Redux 实现中,核心概念有 reducer、action、dispatch、selector、store、store provider 和 状态。概念间的关系有,reducer 注册进 store,store 注册进 store provider,store 提供根状态,selector 从根状态选择分支状态,以及 dispatch 一个 action 到 reducer 结合老状态生成新状态存入 store 根状态的对应分支上。

在 React Mug 实现中,核心概念只有 Mug、写操作器、读操作器 和 状态。概念间的关系只有,操作器对 Mug 里的状态进行读写操作。

因此在简洁度上 React Mug 胜出了一筹。

最后是在 包大小 上。

在 Redux 实现中,用到了 @reduxjs/toolkit@2.2.7react-redux@9.1.2 两个包,minify 并 gzip 后的包大小总和为 18.1KB。即使把 @reduxjs/toolkit@2.2.7 换成 redux@5.0.1,包大小总和也有 5.53KB,同时意味着更多的模板代码。

在 React Mug 实现中,用到了 react-mug@0.1.5 一个包,minify 并 gzip 后的包大小为 2.02KB。

因此在包大小上 React Mug 也胜出了一筹。

报名表单 案例实现

接下来用一个稍微复杂一点常见案例 “报名表单” 再来对比一下,避免因为刚刚的案例相对简单引起可能的对比不周全。报名表单里共有三个字段 “姓名”、“年龄” 和 “报名原因”,在编辑一个字段时会实时校验字段的值并显隐错误提示。点击提交按钮会校验全部字段,有校验不通过的字段时提示修改,在校验全部通过时调用接口提交数据,然后重置全部状态并提示成功。

报名表单.gif

这里用到的接口调用如下:

async function submitEnrollment(params: {
  name: string;
  age: number;
  reason: string;
}): Promise<void> {
  // 调用接口提交数据
}

首先是 Redux 的实现:

import { configureStore, createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
import { Provider, useDispatch, useSelector } from 'react-redux';

interface EnrollState {
  name: string;
  nameError?: string;

  age: number;
  ageError?: string;

  reason: string;
  reasonError?: string;

  submitting: boolean;
  submitHint?: string;
  submitSuccess?: string;
  submitFailure?: string;
}

const initialEnrollState: EnrollState = {
  name: '',
  age: 30,
  reason: '',
  submitting: false,
};

const submit = createAsyncThunk<void, void, { state: RootState }>(
  'enroll/submit',
  async (_arg0, { getState, dispatch, rejectWithValue }) => {
    dispatch(validateAllFields());

    const enrollState = selectEnroll(getState());
    if (hasError(enrollState)) {
      setTimeout(() => dispatch(clearSubmitHint()), 3000);
      return rejectWithValue('请按照要求填写,谢谢');
    }

    await submitEnrollment(enrollState);
    setTimeout(() => dispatch(clearSubmitSuccess()), 3000);
  }
);

function hasError(state: EnrollState) {
  return state.nameError || state.ageError || state.reasonError;
}

const enrollSlice = createSlice({
  name: 'enroll',
  initialState: initialEnrollState,
  reducers: {
    setName(state, action: PayloadAction<string>) {
      state.name = action.payload;
      enrollSlice.caseReducers.validateName(state);
    },

    validateName(state) {
      const { name } = state;

      if (!name) {
        state.nameError = '姓名不可以为空';
        return;
      }

      if (name.length > 10) {
        state.nameError = '姓名长度不能超过 10 个字符';
        return;
      }

      delete state.nameError;
    },

    setAge(state, action: PayloadAction<string>) {
      state.age = Math.floor(+action.payload);
      enrollSlice.caseReducers.validateAge(state);
    },

    validateAge(state) {
      const { age } = state;

      if (age < 18) {
        state.ageError = '年龄不能小于 18 周岁';
        return;
      }

      if (age > 60) {
        state.ageError = '年龄不能大于 60 周岁';
        return;
      }

      delete state.ageError;
    },

    setReason(state, action: PayloadAction<string>) {
      state.reason = action.payload;
    },

    validateReason(state) {
      const { reason } = state;

      if (!reason) {
        state.reasonError = '报名原因不能为空';
        return;
      }

      if (reason.length > 300) {
        state.reasonError = '报名原因请控制在 300 字以内';
        return;
      }

      delete state.reasonError;
    },

    validateAllFields(state) {
      enrollSlice.caseReducers.validateName(state);
      enrollSlice.caseReducers.validateAge(state);
      enrollSlice.caseReducers.validateReason(state);
    },

    setSubmitHint(state, action: PayloadAction<string>) {
      state.submitHint = action.payload;
    },

    clearSubmitHint(state) {
      delete state.submitHint;
    },

    clearSubmitSuccess(state) {
      delete state.submitSuccess;
    },
  },

  extraReducers: (builder) => {
    builder.addCase(submit.pending, (state) => {
      state.submitting = true;
      delete state.submitHint;
      delete state.submitSuccess;
      delete state.submitFailure;
    });

    builder.addCase(submit.fulfilled, () => {
      return {
        ...initialEnrollState,
        submitSuccess: '恭喜您,报名成功',
      };
    });

    builder.addCase(submit.rejected, (state, action) => {
      state.submitting = false;
      if (typeof action.payload === 'string') {
        state.submitHint = action.payload;
      } else {
        state.submitFailure = '报名失败,请稍后再试';
      }
    });
  },
});

const { setName, setAge, setReason, validateAllFields, clearSubmitHint, clearSubmitSuccess } =
  enrollSlice.actions;

const enrollReducer = enrollSlice.reducer;

const selectEnroll = (rootState: RootState) => rootState.enroll;

const store = configureStore({
  reducer: {
    enroll: enrollReducer,
  },
});

type RootState = ReturnType<typeof store.getState>;
type AppDispatch = typeof store.dispatch;

const useAppSelector = useSelector.withTypes<RootState>();
const useAppDispatch = useDispatch.withTypes<AppDispatch>();

function Enroll() {
  const dispatch = useAppDispatch();
  const {
    name,
    nameError,
    age,
    ageError,
    reason,
    reasonError,
    submitting,
    submitHint,
    submitSuccess,
    submitFailure,
  } = useAppSelector(selectEnroll);

  return (
    <form
      onSubmit={(e) => {
        e.preventDefault();
        dispatch(submit());
      }}
    >
      <div>
        <label>姓名:</label>
        <input type="text" value={name} onChange={(e) => dispatch(setName(e.target.value))} />
        {nameError && <div>{nameError}</div>}
      </div>

      <div>
        <label>年龄:</label>
        <input type="number" value={age} onChange={(e) => dispatch(setAge(e.target.value))} />
        {ageError && <div>{ageError}</div>}
      </div>

      <div>
        <label>报名原因:</label>
        <div>
          <textarea value={reason} onChange={(e) => dispatch(setReason(e.target.value))} />
        </div>
        {reasonError && <div>{reasonError}</div>}
      </div>

      <button type="submit" disabled={submitting}>
        提交报名信息
      </button>
      {submitHint && <div>{submitHint}</div>}
      {submitSuccess && <div>{submitSuccess}</div>}
      {submitFailure && <div>{submitFailure}</div>}
    </form>
  );
}

export function App() {
  return (
    <Provider store={store}>
      <Enroll />
    </Provider>
  );
}

代码中:

  • 通过帮助函数 createAsyncThunk 创建了一个 thunk action 创建函数 submit,定义了提交表单数据这一异步行为中的 action 派发逻辑,
  • 通过帮助函数 createSlice 创建了 reducer 和余下的 action 创建函数,
  • 通过 enrollSlice.caseReducers 复用了一些状态变化,
  • 通过 extraReducers 定义了在 submit 行为开始、成功 和 失败 时的状态变化。

紧接着是 React Mug 实现的同等逻辑:

import { check, construction, Mug, none, r, swirl, useOperator, w } from 'react-mug';

interface EnrollState {
  name: string;
  nameError?: string;

  age: number;
  ageError?: string;

  reason: string;
  reasonError?: string;

  submitting: boolean;
  submitHint?: string;
  submitSuccess?: string;
  submitFailure?: string;
}

const enrollMug: Mug<EnrollState> = {
  [construction]: {
    name: '',
    age: 30,
    reason: '',
    submitting: false,
  },
};

const setName = w((state: EnrollState, name: string) => ({
  ...state,
  name,
  nameError: validateName(name),
}));

function validateName(name: string) {
  if (!name) {
    return '姓名不可以为空';
  }

  if (name.length > 10) {
    return '姓名长度不能超过 10 个字符';
  }
}

const setAge = w((state: EnrollState, age: string) => {
  const newAge = Math.floor(+age);
  return {
    ...state,
    age: newAge,
    ageError: validateAge(newAge),
  };
});

function validateAge(age: number) {
  if (age < 18) {
    return '年龄不能小于 18 周岁';
  }

  if (age > 60) {
    return '年龄不能大于 60 周岁';
  }
}

const setReason = w((state: EnrollState, reason: string) => ({
  ...state,
  reason,
  reasonError: validateReason(reason),
}));

function validateReason(reason: string) {
  if (!reason) {
    return '报名原因不能为空';
  }

  if (reason.length > 300) {
    return '报名原因请控制在 300 字以内';
  }
}

const validateAllFields = w((state: EnrollState) => ({
  ...state,
  nameError: validateName(state.name),
  ageError: validateAge(state.age),
  reasonError: validateReason(state.reason),
}));

const clearAllSubmitMessages = w((state: EnrollState) => ({
  ...state,
  submitHint: undefined,
  submitSuccess: undefined,
  submitFailure: undefined,
}));

const reset = w((_state: EnrollState) => enrollMug[construction]);

const hasError = r((state: EnrollState) => {
  return state.nameError || state.ageError || state.reasonError;
});

async function submit() {
  const mug = enrollMug;

  validateAllFields(mug);
  if (hasError(mug)) {
    swirl(mug, { submitHint: '请按照要求填写,谢谢' });
    setTimeout(() => swirl(mug, { submitHint: none }), 3000);
    return;
  }

  try {
    swirl(clearAllSubmitMessages(mug), { submitting: true });
    await submitEnrollment(check(mug));
    swirl(reset(mug), { submitSuccess: '恭喜您,报名成功' });
    setTimeout(() => swirl(mug, { submitSuccess: none }), 3000);
  } catch {
    swirl(mug, { submitFailure: '报名失败,请稍后再试' });
  } finally {
    swirl(mug, { submitting: false });
  }
}

function Enroll() {
  const mug = enrollMug;

  const {
    name,
    nameError,
    age,
    ageError,
    reason,
    reasonError,
    submitting,
    submitHint,
    submitSuccess,
    submitFailure,
  } = useOperator(check, mug);

  return (
    <form
      onSubmit={(e) => {
        e.preventDefault();
        submit();
      }}
    >
      <div>
        <label>姓名:</label>
        <input type="text" value={name} onChange={(e) => setName(mug, e.target.value)} />
        {nameError && <div>{nameError}</div>}
      </div>

      <div>
        <label>年龄:</label>
        <input type="number" value={age} onChange={(e) => setAge(mug, e.target.value)} />
        {ageError && <div>{ageError}</div>}
      </div>

      <div>
        <label>报名原因:</label>
        <div>
          <textarea value={reason} onChange={(e) => setReason(mug, e.target.value)} />
        </div>
        {reasonError && <div>{reasonError}</div>}
      </div>

      <button type="submit" disabled={submitting}>
        提交报名信息
      </button>
      {submitHint && <div>{submitHint}</div>}
      {submitSuccess && <div>{submitSuccess}</div>}
      {submitFailure && <div>{submitFailure}</div>}
    </form>
  );
}

export function App() {
  return <Enroll />;
}

代码中:

  • enrollMug 声明了 盛装表单 状态的容器。
  • 通过帮助函数 wr 分别自定义了写操作器和读操作器。
  • submit 异步函数定义了提交表单数据的逻辑。
  • swirl 是内置的写操作器,作用是以 合并逻辑 修改 Mug 里的状态,可以有效处理比较琐碎的写操作,避免过多不必要地自定义写操作器。
  • 借助写操作器返回入参 Mug 的特性,以嵌套调用进行了连续的写操作。

报名表单 实现对比

下面再次对比分析一下两种实现。

首先是在 函数式 上。尽管两者都还是善用了纯函数 易推倒 易测试 的优点,但是在异步行为中对状态变化的处理方式上展现出了不同。

Redux 的方式是给 thunk action 预制了一些钩子,比如 submit.pendingsubmit.fulfilledsubmit.rejected,用户通过给这些钩子巧妙地挂上处理逻辑来完成琐碎的状态变化,这需要编码 thunk action 创建函数 和 reducer 时在两者之间来回切换和对照,需要培养习惯。

Redux 的方式是给到了 swirl 写操作器灵活应对琐碎的状态变化,用户继续按照常规习惯编码异步函数即可,比较自然。

但是不同没有高低,因此在函数式上两者还是打了个平手。

然后是在 简洁度 上,React Mug 还是因为概念更少、概念间的关系更简单,继续保持优势。

最后是在 包大小 上,React Mug 也继续保持优势。

结语

以上便是 Redux 与 React Mug 在常见案例上的对比了,后者以更加简洁的理念实践和完善了函数式状态管理,希望大家喜欢。对 React Mug 感兴趣的话还可以移步至 把状态装进马克杯里,给你一个不烫手的状态库 进一步阅读,欢迎交流,谢谢!