Redux入门核心概念精讲:单向数据流与状态管理实战
写这篇Redux介绍之前我犹豫了很久。网上讲Redux的文章多到能堆满一个书架大部分都是照搬官方文档的术语什么单一数据源状态只读纯函数……说得都没错但新手看完往往更懵了道理我都懂到底跟我写React有什么关系所以这篇我打算换个写法。先放下术语从你平时写代码最烦躁的那个瞬间讲起一个用户信息要传给三层之外的组件、两个同级组件要共享一个购物车状态、页面一刷新状态全没了……这些场景逼着你去找一套全局状态管理的解决方案而Redux正是目前生态最成熟、思想最经典的那一个。它本质上就是一个可预测的全局状态容器所有共享的数据都放进一个地方改数据必须走一条固定的单向流程任何人都能一眼看清某条数据从哪来、到哪去、为什么变了。这篇定位是Redux系列一我会把核心概念用大白话讲透配合一个能直接跑的最小示例最后附上新手期最容易踩的坑。无论你是刚接触Redux的React开发者还是学过一遍但觉得知其然不知其所以然的人这篇应该都能帮到你。1. 先说痛点为什么要有一个全局状态仓库1.1 组件之间共享状态的三种土办法和它们的极限先想想没有Redux的时候React应用里的共享状态是怎么搞的第一种是props逐层透传。父组件拿到数据往下传给子组件子组件再传给孙组件一路传下去。层次少还行一旦超过两级中间某层组件其实根本用不到这个数据但它必须帮你转发。哪天你给这个prop换个名字所有引用它的地方都要跟着改遗忘一个就是一个隐蔽bug。第二种是状态提升。两个兄弟组件要共用一份数据就把它提到最近的父组件里通过父组件的state管理再分别传给两个子组件。这个方案对父组件和两个直接子组件这种小范围共享是有效的但一旦共享范围跨越了页面、跨越了路由或者层级更深状态提升就成了连续剧式传参——你永远在找最近的公共祖先组件到底是谁。第三种是用全局变量或者一个单独的EventBus。数据是全局的谁都能读谁都能写看起来爽快了但调试的时候四处都会有修改。定位一个问题要在整个项目里搜这个变量在哪被改过搜出来的现场往往已经是翻车现场。上面这三种方案本质上都绕不开两个死穴数据流不可追溯状态一旦乱了很难回溯是谁改的逻辑散落在各个组件里一个业务动作被拆成无数个API调用嵌在组件生命周期里时间久了谁都说不清业务全貌。1.2 Redux的解题思路把状态拿出来集中放到一个仓库里Redux比较狠的做法是干脆把组件里所有的共享状态全部抽出来放到一个应用级的单一Store里。组件不再自带可变的共享状态它只负责两件事——读取Store里的数据以及向Store发一个我想改数据的申请。数据怎么改是Redux内部一套固定的流程说了算。这样做的好处非常实际单一数据源Single Source of Truth整个应用的数据状态在内存里只有一份不存在这个组件里的副本和那个组件里的副本对不上的情况。所有组件读到的是同一份数据数据变了所有订阅者一起更新。状态变更链路固定修改数据只能通过 dispatch(action) → reducer → 生成新state → 通知订阅者 这一条路。每一次状态变化都有迹可循你可以在日志里看到触发了哪个action、老状态是什么、新状态是什么。状态修改逻辑与组件解耦组件不用关心购物车怎么计算总价用户信息怎么校验它只管把action发出去。业务逻辑集中到reducer里天然适合单元测试。很多新手刚一接触Redux会觉得很绕以前直接在组件里this.setState几行代码就完事现在要先写action、要写reducer、要dispatch不是脱裤子放屁吗我的体会是等你的项目里出现多个组件依赖同一个状态、且在同一个事件里这个状态会被好几次修改这种需求时你会感谢这个绕——因为那个直接setState的版本一旦出现状态不对你根本不知道是哪一次setState干的。2. 三大核心概念Action、Reducer、StoreRedux的整个设计拆到底就是三个角色Action描述发生了什么Reducer决定状态怎么变Store把它们串起来并负责状态怎么存。下面逐一拆开讲。2.1 Action一个描述发生了什么的信封Action是Redux里最小的信息单元本质就是一个普通的JavaScript对象长这样{ type: ADD_TODO, payload: { id: 1, text: 学习Redux, completed: false } }约定俗成的规则是type必填用来标识这条action的类型常用大写字母加下划线命名比如ADD_TODO、USER_LOGINpayload是约定俗成的字段用来携带改动需要的额外数据不一定非叫payload你叫data、value都行但团队内最好统一。为什么必须有个type因为reducer得靠它来识别你要干啥。这个type在整个应用里要保证唯一否则两个不同含义的动作用同一个typereducer就分不清谁是谁了。实际项目里常见的做法是抽出actionTypes常量文件统一管理这样还能获得编辑器的自动补全和错误提示。实际应用中我们不会手动每次写一遍完整的对象而是封装成action creator动作工厂函数// actions/todoActions.js export function addTodo(text) { return { type: ADD_TODO, payload: { id: Date.now(), text, completed: false } }; } export function toggleTodo(id) { return { type: TOGGLE_TODO, payload: { id } }; }组件里调用dispatch(addTodo(写一篇Redux入门笔记))即可不用关心action对象长什么样。这个封装让我在后来做项目时深有体会当你的业务逻辑越来越复杂action creator就是你所有动作的官方API清单翻一遍这些函数基本就能知道这个应用支持哪些操作。2.2 Reducer根据Action计算出新状态的纯函数Action只是提了一个申请真正做事的还是reducer。Reducer是一个纯函数接收当前state和一个action返回新的statefunction todoReducer(state [], action) { switch (action.type) { case ADD_TODO: return [...state, action.payload]; case TOGGLE_TODO: return state.map(todo todo.id action.payload.id ? { ...todo, completed: !todo.completed } : todo ); default: return state; } }这里有两个字是雷打不动的铁律纯和不可变。纯函数意味着同样的入参永远得到同样的输出函数内不能产生副作用不能改外部变量、不能发请求、不能写日志、不能操作DOM、不能产生随机数。为什么这么较真因为只有纯函数状态变化才是可预测的。如果reducer里塞了一个Date.now()或者一个API请求同一个action在不同时刻可能算出不同的结果那整个系统的可预测性就崩塌了。不可变意味着不能直接修改原state而是返回一个新的对象/数组。上面的例子里我用展开运算符...创建了新数组、新对象而不是直接state.push(payload)或todo.completed true。为什么要费这个劲因为React的更新机制依赖浅比较——引用变了才认为数据变了。直接改原来的对象React做oldState newState比较时发现引用没变视图就不会刷新。在Redux里这是最容易被初学者忽略、又最致命的一个坑后面第4节我会重点演示错误写法。2.3 Store把上面两个角色串起来的中央仓库Reducer和Action都只是概念和函数真正让它们跑起来的是Store。一个Redux应用只有一个Store它承担四件事import { createStore } from redux; import todoReducer from ./reducers/todoReducer; const store createStore(todoReducer);这行代码执行完你就拥有了一个完整的Store实例它提供三个核心方法方法作用初次接触时的经验提示store.getState()获取当前的应用状态初次可以用它打印状态看效果调试神器store.dispatch(action)派发一个action触发reducer计算新状态整个Redux里唯一修改状态的入口store.subscribe(listener)订阅状态变化state一变就调用listenerReact-Redux内部会自动订阅手写时记得清理当你调用store.dispatch({ type: ADD_TODO, payload: { id: 1, text: hello } })时Redux内部实际发生的事情是把当前状态和这个action交给todoReducer执行拿到返回的新state保存起来然后把所有订阅过的listener都执行一遍通知它们数据变了。可以把这个过程类比成到柜台办业务Store是柜台Reducer是业务流程手册Action是你递过去的申请单。申请单投进去柜台只认手册上写的那套流程按流程走完给你一个结果然后大厅广播叫到XX号了。全程你都不能越过柜台自己进后台翻资料。3. 用代码把整个数据流跑一遍写一个购物车加购示例讲再多概念不如亲手把流程跑一遍。我从零写一个最简的Redux示例不引入React-Redux先用原生Redux 手动订阅的方式让你看见最底层的数据流长什么样。3.1 从零搭建一个最小实验环境先初始化一个Node项目装一个Redux就够了这只是示例所以不用脚手架mkdir redux-demo cd redux-demo npm init -y npm install redux然后创建一个store.js我们把状态、reducer、store都放一个文件里方便你直观看到全貌。真实项目里会拆分成独立的文件但作为系列第一篇先不要纠结目录结构。3.2 定义一个购物车的Reducer并创建Store购物车的需求先简化成三个动作加购、清空。每个商品有id、name、price、count四个字段。// store.js const { createStore } require(redux); // 1. 定义初始状态 const initialState { cart: [], totalCount: 0 }; // 2. 定义reducer接收旧状态 action返回新状态 function cartReducer(state initialState, action) { switch (action.type) { case ADD_ITEM: { const { payload } action; // payload: { id, name, price } const existingItem state.cart.find(item item.id payload.id); // 关键不直接修改state返回新数组 let newCart; if (existingItem) { newCart state.cart.map(item item.id payload.id ? { ...item, count: item.count 1 } : item ); } else { newCart [...state.cart, { ...payload, count: 1 }]; } return { ...state, cart: newCart, totalCount: newCart.reduce((sum, item) sum item.count, 0) }; } case CLEAR_CART: return { ...state, cart: [], totalCount: 0 }; default: return state; } } // 3. 基于reducer创建Store const store createStore(cartReducer); module.exports store;这段代码里你重点关注两处一开始我在每个分支的return前都用了展开运算符确保老对象没有被改动default分支原样返回state这是个非常重要的兜底行为——如果你dispatch了一个reducer不识别的action它就得原样返回不能返回undefined。3.3 手动订阅Store变化模拟组件更新下一步我们模拟组件的角色——订阅Store状态一变就重新渲染。这里我用最原始的方式订阅后打印日志。// index.js const store require(./store); // 模拟组件更新 function render() { const state store.getState(); console.log(当前购物车, JSON.stringify(state.cart)); console.log(商品总件数, state.totalCount); } // 订阅每次状态变化都会执行render store.subscribe(render); // 派发动作 console.log(--- 首次加购 苹果 ---); store.dispatch({ type: ADD_ITEM, payload: { id: 1, name: 苹果, price: 5 } }); console.log(--- 再次加购 苹果 ---); store.dispatch({ type: ADD_ITEM, payload: { id: 1, name: 苹果, price: 5 } }); console.log(--- 加购 香蕉 ---); store.dispatch({ type: ADD_ITEM, payload: { id: 2, name: 香蕉, price: 3 } }); console.log(--- 清空购物车 ---); store.dispatch({ type: CLEAR_CART });跑一下node index.js输出应该类似--- 首次加购 苹果 --- 当前购物车 [{id:1,name:苹果,price:5,count:1}] 商品总件数 1 --- 再次加购 苹果 --- 当前购物车 [{id:1,name:苹果,price:5,count:2}] 商品总件数 2 --- 加购 香蕉 --- 当前购物车 [{id:1,name:苹果,price:5,count:2},{id:2,name:香蕉,price:3,count:1}] 商品总件数 3 --- 清空购物车 --- 当前购物车 [] 商品总件数 0你看到了吗整个过程完全走了dispatch(action) → reducer → getState这一条固定流水线。这就是Redux的单向数据流。每一步的状态变化都能被打印出来如果哪天状态不对你把日志回放一遍就能找到是哪一步出了问题。这是传统setState方案完全做不到的。3.4 在React里怎么用先懂原理再谈抽象有人可能会说我们真实项目里没这么写啊——对因为真实项目里你会用React-Redux它帮你封装了store.subscribe和store.getState的过程你只需要用useSelector读数据、用useDispatch派发actionimport { useSelector, useDispatch } from react-redux; import { addTodo, toggleTodo } from ./actions/todoActions; function TodoList() { const todos useSelector(state state.todos); const dispatch useDispatch(); return ( ul {todos.map(todo ( li key{todo.id} onClick{() dispatch(toggleTodo(todo.id))} {todo.text} /li ))} /ul ); }我刻意把原生Redux和React-Redux分开讲是想强调一个观念React-Redux只是Redux在React环境下的适配器你真正要理解的核心还是那三件事。如果你从一开始就只学React-Redux的hooks遇到深层问题比如selector为什么会重复执行、为什么组件没有重新渲染会很吃力。先把底层的数据流跑通了后面用任何封装层都是水到渠成的事。4. 新手最容易踩的坑附排查清单Redux的坑说来说去就那么几个。我把它们集中整理出来每个都附上错误写法、现象和正确做法这套东西当年是我调试掉了很多头发才总结出来的你现在可以直接抄作业。4.1 坑一直接修改了state这是Redux犯错率排名第一的问题几乎所有新人都会踩。// 错误写法 case ADD_ITEM: state.cart.push(action.payload); return state; // 正确写法 case ADD_ITEM: return { ...state, cart: [...state.cart, action.payload] };直接push、直接赋值、修改嵌套对象属性都属于直接修改state。现象很迷惑有时候状态看起来变了因为数组引用没变打印出来内容确实增加了但React组件不更新因为浅比较认为引用没变。很多新人会往是不是shouldComponentUpdate有问题方向排查绕一大圈才发现问题在reducer。判断准则就一条每次返回值都要是一个全新的引用确保oldState ! newState。如果state结构嵌套比较深可以用扩展运算符一层层展开如果层级复杂到展开起来想骂人建议你把数据结构拍扁一点或者后面升级用Redux Toolkit的Immer机制它可以让你放心写看起来像直接修改的代码内部自动完成不可变更新。4.2 坑二reducer不是纯函数新手特别容易在reducer里面做这些事发API请求、调用Date.now()、Math.random()、localStorage.setItem、console.log。这些都是副作用。你或许觉得我就在reducer里打个日志顺便存一下本地缓存怎么了问题在于这会破坏Redux的可预测性。举个现实的例子你在reducer里根据Date.now()判断是白天就给商品打八折、晚上打七折同一个action白天跑和晚上跑结果不一样一旦线上状态出了偏差你能回忆起来问题出在我半夜下单吗正确姿势是reducer里只做纯计算。那异步请求放哪放在action creator里做异步处理等请求返回之后再dispatch真正的同步action。这是后面讲中间件Redux Thunk / Redux Saga时的核心场景系列后面的文章会展开这里你先记住结论reducer 纯净的计算器。4.3 坑三state结构设计不合理新人刚开始写reducer时很喜欢把所有东西都塞进一个巨大的state对象里层层嵌套state: { user: { profile: { contact: { address: {...} } } } }这样做的代价是你更新最里层的address时外面每一层都要用展开运算符复制一遍代码会长得跟洋葱一样。而且Redux官方推荐把数据扁平化像数据库表一样设计id作为索引多个实体用单独的键存放。这样不仅更新简单selector的查找效率也更高。我的建议是尽量让state保持扁平用id引用而不是对象嵌套。比如{ items: { 1: { name: 苹果 }, 2: { name: 香蕉 } }, itemIds: [1, 2] }比[{ name: 苹果 }, { name: 香蕉 }]在这种需要按id快速增删改查、又要保持顺序的场景里实用得多。4.4 坑四多处创建Store或者滥用多个Store官方规范是一个应用只有一个Store。有些新人会觉得我这边模块A要一套状态模块B要一套状态我创建两个Store各管各的多爽。爽是爽了但你会丢掉Redux最核心的两个能力单一快照无法在一张快照里看清整个应用的完整状态和统一调试Redux DevTools没法同时追踪多个Store。真出现模块A和模块B状态完全独立、没有任何关联需求的情况正确思路不是搞多个Store而是用combineReducers按领域拆分成多个reducer模块再合并成一个根reducerimport { combineReducers, createStore } from redux; import cartReducer from ./reducers/cartReducer; import userReducer from ./reducers/userReducer; const rootReducer combineReducers({ cart: cartReducer, user: userReducer }); const store createStore(rootReducer);这样你既保持了模块的清晰划分又仍然只有一个Store、一份全局状态树。这个教训是个典型的和直觉相反设计——Redux的模块化体现在reducer层而不在store层。4.5 常见报错对照速查表最后把我这些年实际开发中遇到的高频报错整理成一张对照表方便你排查时快速定位报错/现象原因解决方案Reducer did not return any statereducer某个分支忘了return或者返回了undefined检查每个case后面都有returndefault分支必须返回state组件不更新但store里的数据确实变了reducer里直接修改了原state引用没变用展开运算符/Immer实现不可变更新Actions must be plain objectsdispatch了非普通对象比如直接dispatch一个函数异步场景需要先配置中间件Redux Thunk等不能直接dispatch函数Unexpected key xxx found in preloadedStatepreloadedState里的键和reducer处理的对不上检查初始状态结构是否和reducer约定一致相同的action返回了不同的结果reducer里用了随机数、日期等副作用把不确定性逻辑移出reducer另一条经验是刚开始用Redux时一定要开Redux DevTools调试。它能让你看到每一次dispatch的action、前后状态变化、状态差异diff还能时间旅行式地回放操作。我见过很多初学者靠console.log艰难排错效率至少慢三倍。Chrome装了Redux DevTools扩展后配合composeWithDevTools一行配置就可以接入Store系列后文我会专门写这个。最后说一点个人体会。Redux这套设计初学的时候觉得冗长又麻烦但它强迫你养成的单向数据流纯函数不可变数据这些习惯放到今天React生态里依然非常值钱。后面学Redux Toolkit、Zustand、甚至Vue的Pinia你都会发现它们底层思路跟Redux惊人的一致——无非是把这套action → reducer → store的流程做了或多或少的简化封装。所以第一篇把地基打牢后面真的一通百通。如果你看完这篇自己动手把那个购物车示例跑起来恭喜你已经跨过了Redux最难的第一道门槛。下一篇系列文章我计划讲combineReducers 怎么拆分模块以及Redux DevTools的调试实战把真实项目里的Redux长什么样完整铺开。有问题欢迎评论区留言我看到基本都会回。