本文翻译自10 Tips for Better Redux Architecture,这篇文章写得真不错,建议能够看看原文。本文从属于笔者的Web 前端入门与最佳实践系列文章。javascript
记得我才开始使用React的时候,尚未Redux呢,那时候只有Flux 架构的理论概念和一堆各类各样的实现。前端
而硝烟以后,如今主流的状态与数据管理框架便数Redux与MobX,其中MobX并不是Flux的实现。Redux如此流行的一个重要乃其普遍的适用性,其不只适用于React,还能应用于包括Angular 2在内的众多前端框架中。java
而MobX与Redux相比,我会更倾向于在简单的UI工程中使用它,换言之,我认为MobX并无提供不少Redux拥有的特性。所以根据你的项目特质来选择合适的状态管理框架才是正道。react
另外,Relay与Falcor也是很流行的能够用于状态管理的工具,不过不一样于Redux或者MobX,它们对于后端有特定的需求,只能基于GraphQL或者Falcor Server,而且Relay中的状态也与服务端持久化存储的某些数据相匹配。换言之,Relay与Falcor并无提供了能够独立运行在客户端而且支持暂时态的状态管理功能,不过若是你须要同时维护Client-Only与Server-Persisted的状态,那么混用Redux与Relay也是个不错的选择。没有最好的工具,只有最合适的工具。git
Redux的做者Dan Abramov提供了很多的优质学习资源,譬如Getting Started with Redux与building-react-applications-with-idiomatic-redux,这两者均可以引领你一步一步地熟悉而且掌握Redux基础,而本文则是从工程实践经验的角度来介绍一些高级的技巧让你可以更好地使用Redux。github
Redux最重要的两个特性能够归纳为:编程
Deterministic View Renders:可肯定/可控的视图渲染redux
Deterministic State Reproduction:可肯定/可控的状态重现
Determinism对于保证应用的可测试性与诊断调试以及Bug修复很是重要。若是你的应用视图与状态是非Deterministic,也就意味着你没法了解到视图与状态是不是有效的,乃至于非Deterministic自己就是一个Bug。不过不少东西本质上就是不可肯定的,譬如用户什么时候产生输入操做以及网络I/O的变化等等。而在这种状况下咱们又该如何保证代码的工做正常呢?那就要从代码隔离下手了。后端
Redux最主要的目标便是在界面渲染或者经过网络获取数据时将状态管理隔离于譬如I/O这样的反作用。在隔离了反作用以后,代码会变得清晰不少,也能更加方便地审阅与测试你的独立于界面操做与网络请求的业务逻辑代码。当你的View Render独立于网络I/O,也就意味着此时的View Render是可肯定的View Render,即在有相同的状态输入的状况下永远会得到相同的结果。不少新手在考虑如何建立视图的时候,会参考一下步骤:这部分须要一个User Model,这样我才能在Model中发起异步请求而后经过Promise来根据名字更新User Component。另外一块须要TODO Items,在获取完毕以后遍历整个数组而后渲染到屏幕上。不过这种模式会存在如下的缺陷:数组
在任意的时间点你并不会拥有用于渲染整个界面的所有数据,不少时候直到组件开始某些操做以前并不会开始数据抓取过程。
不一样的数据获取可能会在不一样的时间点结束,从而会改变View Render序列的渲染顺序。为了能真实了解渲染的顺序,你必需要知道些没法控制的东西:每一个异步请求的完成间隔。就譬如在上述描述的场景中,你并不知道User数据与TODO数据哪一个会先被得到,就好像薛定谔的猫,谁也不知道会是什么结果。
有时候事件监听器也会修改视图状态,这样也会触发另外一轮的渲染,如此递归,从而使得渲染序列变得更加不可知。
直接将数据存储在视图状态中而且容许异步的事件监听器去修改这些视图状态最大的问题在于将数据获取、数据处理与视图渲染混合在一块儿,就比如作一盘乱麻般的意大利面条:
Nondeterminism = Parallel Processing + Shared State
而Flux框架所作的就是严格的隔离规范与顺序操做保证这样一种可控性:
首先,某个时刻咱们都有已知而且固定的状态
而后,根据该状态进行视图渲染,在视图渲染的过程当中并不会受到任何异步监听器的影响,而且保证在相同的状态下会渲染出相同的视图
Event Listeners负责监听用户输入或者网络请求,在接受到异步的触发以后,会将Actions投递到Dispatcher
在某个Action分发以后,状态会根据Action更新到下个已知的状态,仅有经过分发的Action才能修改全局状态
在上述的Flux架构中,视图负责监听用户输入而且将之转化为Action对象,而后Action对象会被投递到Store中。Store在接收到Action以后会根据不一样的Action类型与载荷数据更新应用状态,而后通知View进行重渲染。固然,View并不是惟一的输入与事件触发源,不过咱们也能够经过设置其余的事件监听器来派发其余的Action对象,以下所示:
另外须要注意的是,Flux中的状态更新是事务性的,不一样于简单的调用状态更新函数或者直接操做对象值,任何一个Action对象都是事务记录。能够把它类比于银行中的交易,当你存入一笔钱到你的帐户时,并不会覆盖清除你5分钟以前的交易记录,而会将新的结算信息添加到事务的历史记录中。一个Action对象以下所示:
{ type: ADD_TODO, payload: 'Learn Redux' }
Action对象容许咱们将全部对于对象的操做所有记录下来,而这些记录能够经过可控的方式进行状态重现,也就是说,在相同的初始状态下只要将相同的事务以相同的顺序进行执行,就可以获得相同的状态结果。总结一下,在这样一种可控的状态管理中,咱们可以方便地达成如下目标:
易测试性
方便地Undo/Redo
Time Travel Debugging
可重现性:即便某个状态已经被清除了,可是只要你保留有事务处理的历史记录,你就能够重现该状态
若是你的UI工做流程自己便不复杂,那么还坚持要用Redux就有点大材小用,过分使用了。譬如你打算弄一个剪刀锤子布的小游戏,你以为你须要Undo/Redo功能吗?这种游戏每局差很少一分钟左右吧,即便用户把游戏弄崩溃了,也只要简单的重启游戏便可。当你打算启动一个新项目时,你能够考量如下几点来判断是否须要Redux:
用户工做流比较简单
用户之间并不会有所协做交互
并不须要关心Server Side Events或者WebSockets
对于每一个View而言只须要从单一的数据源抓取数据
这种时候你并不须要花费额外的精力来维持可控的可重现的状态,这时候你就能够尝试使用MobX。不过,随着你的应用的功能增长,复杂度的增长,事务型的状态就有所必要了,而MobX并无提供这种事务型的状态管理:
用户工做流比较复杂
应用中可能包含不少不一样性质的工做流,譬若有普通用户与管理员之分
用户之间会发生交互
使用WebSockets或者SSE
对于单一视图也须要从多个EndPoint抓取数据
这时候你再引入事务型状态管理模型那就棋逢对手,物有所值了。为啥说WebSockets与SSE状态下建议引入Transactional State呢?随着你不断地增长异步I/O源,你会愈来愈难以在模糊的状态管理中理解到底会发生啥。在我我的的理解中,大部分的SAAS产品的UI工做流都挺复杂的,那么此时使用相似于Redux这样的事务型状态管理解决方案可以增长应用的健壮性与可扩展性。
Flux规定了单向的数据流规范与基于Action对象的事务型状态管理,不过Flux并无指明应该如何处理Action对象,这也是Redux独有的特色之一。当咱们初学Redux状态管理时,不可避免地会接触到Reducer的概念,那么何谓Reducer函数呢?
在函数式编程中,常见的两个辅助函数reducer()
与fold()
经常被用于将列表中的某个值转化为某个单一的输出值。这里就给出了一个基于Array.prototype.reduce()
函数的求和Reducer的例子:
const initialState = 0; const reducer = (state = initialState, data) => state + data; const total = [0, 1, 2, 3].reduce(reducer); console.log(total); // 6
不一样于面向某个数组进行操做,Redux提供的Reducer函数主要是面向Action对象流进行操做,咱们上文中有提到Action对象大概是这样的:
{ type: ADD_TODO, payload: 'Learn Redux' }
咱们能够将上述的求和Reducer转化为以下的Redux风格的Reducer:
const defaultState = 0; const reducer = (state = defaultState, action) => { switch (action.type) { case 'ADD': return state + action.payload; default: return state; } };
而后咱们就可使用一系列的Action对象进行测试了:
const actions = [ { type: 'ADD', payload: 0 }, { type: 'ADD', payload: 1 }, { type: 'ADD', payload: 2 } ]; const total = actions.reduce(reducer, 0); // 3
为了能保证可控的状态重现,Reducers必须保证为纯函数,即毫无反作用。所谓纯函数,会有以下特性:
相同的输入会有相同的输出
并无任何的反作用
须要注意的是,在JavaScript中,全部的传入函数中的非原始类型都会以引用形式传递,换言之,若是你传入了某个Object对象,而后在函数中直接改变了其属性值,那么函数外的该Object对象属性值也会发生变化。这也就是所谓的反作用,若是你不知道某个传入函数中的对象的所有操做记录你也就没法知道该函数的真实返回值。这也就致使了整个函数的不可控性与不肯定性。
Reducers应该返回某个新的Object对象,譬如使用Object.assign({}, state, { thingToChange })
来修改而且得到某个对象值。而对于全部的Array参数,它们一样是引用类型传入的,你不能直接使用push()
、pop()
、.shift()
、unshift()
、reverse()
、splice()
或者相似的操做来修改传入的数组。咱们应该使用concat()
函数来代替push()
进行操做,譬如咱们须要添加某个Reducer来处理ADD_CHAT
事件:
const ADD_CHAT = 'CHAT::ADD_CHAT'; const defaultState = { chatLog: [], currentChat: { id: 0, msg: '', user: 'Anonymous', timeStamp: 1472322852680 } }; const chatReducer = (state = defaultState, action = {}) => { const { type, payload } = action; switch (type) { case ADD_CHAT: return Object.assign({}, state, { chatLog: state.chatLog.concat(payload) }); default: return state; } };
如你所见,咱们可使用Object.assign()
或者Array中的concat()
函数来建立新的对象。若是你但愿在JavaScript中如何使用纯函数,那么能够参考master-the-javascript-interview-what-is-a-pure-function这篇文章。
何谓Single Source of Truth?即应用中的全部状态都存放在单一的存储中,任何须要访问状态的地方都须要经过该存储的引用进行访问。固然,对于不一样的业务逻辑/不一样的事物能够设置不一样的状态源,譬如URL能够认为是用户输入与请求参数的Single Source of Truth。而应用中存在某个Configuration Service用于存放全部的API URLs信息。而若是你选用Redux做为状态管理框架,任何对于状态的访问操做都必须经过Redux。换言之,若是你没有使用单一的状态存储源,你可能会失去以下的特性:
可肯定的/可控的视图渲染
可肯定的/可控的状态重现
方便的Undo/Redo
Time Travel Debugging
易测性
咱们但愿Action历史记录中的Action易于追踪易于理解,若是全部的Action都是设置了较短的、通用性的譬如CHANGE_MESSAGE
这样的名字,也就会难以理解APP中到底发生了啥。而若是Action类型能有更具说明性的命名,譬如:CHAT::CHANGE_MESSAGE
,可让咱们在调试的时候更方便地去理解到底发生了啥。所以,咱们建议将全部在Reducer中用到的Action声明归结到一个文件中(文中建议是放置到Reducer文件的首部),而且在文件头部显式声明该类型,这会有助于你:
保证命名的一致性
快速理解Reducer API功能
发现Pull Request中所作的修改
有时候,当我跟别人说你并不能在Reducer中进行相似于ID生成或者获取当前时间等操做时,不少人以一种关怀智障的表情看着我。不过平心而论,最合适的来处理有反作用的逻辑而不是在每次须要构建该Action的时候就写一遍代码的地方当属Action Creator。Action Creator的优势可列举以下:
不须要在不少地方导入声明在Reducer文件中的Action类型常量
在实际的分发Action以前能够进行些简单的计算或者输入转换
减小模板代码的数量
这里咱们尝试使用Action Creator来建立ADD_CHAT
Action对象:
// Action creators can be impure. export const addChat = ({ // cuid is safer than random uuids/v4 GUIDs // see usecuid.org id = cuid(), msg = '', user = 'Anonymous', timeStamp = Date.now() } = {}) => ({ type: ADD_CHAT, payload: { id, msg, user, timeStamp } });
这里咱们使用cuid来为每条聊天记录构建标识,使用Date.now()
来生成时间戳。这些带有反作用的操做是绝对不能运行在Reducer中的,不然就会破坏Reducer的事务型状态管理的特性。
有些人可能会认为使用Action Creator会增长项目中的代码的数量,不过做者认为偏偏相反的是,经过引入Action Creator能够方便地减小Reducer中的代码的数量。譬如咱们须要添加两个功能,容许用户自定义它们的用户名与在线状态,那么咱们可能须要添加两个Action Type到Reducer中:
const chatReducer = (state = defaultState, action = {}) => { const { type, payload } = action; switch (type) { case ADD_CHAT: return Object.assign({}, state, { chatLog: state.chatLog.concat(payload) }); case CHANGE_STATUS: return Object.assign({}, state, { statusMessage: payload }); case CHANGE_USERNAME: return Object.assign({}, state, { userName: payload }); default: return state; } };
对于一个须要处理复杂逻辑的Reducer,这些细微的功能需求可能使其迅速变得庞杂。而做者在平常的工做中会构建不少比这个更加复杂的Reducer,里面充斥着大量重复冗余的代码。而咱们又该如何简化这些代码呢?咱们能够尝试将全部对于简单状态的改变合并到单个Action中完成:
const chatReducer = (state = defaultState, action = {}) => { const { type, payload } = action; switch (type) { case ADD_CHAT: return Object.assign({}, state, { chatLog: state.chatLog.concat(payload) }); // Catch all simple changes case CHANGE_STATUS: case CHANGE_USERNAME: return Object.assign({}, state, payload); default: return state; } };
尽管咱们须要添加额外的注释,这种样子实现也会比原来两个单独的Case处理要减小不少的代码。另外咱们会关注到在Reducer文件中常常出现的关键字:switch,可能你在其余文章中有看到过,应该避免使用switch
语句,特别是须要避免对于落空状态的依赖与处理,不然会让整个Case的处理变得异常肿胀。不过做者认为:
Reducers的一个重要特性就是可组合性,所以咱们彻底能够经过组合Reducer来避免过于肿胀的Case处理出现。当你以为某个Case列表太长的时候,将其切分到不一样的Reducer中便可。
保证每一个Case处理的最后都添加一个返回语句,这样就不会陷入未命中的状况了。
最后,与上面Reducer相匹配的Action Creator,应该遵循以下写法:
export const changeStatus = (statusMessage = 'Online') => ({ type: CHANGE_STATUS, payload: { statusMessage } }); export const changeUserName = (userName = 'Anonymous') => ({ type: CHANGE_USERNAME, payload: { userName } });
如代码所示,Action Creator再也不仅仅单纯地构造出某个Action对象,还将传入的参数转化为了Reducer中所须要的形式,从而简化了Reducer中的代码。
若是你使用譬如Sublime Text或者Atom这样流行的编辑器,它会自动地读取ES6的默认解构值而且帮你在调用某个Action Creator时推导出必须的参数有哪些,这样你就能方便地使用智能提示与自动完成功能了。这一特性可以简化开发者额外的认知压力,他们不用再烦恼于由于老是记不住Payload的形式而不得不常常翻阅源代码了。因此这里推荐你可使用相似于Tern、TypeScript或者Flow这样的类型推导插件或者强类型语言。不过笔者是更推荐使用ES6在解构赋值中提供的默认解构值的特性做为函数签名,而不是使用类型注解,缘由以下:
这样就能够只学习标准的JavaScript而不须要再去学习Flow或者TypeScript这样的JavaScript超集
越少的语法能保证越好的可读性
使用默认值有助于在CI时避免类型错误,也能在运行时避免触发大量的undefined
参数赋值
假如你已经构建好了一个数万行代码的复杂的聊天APP应用,而后亲爱的产品经理跟你说须要添加一个新的Exciting特性进去,而不得不要修改你现有的状态树中的数据结构。不方,这里介绍的Selector便是一种有效地将状态树的结构与应用的其余部分解耦和的工具。
基本上对于我写的每一个Reducer,我都会建立一个对应的Selector来将全部须要用于构建View的变量导出,对于简单的Chat Reducer,可能要以下所写:
export const getViewState = state => Object.assign({}, state, { // return a list of users active during this session recentlyActiveUsers: [...new Set(state.chatLog.map(chat => chat.user))] });
若是你将须要对状态所作的简单的计算放置到Selector中,你能够获得以下的遍历:
遵循了职责分割的原则,减小了Reducer与Components的复杂度
将应用的其余部分与状态结构解耦和
不少的研究都有专门对比过Test-First、Test-After以及No-Test这三个不一样的开发模式,结果都是类似的:大部分研究都代表在开发以前先编写测试用例可以减小40-80%Bug出现的比率。即便在编写本文中全部的例子以前,我都会先写好对应的测试用例。为了不过于简单的测试用例,我编写了以下的工厂方法来产生预测值:
const createChat = ({ id = 0, msg = '', user = 'Anonymous', timeStamp = 1472322852680 } = {}) => ({ id, msg, user, timeStamp }); const createState = ({ userName = 'Anonymous', chatLog = [], statusMessage = 'Online', currentChat = createChat() } = {}) => ({ userName, chatLog, statusMessage, currentChat });
注意,这两个工厂方法中我都提供了默认值,也就保证了我在编写测试用例时仅须要将我感兴趣的参数传入,其余的参数使用默认值便可。
describe('chatReducer()', ({ test }) => { test('with no arguments', ({ same, end }) => { const msg = 'should return correct default state'; const actual = reducer(); const expected = createState(); same(actual, expected, msg); end(); }); });
这里我是使用Tape做为默认的TestRunner,以前我也有2~3年的时间在使用Mocha与Jasmine,还有不少其余的框架。你应该可以注意到我倾向于使用嵌套的测试用例编写方式,多是受以前使用Mocha与Jasmine较多的影响,我习惯先在外层声明某个测试组件,而后在内层声明组件的传入参数。这里我分别给出对于Action Creator、Selector的测试用例。
describe('addChat()', ({ test }) => { test('with no arguments', ({ same, end}) => { const msg = 'should add default chat message'; const actual = pipe( () => reducer(undefined, addChat()), // make sure the id and timestamp are there, // but we don't care about the values state => { const chat = state.chatLog[0]; chat.id = !!chat.id; chat.timeStamp = !!chat.timeStamp; return state; } )(); const expected = Object.assign(createState(), { chatLog: [{ id: true, user: 'Anonymous', msg: '', timeStamp: true }] }); same(actual, expected, msg); end(); }); test('with all arguments', ({ same, end}) => { const msg = 'should add correct chat message'; const actual = reducer(undefined, addChat({ id: 1, user: '@JS_Cheerleader', msg: 'Yay!', timeStamp: 1472322852682 })); const expected = Object.assign(createState(), { chatLog: [{ id: 1, user: '@JS_Cheerleader', msg: 'Yay!', timeStamp: 1472322852682 }] }); same(actual, expected, msg); end(); }); });
这个例子有个颇有趣的地方在于,addChat()
Action Creator自己非纯函数。也就是说除非你传入特定的值进行覆盖,不然你并不能预测它到底会生成怎样的属性。所以在这里咱们使用了pipe函数,将那些咱们不关注的变量值忽略掉。咱们只会关心这些值是否存在,可是并不会关心这些值到底如何。对于pipe函数的详细用法能够以下所示:
const pipe = (...fns) => x => fns.reduce((v, f) => f(v), x); const fn1 = s => s.toLowerCase(); const fn2 = s => s.split('').reverse().join(''); const fn3 = s => s + '!' const newFunc = pipe(fn1, fn2, fn3); const result = newFunc('Time'); // emit!
describe('getViewState', ({ test }) => { test('with chats', ({ same, end }) => { const msg = 'should return the state needed to render'; const chats = [ createChat({ id: 2, user: 'Bender', msg: 'Does Barry Manilow know you raid his wardrobe?', timeStamp: 451671300000 }), createChat({ id: 2, user: 'Andrew', msg: `Hey let's watch the mouth, huh?`, timeStamp: 451671480000 }), createChat({ id: 1, user: 'Brian', msg: `We accept the fact that we had to sacrifice a whole Saturday in detention for whatever it was that we did wrong.`, timeStamp: 451692000000 }) ]; const state = chats.map(addChat).reduce(reducer, reducer()); const actual = getViewState(state); const expected = Object.assign(createState(), { chatLog: chats, recentlyActiveUsers: ['Bender', 'Andrew', 'Brian'] }); same(actual, expected, msg); end(); }); });