一、先从问题说起

做前端开发的朋友,基本都跟 Redux 打过交道。用着用着你会发现,业务复杂起来以后,很多动作不是简简单单改个数据就完事。比如用户点了删除按钮,你得先看看这家伙有没有权限;比如每次派发一个动作,你想把前后状态打印下来,方便排查问题;比如某些操作在特定环境里要直接跳过。这些需求如果都堆在组件里写,代码会变得又臭又长。要是每个页面都复制粘贴一套判断逻辑,后面改起来简直就是灾难。

这时候,中间件就能帮你把这些“杂活”从组件里抽出来,集中处理。说白了,Redux 中间件就是给 dispatch 这个动作加一层“过滤网”或“加工流水线”。在动作到达 reducer 之前,你可以先做各种检查、拦截、记录,甚至发新的动作。这就像公司门口有保安,不是谁想进就能进,还得看证件,同时保安还会登记来访记录。是不是很好理解?

二、中间件到底是什么

2.1 一个生活中的类比

想象你去食堂打饭。正常流程是:你直接走到窗口,厨师把菜舀给你。但有一天食堂规定,打饭前必须测量体温、洗手、戴口罩。于是食堂门口加了一个检查岗。这个检查岗不会改变你最终要打的菜,但它会在你和厨师之间多了一道流程。这道流程可以拒绝你进入,也可以放行,还可以顺便记下你几点来的。

Redux 中间件就是这个检查岗。它夹在“派发动作”和“reducer 处理动作”之间。动作是你要打的菜,中间件是检查岗,reducer 是厨师。你可以放行,也可以拦截,还可以在放行前后做额外的事情。

2.2 Redux 中间件的工作流程

Redux 本身提供 applyMiddleware 这个工具来组合中间件。一个中间件的本质是一个函数,这个函数接收 storedispatchgetState,然后返回一个新的 dispatch。这个新的 dispatch 在被调用时,会先执行你自己写的逻辑,再决定是否调用 next(action) 把动作传给下一个中间件或 reducer。

简单说,中间件就是一个“套娃”结构:你执行 dispatch 时,先进第一个中间件,它加工完再传给第二个,最后才到 reducer。每个中间件都可以自己决定要不要往下传。

三、动手写一个日志中间件

3.1 技术栈说明(JavaScript + Redux)

下面所有代码都用原生 JavaScript 和 Redux。你可以在 React 项目里直接用,也可以纯 Node 环境里跑。为了直观,我们假设 store 里存的是用户信息。

3.2 最简日志中间件

我们要实现的目标是:每次派发动作时,在控制台打印旧状态、动作内容和新状态。先看这个中间件长什么样:


// 技术栈:JavaScript + Redux

// 日志中间件:其实是三层箭头函数
const loggerMiddleware = store => next => action => {
  // 派发前打印旧状态和要执行的动作
  console.log('【旧状态】', store.getState());
  console.log('【要执行的动作】', action);

  // 这里调用 next(action) 才会把动作继续往下传
  // 如果不调用,动作就被拦截了
  const result = next(action);

  // 动作处理完之后打印新状态
  console.log('【新状态】', store.getState());

  // 返回结果,方便外部拿值
  return result;
};

就这么几行,一个最简单的日志中间件就完成了。这里要特别说一下,store.getState() 可以随时拿到当前的状态。next 是“下一个中间件”的派发函数,如果这是最后一个中间件,next 就是原始的 store.dispatchresult 是 reducer 返回的新状态,通常我们用不到,但有些场景下需要,所以保留。

为了让这个中间件生效,我们要在创建 store 的时候使用 applyMiddleware。代码如下:


// 技术栈:JavaScript + Redux

// 引入 Redux
const { createStore, applyMiddleware } = require('redux');

// 定义一个极简的 reducer,只处理加一减一
function counterReducer(state = { count: 0 }, action) {
  switch (action.type) {
    case 'ADD':
      return { count: state.count + 1 };
    case 'SUBTRACT':
      return { count: state.count - 1 };
    default:
      return state;
  }
}

// 把上面写的日志中间件和 Redux 结合
const store = createStore(
  counterReducer,
  applyMiddleware(loggerMiddleware)
);

// 派发一个动作,见证中间件里的日志输出
store.dispatch({ type: 'ADD' });
// 控制台会打印:
// 【旧状态】 { count: 0 }
// 【要执行的动作】 { type: 'ADD' }
// 【新状态】 { count: 1 }

你看,中间件就像一个“透明观察窗”,能让你清楚地看到每个动作发生了什么。这种对开发调试特别友好,尤其是当动作顺序错乱或者状态更新不符合预期的时候,打开控制台一看,马上就明白问题出在哪一步。

四、实现业务逻辑拦截

日志记录只是中间件的入门功能。我们更需要的是“拦截”——在动作到达 reducer 之前决定放行还是丢掉。比如权限校验、并发重复点击拦截、灰度发布开关等。

4.1 拦截需求场景

假设你的应用里有个“删除订单”的功能。只有管理员才能删除,普通用户点了应该弹出提示,并且不执行真正的删除 action。通常我们会在点击事件里写 if 判断,但更好的做法是在中间件里统一处理。这样即使以后有其他地方也派发同一个动作,也能被拦截,不会漏掉。

再比如,用户连续快速点击某个“提交”按钮,可能会触发多次网络请求。我们可以在中间件里判断:上一次同类型的动作还没完成时,直接忽略新动作。这种防重复提交的逻辑,放在中间件里非常干净。

4.2 写一个权限校验中间件

我们来写一个权限校验中间件。它检查 action 上有没有带上 roles 需要的角色,然后通过 store.getState() 里的当前用户角色来决定是否放行。如果没权限,就弹个提示,顺便用 console.warn 记一笔账。


// 技术栈:JavaScript + Redux

// 权限校验中间件
const authMiddleware = store => next => action => {
  // 假设每个动作上可以用一个 meta 字段标记需要的角色
  // 比如 { type: 'DELETE_ORDER', meta: { roles: ['admin'] } }
  const requiredRoles = action.meta && action.meta.roles;

  // 如果没有配置需要的角色,直接放行,因为不需要校验
  if (!requiredRoles) {
    return next(action);
  }

  // 从 store 里获取当前登录用户的角色
  const state = store.getState();
  const currentUserRole = state.currentUser ? state.currentUser.role : null;

  // 检查当前用户角色是否在允许列表里
  const hasPermission = requiredRoles.includes(currentUserRole);

  // 没有权限就拦截,不调用 next(action)
  if (!hasPermission) {
    console.warn('【权限拦截】当前用户没有权限执行:', action.type);
    // 这里可以弹提示,也可以派发一个“无权限”的新动作
    store.dispatch({ type: 'ACCESS_DENIED', message: '您没有权限执行该操作' });
    return; // 注意这里直接 return,不调用 next
  }

  // 有权限,放行
  console.log('【权限校验】通过,执行操作:', action.type);
  return next(action);
};

再看一下怎么和前面的日志中间件组合起来用。这里要提醒:中间件的顺序很重要。日志在最外层,意味着它能记录到所有动作(包括拦截后的动作);权限校验在日志里面,也就是说先记录再校验。如果你希望被拦截的动作也不要记日志,那就把权限校验放在日志外层。


// 技术栈:JavaScript + Redux

const { createStore, applyMiddleware } = require('redux');

// 定义初始状态,里面有个模拟的当前用户
const initialState = {
  count: 0,
  currentUser: { name: '小明', role: 'user' } // 普通用户
};

// 一个简单的 reducer
function appReducer(state = initialState, action) {
  switch (action.type) {
    case 'DELETE_ORDER':
      // 这里假装真的删了订单
      return { ...state, count: state.count - 1 };
    case 'ACCESS_DENIED':
      // 记录一下被拒绝的消息
      return { ...state, deniedMessage: action.message };
    default:
      return state;
  }
}

// 组合中间件:日志在外,权限校验在内
const middleware = applyMiddleware(
  loggerMiddleware,
  authMiddleware
);

const store = createStore(appReducer, middleware);

// 派发一个需要 admin 权限的动作
store.dispatch({
  type: 'DELETE_ORDER',
  meta: { roles: ['admin'] }
});

// 因为当前角色是 'user',所以这个动作会被权限中间件拦截
// 控制台会先打印日志(旧状态和动作),然后打印权限拦截警告
// 再然后会派发一个 ACCESS_DENIED 动作,这个动作因为没带 roles,所以会被放行
// 最终 reducer 会处理 ACCESS_DENIED,状态里的 deniedMessage 就会被设置

你可能会问:为什么派发 ACCESS_DENIED 的时候,日志中间件不打印?其实它也会打印,因为 store.dispatch 会触发整个中间件链条。也就是说,在权限中间件内部调用 store.dispatch 时,会重新经历一遍所有中间件。这既是好事也是坑,后面注意事项里我会细说。

4.3 组合多个中间件

Redux 的 applyMiddleware 可以接收多个中间件,它们会按顺序执行。经典的顺序一般是:日志、路由、权限、业务。你可以把日志放最外面,保证任何动作都逃不过它的记录。权限放里面一点,防止无权限动作被日志记成“待执行”,实际上并没有发生。业务中间件放最里面,离 reducer 最近,方便做一些最接近状态变更的操作。

我们再添加一个防重复点击的中间件,作为示例。它利用 store.getState() 里保存的“上一次动作时间”来进行判断。


// 技术栈:JavaScript + Redux

// 防重复提交中间件:同一个动作在 500 毫秒内不能再次触发
const throttleMiddleware = store => next => action => {
  // 如果动作没有声明需要节流,直接放行
  if (!action.meta || !action.meta.throttle) {
    return next(action);
  }

  // 获取上次执行的时间(存储在 reducer 状态里)
  const state = store.getState();
  const lastTime = state.lastActionTime[action.type] || 0;

  const now = Date.now();
  const minInterval = action.meta.throttle || 500; // 默认 500 毫秒

  // 如果距离上次执行的时间太短,拦截掉
  if (now - lastTime < minInterval) {
    console.warn('【防重拦截】操作太频繁,请稍后再试:', action.type);
    return;
  }

  // 记录这次执行的时间,需要派发一个内部动作来更新状态
  // 这一步会重新进入中间件,但因为 UPDATE_TIME 动作没有 meta.throttle,会直接放行
  store.dispatch({
    type: 'UPDATE_LAST_ACTION_TIME',
    actionType: action.type,
    time: now
  });

  return next(action);
};

上面这个示例里,我们把“上次执行时间”保存在 reducer 里,这样状态清晰。当然,更好的做法是用闭包变量存时间,避免污染状态树。不过这里为了完整展示状态流转,就用了 reducer。实际开发中,建议用闭包,代码更简洁。

五、应用场景与优缺点

5.1 适用场景

中间件最擅长处理那些“横切面”的逻辑,也就是与多个业务都相关的逻辑。比如:

  • 统一日志系统:无论谁派发了什么动作,都记录下来,方便还原用户操作路径。
  • 权限控制:在动作进 reducer 之前检查用户角色,避免无权限的人修改状态。
  • 异步请求管理:配合 redux-thunkredux-saga,在动作派发前后发送网络请求。
  • 撤销/重做:把动作压入历史栈,或者从历史栈里恢复状态。
  • 埋点和统计:某些动作触发时上报数据。
  • 灰度发布:根据用户或者开关控制新老功能。

5.2 优点

  • 解耦:业务组件不用写那么多 if 判断,中间件统一搞定。
  • 可复用:写好的中间件可以在不同项目里直接拿来用。
  • 可组合:多个中间件像水管一样串起来,灵活调整顺序。
  • 可插拔:上线时想关闭某个功能,去掉一个中间件就行,不用改业务代码。
  • 集中管理:所有拦截和记录逻辑都集中在一个地方,维护方便。

5.3 缺点与注意事项

  • 顺序敏感:中间件的执行顺序不同,结果可能完全不同。比如日志在权限前面会记录到被拦截的动作,在权限后面就不会。所以设计时要想清楚链路顺序。
  • 误用 dispatch 会导致死循环:在中间件里调用 store.dispatch 会重新进入整个中间件链条。如果没写好条件判断,比如一直派发同一个动作,就会无限循环。所以内部派发新动作时,一定要确保新动作不会被同样的逻辑再次拦截。
  • 调试变难:多层中间件会带来更多的间接层,出错的时候,追踪调用栈可能比普通函数更费劲。不过有了日志中间件,这也算是一种补偿。
  • 不要做重工作:中间件里不要执行耗时的同步操作,比如大数组排序、复杂加密等,会阻塞 UI。如果要做复杂计算,请考虑放到异步任务里,或者用 thunk 里做。
  • 命名冲突:如果多个中间件都往状态里写东西,要小心字段名冲突。最好的办法是中间件只管拦截,不要随意修改状态树。
  • 对纯函数的影响:中间件本身可以是不纯的,但最终交给 reducer 的动作必须是纯的。所以不要在中间件里直接改 action 对象,可以用展开运算符创建新对象。

六、总结

Redux 中间件是一个强大又灵活的设计。它既不像函数式编程那么抽象,也没有什么神秘黑魔法。本质上就是“在派发动作的路上,多安排几个检查站”。日志记录是最简单的入口,让你立刻感受到“看穿一切”的爽快。业务逻辑拦截则是把权限、防重复、埋点等通用逻辑从业务代码中剥离出来,让代码更清爽,也更容易维护。

通过自己动手写一个日志中间件和一个权限拦截中间件,你应该已经掌握了核心套路:store => next => action => { ... }。记住,调不调用 next(action),决定了你是放行还是拦截;调用 store.getState(),让你能读取当前状态;调用 store.dispatch,可以发起新动作,但要小心别踩死循环的坑。

你的项目里不一定非要上复杂的状态管理库,但只要用了 Redux,中间件这一环是绝对值得花时间研究的。它帮你把乱七八糟的“潜规则”变成清晰可见、可配置的代码。以后遇到那些需要全局拦截或者记录的需求,先别急着写 if,想一想:能不能用一个中间件优雅地解决?答案大概率是“能”。