一、先从问题说起
做前端开发的朋友,基本都跟 Redux 打过交道。用着用着你会发现,业务复杂起来以后,很多动作不是简简单单改个数据就完事。比如用户点了删除按钮,你得先看看这家伙有没有权限;比如每次派发一个动作,你想把前后状态打印下来,方便排查问题;比如某些操作在特定环境里要直接跳过。这些需求如果都堆在组件里写,代码会变得又臭又长。要是每个页面都复制粘贴一套判断逻辑,后面改起来简直就是灾难。
这时候,中间件就能帮你把这些“杂活”从组件里抽出来,集中处理。说白了,Redux 中间件就是给 dispatch 这个动作加一层“过滤网”或“加工流水线”。在动作到达 reducer 之前,你可以先做各种检查、拦截、记录,甚至发新的动作。这就像公司门口有保安,不是谁想进就能进,还得看证件,同时保安还会登记来访记录。是不是很好理解?
二、中间件到底是什么
2.1 一个生活中的类比
想象你去食堂打饭。正常流程是:你直接走到窗口,厨师把菜舀给你。但有一天食堂规定,打饭前必须测量体温、洗手、戴口罩。于是食堂门口加了一个检查岗。这个检查岗不会改变你最终要打的菜,但它会在你和厨师之间多了一道流程。这道流程可以拒绝你进入,也可以放行,还可以顺便记下你几点来的。
Redux 中间件就是这个检查岗。它夹在“派发动作”和“reducer 处理动作”之间。动作是你要打的菜,中间件是检查岗,reducer 是厨师。你可以放行,也可以拦截,还可以在放行前后做额外的事情。
2.2 Redux 中间件的工作流程
Redux 本身提供 applyMiddleware 这个工具来组合中间件。一个中间件的本质是一个函数,这个函数接收 store 的 dispatch 和 getState,然后返回一个新的 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.dispatch。result 是 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-thunk或redux-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,想一想:能不能用一个中间件优雅地解决?答案大概率是“能”。
评论
围绕“自定义Redux中间件实现业务逻辑拦截与日志记录的具体步骤”参与讨论