Redux的全家桶与最佳实践
ArlethaBonn
8年前
<p style="text-align:center"><img src="https://simg.open-open.com/show/b46834f8335534e55f928a88e8a17759.png"></p> <p>Redux 的第一次代码提交是在 2015 年 5 月底(也就是一年多前的样子),那个时候 React 的最佳实践还不是明晰,作为一个 View 层,有人会用 backbone 甚至是 angular 和它搭配,也有人觉得这层 View 功能已经足够强大,简单地搭配一些 utils 就直接上。后来便有了 FLUX 的演讲,React 社区开始注意到这种新的类似函数式编程的理念,Redux 也作为 FLUX 的一种变体开始受到关注,再后来顺理成章地得到 React 的『钦点』,作者也加入了 非死book 从事 React 的开发。生态圈经过了这一年的成熟,现在很多第三方库已经非常完善,所以这里想介绍一下目前 Redux 的一些最佳实践。</p> <h2><strong>一、复习一下 Redux 的基本概念</strong></h2> <p>首先我们复习一下 Redux 的基本概念, <strong>如果你已经很熟悉了,就直接跳过这一章吧。</strong></p> <p>Redux 把界面视为一种状态机,界面里的所有状态、数据都可以由一个状态树来描述。所以对于界面的任何变更都简化成了状态机的变化:</p> <pre> <code class="language-javascript">(State, Input) => NewState</code></pre> <p>这其中切分成了三个阶段:</p> <ol> <li>action</li> <li>reducer</li> <li>store</li> </ol> <p>所谓的 action,就是用一个对象描述发生了什么,Redux 中一般使用一个纯函数,即 <strong>actionCreator</strong> 来生成 action 对象。</p> <pre> <code class="language-javascript">// actionCreator => action // 这是一个纯函数,只是简单地返回 action function somethingHappened(data){ return { type: 'foo', data: data } }</code></pre> <p>随后这个 action 对象和当前的状态树 state 会被传入到 reducer 中,产生一个新的 state</p> <pre> <code class="language-javascript">//reducer(action, state) => newState function reducer(action, state){ switch(action.type){ case 'foo': return { data: data }; default: return state; } }</code></pre> <p>store 的作用就是储存 state,并且监听其变化。</p> <p>简单地说就是你可以这样产生一个 store :</p> <pre> <code class="language-javascript">import { createStore } from 'redux' //这里的 reducer 就是刚才的 Reducer 函数 let store = createStore(reducer);</code></pre> <p>然后你可以通过 dispatch 一个 action 来让它改变状态:</p> <pre> <code class="language-javascript">store.getState(); // {} store.dispatch(somethingHappened('aaa')); store.getState(); // { data: 'aaa'}</code></pre> <p>好了,这就是 Redux 的全部功能。对的,它就是如此简单,以至于它本体只有 3KB 左右的代码,因为它只是实现了一个简单的状态机而已,任何稍微有点编程能力的人都能很快写出这个东西。至于和 React 的结合,则需要 <a href="/misc/goto?guid=4959715294982608728" rel="nofollow,noindex"> react-redux </a> 这个库,这里我们就不讲怎么用了。</p> <h2><strong>二、Redux 的一些痛点</strong></h2> <p>大体上,Redux 的数据流是这样的:</p> <pre> <code class="language-javascript">界面 => action => reducer => store => react => virtual dom => 界面</code></pre> <p>每一步都很纯净,看起来很美好对吧?对于一些小小的尝试性质的 DEMO 来说确实很美好。但其实当应用变得越来越大的时候,这其中存在诸多问题:</p> <ol> <li>如何优雅地写异步代码?(从简单的数据请求到复杂的异步逻辑)</li> <li>状态树的结构应该怎么设计?</li> <li>如何避免重复冗余的 actionCreator?</li> <li>状态树中的状态越来越多,结构越来越复杂的时候,和 react 的组件映射如何避免混乱?</li> <li>每次状态的细微变化都会生成全新的 state 对象,其中大部分无变化的数据是不用重新克隆的,这里如何提高性能?</li> </ol> <p>你以为我会在下面一一介绍这些问题是怎么解决的?还真不是,这里大部分问题的回答都可以在官方文档中看到: <a href="/misc/goto?guid=4959715295082724191" rel="nofollow,noindex"> 技巧 | Redux 中文文档 </a> ,文档里讲得已经足够详细(有些甚至详细得有些啰嗦了)。所以下面只挑 Redux 生态圈里几个比较成熟且流行的组件来讲讲。</p> <h2><strong>三、Redux 异步控制</strong></h2> <p>官方文档里介绍了一种很朴素的异步控制中间件 <a href="/misc/goto?guid=4959715295167665202" rel="nofollow,noindex"> redux-thunk </a> (如果你还不了解中间件的话请看 <a href="/misc/goto?guid=4959715295265438042" rel="nofollow,noindex"> Middleware | Redux 中文文档 </a> ,事实上 redux-thunk 的代码很简单,简单到只有几行代码:</p> <pre> <code class="language-javascript">function createThunkMiddleware(extraArgument) { return ({ dispatch, getState }) => next => action => { if (typeof action === 'function') { return action(dispatch, getState, extraArgument); } return next(action); }; }</code></pre> <p>它其实只干了一件事情,判断 actionCreator 返回的是不是一个函数,如果不是的话,就很普通地传给下一个中间件(或者 reducer);如果是的话,那么把 <strong>dispatch</strong> 、 <strong>getState</strong> 、 <strong>extraArgument</strong> 作为参数传入这个函数里,实现异步控制。</p> <p>比如我们可以这样写:</p> <pre> <code class="language-javascript">//普通action function foo(){ return { type: 'foo', data: 123 } } //异步action function fooAsync(){ return dispatch => { setTimeout(_ => dispatch(123), 3000); } }</code></pre> <p>但这种简单的异步解决方法在应用变得复杂的时候,并不能满足需求,反而会使 action 变得十分混乱。</p> <p>举个比较简单的例子,我们现在要实现『图片上传』功能,用户点击开始上传之后,显示出加载效果,上传完毕之后,隐藏加载效果,并显示出预览图;如果发生错误,那么显示出错误信息,并且在2秒后消失。</p> <p>用普通的 redux-thunk 是这样写的:</p> <pre> <code class="language-javascript">function upload(data){ return dispatch => { // 显示出加载效果 dispatch({ type: 'SHOW_WAITING_MODAL' }); // 开始上传 api.upload(data) .then(res => { // 成功,隐藏加载效果,并显示出预览图 dispatch({ type: 'PRELOAD_IMAGES', data: res.images }); dispatch({ type: 'HIDE_WAITING_MODAL' }); }) .catch(err => { // 错误,隐藏加载效果,显示出错误信息,2秒后消失 dispatch({ type: 'SHOW_ERROR', data: err }); dispatch({ type: 'HIDE_WAITING_MODAL' }); setTimeout(_ => dispatch({ type: 'HIDE_ERROR' }), 2000); }) } }</code></pre> <p>这里的问题在于,一个异步的 upload action 执行过程中会产生好几个新的 action,更可怕的是这些新的 action 也是包含逻辑的(比如要判断是否错误),这直接导致异步代码中到处都是 <strong>dispatch(action)</strong> ,是很不可控的情况。如果还要进一步考虑取消、超时、队列的情况,就更加混乱了。</p> <p>所以我们需要更强大的异步流控制,这就是 <a href="/misc/goto?guid=4959715295354310825" rel="nofollow,noindex"> GitHub - yelouafi/redux-saga: An alternative side effect model for Redux apps </a> 。下面我们来看看如果换成 redux-saga 的话会怎么样:</p> <pre> <code class="language-javascript">import { take, put, call, delay } from 'redux-saga/effects' // 上传的异步流 function *uploadFlow(action) { // 显示出加载效果 yield put({ type: 'SHOW_WAITING_MODAL' }); // 简单的 try-catch try{ const response = yield call(api.upload, action.data); yield put({ type: 'PRELOAD_IMAGES', data: response.images }); yield put({ type: 'HIDE_WAITING_MODAL' }); }catch(err){ yield put({ type: 'SHOW_ERROR', data: err }); yield put({ type: 'HIDE_WAITING_MODAL' }); yield delay(2000); yield put({ type: 'HIDE_ERROR' }); } } function* watchUpload() { yield* takeEvery('BEGIN_REQUEST', uploadFlow) }</code></pre> <p>是不是规整很多呢?redux-saga 允许我们使用简单的 <strong>try-catch</strong> 来进行错误处理,更神奇的是竟然可以直接使用 <strong>delay</strong> 来替代 <strong>setTimeout</strong> 这种会造成回调和嵌套的不优雅的方法。</p> <p>本质上讲,redux-sage 提供了一系列的『副作用(side-effects)方法』,比如以下几个:</p> <ol> <li><strong>put</strong> (产生一个 action)</li> <li><strong>call</strong> (阻塞地调用一个函数)</li> <li><strong>fork</strong> (非阻塞地调用一个函数)</li> <li><strong>take</strong> (监听且只监听一次 action)</li> <li><strong>delay</strong> (延迟)</li> <li><strong>race</strong> (只处理最先完成的任务)</li> </ol> <p>并且通过 Generator 实现对于这些副作用的管理,让我们可以用同步的逻辑写一个逻辑复杂的异步流。</p> <p>下面这个例子出自于 <a href="/misc/goto?guid=4959715295440109839" rel="nofollow,noindex"> 官方文档 </a> ,实现了一个对于请求的队列,即让程序同一时刻只会进行一个请求,其它请求则排队等待,直到前一个请求结束:</p> <pre> <code class="language-javascript">import { buffers } from 'redux-saga'; import { take, actionChannel, call, ... } from 'redux-saga/effects'; function* watchRequests() { // 1- 创建一个针对请求事件的 channel const requestChan = yield actionChannel('REQUEST'); while (true) { // 2- 从 channel 中拿出一个事件 const {payload} = yield take(requestChan); // 3- 注意这里我们使用的是阻塞的函数调用 yield call(handleRequest, payload); } } function* handleRequest(payload) { ... }</code></pre> <p>更多关于 redux-saga 的内容,请参考 <a href="/misc/goto?guid=4959715295531670504" rel="nofollow,noindex"> Read Me | redux-saga </a> (中文文档: <a href="/misc/goto?guid=4959715295619537970" rel="nofollow,noindex"> 自述 | Redux-saga 中文文档 </a> )。</p> <h2><strong>四、提高 selector 的性能</strong></h2> <p>把 react 与 redux 结合的时候,react-redux 提供了一个极其重要的方法: <strong>connect</strong> ,它的作用就是选取 redux store 中的需要的 <strong>state</strong> 与 <strong>dispatch</strong> , 交由 <strong>connect</strong> 去绑定到 react 组件的 props 中:</p> <pre> <code class="language-javascript">import { connect } from 'react-redux'; import { toggleTodo } from '../actions' import TodoList from '../components/TodoList' // 我们需要向 TodoList 中注入一个名为 todos 的 prop // 它通过以下这个函数从 state 中提取出来: const mapStateToProps = (state) => { // 下面这个函数就是所谓的selector todos: state.todos.filter(i => i.completed) // 其它props... } const mapDispatchToProps = (dispatch) => { onTodoClick: (id) => { dispatch(toggleTodo(id)) } } // 绑定到组件上 const VisibleTodoList = connect( mapStateToProps, mapDispatchToProps )(TodoList) export default VisibleTodoList</code></pre> <p>在这里需要指定哪些 state 属性被注入到 component 的 props 中,这是通过一个叫 <strong>selector</strong> 的函数完成的。</p> <p>上面这个例子存在一个明显的性能问题,每当组件有任何更新时都会调用一次 <strong>state.todos.filter</strong> 来计算 <strong>todos</strong> ,但我们实际上只需要在 <strong>state.todos</strong> 变化时重新计算即可,每次更新都重算一遍是非常不合适的做法。下面介绍的这个 <a href="/misc/goto?guid=4959715295702341395" rel="nofollow,noindex"> reselect </a> 就能帮你省去这些没必要的重新计算。</p> <p>你可能会注意到, <strong>selector</strong> 实际上就是一个『 <strong>纯函数』</strong> :</p> <pre> <code class="language-javascript">selector(state) => some props</code></pre> <p><strong>而纯函数是具有可缓存性的,即对于同样的输入参数,永远会得到相同的输出值</strong> (如果对这个不太熟悉的同学可以参考我之前写的 <a href="/misc/goto?guid=4959677007733323890" rel="nofollow,noindex">JavaScript函数式编程(一) - 一只码农的技术日记 - 知乎专栏</a> ,reselect 的原理就是如此,每次调用 selector 函数之前,它会判断参数与之前缓存的是否有差异,若无差异,则直接返回缓存的结果,反之则重新计算:</p> <pre> <code class="language-javascript">import { createSelector } from 'reselect'; var state = { a: 100 } var naiveSelector = state => state.a; // mySelector 会缓存输入 a 对应的输出值 var mySelector = createSelector( naiveSelector, a => { console.log('做一次乘法!!!'); return a * a; } ) console.log(mySelector(state)); // 第一次计算,需要做一次乘法 console.log(mySelector(state)); // 输入值未变化,直接返回缓存的结果 console.log(mySelector(state)); // 同上 state.a = 5; // 改变 a 的值 console.log(mySelector(state)); // 输入值改变,做一次乘法 console.log(mySelector(state)); // 输入值未变化,直接返回缓存的结果 console.log(mySelector(state)); // 同上</code></pre> <p>上面的输出值是:</p> <pre> <code class="language-javascript">做一次乘法!!! 10000 10000 10000 做一次乘法!!! 25 25 25</code></pre> <p>之前那个关于 todos 的范例可以这样改,就可以避免 todos 数组被重复计算的性能问题:</p> <pre> <code class="language-javascript">import { createSelector } from 'reselect'; import { connect } from 'react-redux'; import { toggleTodo } from '../actions' import TodoList from '../components/TodoList' const todoSelector = createSelector( state => state.todos, todos => todos.filter(i => i.completed) ) const mapStateToProps = (state) => { todos: todoSelector // 其它props... } const mapDispatchToProps = (dispatch) => { onTodoClick: (id) => { dispatch(toggleTodo(id)) } } // 绑定到组件上 const VisibleTodoList = connect( mapStateToProps, mapDispatchToProps )(TodoList) export default VisibleTodoList</code></pre> <p>更多可以参考 <a href="/misc/goto?guid=4959715295702341395" rel="nofollow,noindex"> GitHub - reactjs/reselect: Selector library for Redux </a></p> <h2><strong>五、减少冗余代码</strong></h2> <p>redux 中的 action 一般都类似这样写:</p> <pre> <code class="language-javascript">function foo(data){ return { type: 'FOO', data: data } } //或者es6写法: var foo = data => ({ type: 'FOO', data})</code></pre> <p>当应用越来越大之后,action 的数量也会大大增加,为每个 action 对象显式地写上 type 和 data 或者其它属性会造成大量的代码冗余,这一块是完全可以优化的。</p> <p>比如我们可以写一个最简单的 actionCreator:</p> <pre> <code class="language-javascript">function actionCreator(type){ return function(data){ return { type: type, data: data } } } var foo = actionCreator('FOO'); foo(123); // {type: 'FOO', data: 123} </code></pre> <p><a href="/misc/goto?guid=4959715295821309276" rel="nofollow,noindex">redux-actions </a> 就可以为我们做这样的事情,除了上面这种朴素的做法,它还有其它比较好用的功能,比如它提供的 createActions 方法可以接受不同类型的参数,以产生不同效果的 actionCreator,下面这个范例来自官方文档:</p> <pre> <code class="language-javascript">import { createActions } from 'redux-actions'; const { actionOne, actionTwo, actionThree } = createActions({ // 函数类型 ACTION_ONE: (key, value) => ({ [key]: value }), // 数组类型 ACTION_TWO: [ (first) => first, // payload (first, second) => ({ second }) // meta ], // 最简单的字符串类型 }, 'ACTION_THREE'); actionOne('key', 1)); //=> //{ // type: 'ACTION_ONE', // payload: { key: 1 } //} actionTwo('Die! Die! Die!', 'It\'s highnoon~'); //=> //{ // type: 'ACTION_TWO', // payload: ['Die! Die! Die!'], // meta: { second: 'It\'s highnoon~' } //} actionThree(76); //=> //{ // type: 'ACTION_THREE', // payload: 76, //}</code></pre> <p>更多可以参考 <a href="/misc/goto?guid=4959715295821309276" rel="nofollow,noindex"> GitHub - acdlite/redux-actions: Flux Standard Action utilities for Redux. </a></p> <h2><strong>六、更多</strong></h2> <p>我总是觉得,轮子永远是造不完的,也是看不完的,这么多轮子的取舍其实终究还是要看开发者的能力以及实际项目的需求,有时你或许根本不需要这些东西,有时甚至连 Redux 本身也是多余的,毕竟,第三方库其实也是另一种意义上的『复杂度』嘛。</p> <p> </p> <p>来自:https://zhuanlan.zhihu.com/p/22405838</p> <p> </p>