注:这篇是17年1月的文章,搬运自本人 blog...
https://github.com/BuptStEve/...
零、前言
在上一篇中介绍了 Redux 的各项基础 api。接着一步一步地介绍如何与 React 进行结合,并从引入过程中遇到的各个痛点引出 react-redux 的作用和原理。
不过目前为止还都是纸上谈兵,在日常的开发中最常见异步操作(如通过 ajax、jsonp 等方法 获取数据),在学习完上一篇后你可能依然没有头绪。因此本文将深入浅出地对于 redux 的进阶用法进行介绍。
一、中间件(MiddleWare)
It provides a third-party extension point between dispatching an action, and the moment it reaches the reducer. ———— by Dan Abramov
这是 redux 作者对 middleware 的描述,middleware 提供了一个分类处理 action 的机会,在 middleware 中你可以检阅每一个流过的 action,挑选出特定类型的 action 进行相应操作,给你一次改变 action 的机会。
说得好像很吊...不过有啥用咧...?
1. 日志应用场景[[2]]
因为改变 store 的唯一方法就是 dispatch 一个 action,所以有时需要将每次 dispatch 操作都打印出来作为操作日志,这样一来就可以很容易地看出是哪一次 dispatch 导致了异常。
1.1. 第一次尝试:强行怼...
const action = addTodo('Use Redux');
console.log('dispatching', action);
store.dispatch(action);
console.log('next state', store.getState());
显然这种在每一个 dispatch 操作的前后都手动加代码的方法,简直让人不忍直视...
1.2. 第二次尝试:封装 dispatch
聪明的你一定马上想到了,不如将上述代码封装成一个函数,然后直接调用该方法。
function dispatchAndLog(store, action) {
console.log('dispatching', action);
store.dispatch(action);
console.log('next state', store.getState());
}
dispatchAndLog(store, addTodo('Use Redux'));
矮油,看起来不错哟。
不过每次使用都需要导入这个额外的方法,一旦不想使用又要全部替换回去,好麻烦啊...
1.3. 第三次尝试:猴子补丁(Monkey Patch)
在此暂不探究为啥叫猴子补丁而不是什么其他补丁。
简单来说猴子补丁指的就是:以替换原函数的方式为其添加新特性或修复 bug。
let next = store.dispatch; // 暂存原方法
store.dispatch = function dispatchAndLog(action) {
console.log('dispatching', action);
let result = next(action); // 应用原方法
console.log('next state', store.getState());
return result;
};
这样一来我们就“偷梁换柱”般的为原 dispatch 添加了输出日志的功能。
1.4. 第四次尝试:隐藏猴子补丁
目前看起来很不错,然鹅假设我们又要添加别的一个中间件,那么代码中将会有重复的 let next = store.dispatch;
代码。
对于这个问题我们可以通过参数传递,返回新的 dispatch 来解决。
function logger(store) {
const next = store.dispatch;
return function dispatchAndLog(action) {
console.log('dispatching', action);
const result = next(action); // 应用原方法
console.log('next state', store.getState());
return result;
}
}
store.dispatch = logger(store);
store.dispatch = anotherMiddleWare(store);
注意到最后应用中间件的代码其实就是一个链式的过程,所以还可以更进一步优化绑定中间件的过程。
function applyMiddlewareByMonkeypatching(store, middlewares) {
// 因为传入的是原对象引用的值,slice 方法会生成一份拷贝,
// 所以之后调用的 reverse 方法不会改变原数组
middlewares = middlewares.slice();
// 我们希望按照数组原本的先后顺序触发各个中间件,
// 所以最后的中间件应当最接近原本的 dispatch,
// 就像洋葱一样一层一层地包裹原 dispatch
middlewares.reverse();
// 在每一个 middleware 中变换 store.dispatch 方法。
middlewares.forEach((middleware) =>
store.dispatch = middleware(store);
);
}
// 先触发 logger,再触发 anotherMiddleWare 中间件(类似于 koa 的中间件机制)
applyMiddlewareByMonkeypatching(store, [ logger, anotherMiddleWare ]);
so far so good~! 现在不仅隐藏了显式地缓存原 dispatch 的代码,而且调用起来也很优雅~,然鹅这样就够了么?
1.5. 第五次尝试:移除猴子补丁
注意到,以上写法仍然是通过 store.dispatch = middleware(store);
改写原方法,并在中间件内部通过 const next = store.dispatch;
读取当前最新的方法。
本质上其实还是 monkey patch,只不过将其封装在了内部,不过若是将 dispatch 方法通过参数传递进来,这样在 applyMiddleware 函数中就可以暂存 store.dispatch(而不是一次又一次的改写),岂不美哉?
// 通过参数传递
function logger(store, next) {
return function dispatchAndLog(action) {
// ...
}
}
function applyMiddleware(store, middlewares) {
// ...
// 暂存原方法
let dispatch = store.dispatch;
// middleware 中通过闭包获取 dispatch,并且更新 dispatch
middlewares.forEach((middleware) =>
dispatch = middleware(store, dispatch);
);
}
接着应用函数式编程的 curry 化(一种使用匿名单参数函数来实现多参数函数的方法。),还可以再进一步优化。(其实是为了使用 compose 将中间件函数先组合再绑定)
function logger(store) {
return function(next) {
return function(action) {
console.log('dispatching', action);
const result = next(action); // 应用原方法
console.log('next state', store.getState());
return result;
}
}
}
// -- 使用 es6 的箭头函数可以让代码更加优雅更函数式... --
const logger = (store) => (next) => (action) => {
console.log('dispatching', action);
const result = next(action); // 应用原方法
console.log('next state', store.getState());
return result;
};
function applyMiddleware(store, middlewares) {
// ...
let dispatch = store.dispatch;
middlewares.forEach((middleware) =>
dispatch = middleware(store)(dispatch); // 注意调用了两次
);
// ...
}
以上方法离 Redux 中最终的 applyMiddleware 实现已经很接近了,
1.6. 第六次尝试:组合(compose,函数式方法)
在 Redux 的最终实现中,并没有采用我们之前的 slice + reverse
的方法来倒着绑定中间件。而是采用了 map + compose + reduce
的方法。
先来说这个 compose 函数,在数学中以下等式十分的自然。
f(g(x)) = (f o g)(x)
f(g(h(x))) = (f o g o h)(x)
用代码来表示这一过程就是这样。
// 传入参数为函数数组
function compose(...funcs) {
// 返回一个闭包,
// 将右边的函数作为内层函数执行,并将执行结果作为外层函数再次执行
return funcs.reduce((a, b) => (...args) => a(b(...args)));
}
不了解 reduce 函数的人可能对于以上代码会感到有些费解,举个栗子来说,有函数数组 [f, g, h]传入 compose 函数执行。
- 首次 reduce 执行的结果是返回一个函数
(...args) => f(g(...args))
- 接着该函数作为下一次 reduce 函数执行时的参数
a
,而参数 b 是h
- 再次执行时
h(...args)
作为参数传入 a,即最后返回的还是一个函数(...args) => f(g(h(...args)))
因此最终版 applyMiddleware 实现中并非依次执行绑定,而是采用函数式的思维,将作用于 dispatch 的函数首先进行组合,再进行绑定。(所以要中间件要 curry 化)
// 传入中间件函数的数组
function applyMiddleware(...middlewares) {
// 返回一个函数的原因在 createStore 部分再进行介绍
return (createStore) => (reducer, preloadedState, enhancer) => {
const store = createStore(reducer, preloadedState, enhancer)
let dispatch = store.dispatch
let chain = [] // 保存绑定了 middlewareAPI 后的函数数组
const middlewareAPI = {
getState: store.getState,
dispatch: (action) => dispatch(action)
}
chain = middlewares.map(middleware => middleware(middlewareAPI))
// 使用 compose 函数按照从右向左的顺序绑定(执行顺序是从左往右)
dispatch = compose(...chain)(store.dispatch)
return {
...store,
dispatch
}
}
}
// store -> { getState } 从传递整个 store 改为传递部分 api
const logger = ({ getState }) => (next) => (action) => {
console.log('dispatching', action);
const result = next(action); // 应用原方法
console.log('next state', getState());
return result;
};
综上如下图所示整个中间件的执行顺序是类似于洋葱一样首先按照从外到内的顺序执行 dispatch 之前的中间件代码,在 dispatch(洋葱的心)执行后又反过来,按照从内到左外的顺序执行 dispatch 之后的中间件代码。
桥都麻袋!
你真的都理解了么?
- 在之前的实现中直接传递 store,为啥在最终实现中传递的是 middlewareAPI?
- middlewareAPI 里的 dispatch 是为啥一个匿名函数而不直接传递 dispatch?
- 如下列代码所示,如果在中间件里不用 next 而是调用 store.dispatch 会怎样呢?
const logger = (store) => (next) => (action) => {
console.log('dispatching', action);
// 调用原始 dispatch,而不是上一个中间件传进来的
const result = store.dispatch(action); // <- 这里
console.log('next state', store.getState());
return result;
};
1.7. middleware 中调用 store.dispatch[[6]]
正常情况下,如图左,当我们 dispatch 一个 action 时,middleware 通过 next(action) 一层一层处理和传递 action 直到 redux 原生的 dispatch。如果某个 middleware 使用 store.dispatch(action) 来分发 action,就发生了右图的情况,相当于从外层重新来一遍,假如这个 middleware 一直简单粗暴地调用 store.dispatch(action),就会形成无限循环了。(其实就相当于猴子补丁没补上,不停地调用原来的函数)
因此最终版里不是直接传递 store,而是传递 getState 和 dispatch,传递 getState 的原因是可以通过 getState 获取当前状态。并且还将 dispatch 用一个匿名函数包裹 dispatch: (action) => dispatch(action)
,这样不但可以防止 dispatch 被中间件修改,而且只要 dispatch 更新了,middlewareAPI 中的 dispatch 也会随之发生变化。
1.8. createStore 进阶
在上一篇中我们使用 createStore 方法只用到了它前两个参数,即 reducer 和 preloadedState,然鹅其实它还拥有第三个参数 enhancer。
enhancer 参数可以实现中间件、时间旅行、持久化等功能,Redux 仅提供了 applyMiddleware 用于应用中间件(就是 1.6. 中的那个)。
在日常使用中,要应用中间件可以这么写。
import {
createStore,
combineReducers,
applyMiddleware,
} from 'redux';
// 组合 reducer
const rootReducer = combineReducers({
todos: todosReducer,
filter: filterReducer,
});
// 中间件数组
const middlewares = [logger, anotherMiddleWare];
const store = createStore(
rootReducer,
initialState,
applyMiddleware(...middlewares),
);
// 如果不需要 initialState 的话也可以忽略
const store = createStore(
rootReducer,
applyMiddleware(...middlewares),
);
在上文 applyMiddleware 的实现中留了个悬念,就是为什么返回的是一个函数,因为 enhancer 被定义为一个高阶函数,接收 createStore 函数作为参数。
/**
* 创建一个 redux store 用于保存状态树,
* 唯一改变 store 中数据的方法就是对其调用 dispatch
*
* 在你的应用中应该只有一个 store,想要针对不同的部分状态响应 action,
* 你应该使用 combineReducers 将多个 reducer 合并。
*
* @param {函数} reducer 不多解释了
* @param {对象} preloadedState 主要用于前后端同构时的数据同步
* @param {函数} enhancer 很牛逼,可以实现中间件、时间旅行,持久化等
* ※ Redux 仅提供 applyMiddleware 这个 Store Enhancer ※
* @return {Store}
*/
export default function createStore(reducer, preloadedState, enhancer) {
if (typeof preloadedState === 'function' && typeof enhancer === 'undefined') {
enhancer = preloadedState
preloadedState = undefined
}
if (typeof enhancer !== 'undefined') {
if (typeof enhancer !== 'function') {
throw new Error('Expected the enhancer to be a function.')
}
// enhancer 是一个高阶函数,接收 createStore 函数作为参数
return enhancer(createStore)(reducer, preloadedState)
}
// ...
// 后续内容推荐看看参考资料部分的【Redux 莞式教程】
}
总的来说 Redux 有五个 API,分别是:
- createStore(reducer, [initialState], enhancer)
- combineReducers(reducers)
- applyMiddleware(...middlewares)
- bindActionCreators(actionCreators, dispatch)
- compose(...functions)
createStore 生成的 store 有四个 API,分别是:
- getState()
- dispatch(action)
- subscribe(listener)
- replaceReducer(nextReducer)
以上 API 我们还没介绍的应该就剩 bindActionCreators 了。这个 API 其实就是个语法糖起了方便地给 action creator 绑定 dispatch 的作用。
// 一般写法
function mapDispatchToProps(dispatch) {
return {
onPlusClick: () => dispatch(increment()),
onMinusClick: () => dispatch(decrement()),
};
}
// 使用 bindActionCreators
import { bindActionCreators } from 'redux';
function mapDispatchToProps(dispatch) {
return bindActionCreators({
onPlusClick: increment,
onMinusClick: decrement,
// 还可以绑定更多函数...
}, dispatch);
}
// 甚至如果定义的函数输入都相同的话还能更加简洁
export default connect(
mapStateToProps,
// 直接传一个对象,connect 自动帮你绑定 dispatch
{ onPlusClick: increment, onMinusClick: decrement },
)(App);
二、异步操作
下面让我们告别干净的同步世界,进入“肮脏”的异步世界~。
在函数式编程中,异步操作、修改全局变量等与函数外部环境发生的交互叫做副作用(Side Effect)
通常认为这些操作是邪恶(evil)肮脏(dirty)的,并且也是导致 bug 的源头。
因为与之相对的是纯函数(pure function),即对于同样的输入总是返回同样的输出的函数,使用这样的函数很容易做组合、测试等操作,很容易验证和保证其正确性。(它们就像数学公式一般准确)
2.1. 通知应用场景[[3]]
现在有这么一个显示通知的应用场景,在通知显示后5秒钟隐藏该通知。
首先当然是编写 action
- 显示:SHOW_NOTIFICATION
- 隐藏:HIDE_NOTIFICATION
2.1.1. 最直观的写法
最直观的写法就是首先显示通知,然后使用 setTimeout 在5秒后隐藏通知。
store.dispatch({ type: 'SHOW_NOTIFICATION', text: 'You logged in.' });
setTimeout(() => {
store.dispatch({ type: 'HIDE_NOTIFICATION' });
}, 5000);
然鹅,一般在组件中尤其是展示组件中没法也没必要获取 store,因此一般将其包装成 action creator。
// actions.js
export function showNotification(text) {
return { type: 'SHOW_NOTIFICATION', text };
}
export function hideNotification() {
return { type: 'HIDE_NOTIFICATION' };
}
// component.js
import { showNotification, hideNotification } from '../actions';
this.props.dispatch(showNotification('You just logged in.'));
setTimeout(() => {
this.props.dispatch(hideNotification());
}, 5000);
或者更进一步地先使用 connect 方法包装。
this.props.showNotification('You just logged in.');
setTimeout(() => {
this.props.hideNotification();
}, 5000);
到目前为止,我们没有用任何 middleware 或者别的概念。
2.1.2. 异步 action creator
上一种直观写法有一些问题
- 每当我们需要显示一个通知就需要手动先显示,然后再手动地让其消失。其实我们更希望通知到时间后自动地消失。
- 通知目前没有自己的 id,所以有些场景下存在竞争条件(race condition),即假如在第一个通知结束前触发第二个通知,当第一个通知结束时,第二个通知也会被提前关闭。
所以为了解决以上问题,我们可以为通知加上 id,并将显示和消失的代码包起来。
// actions.js
const showNotification = (text, id) => ({
type: 'SHOW_NOTIFICATION',
id,
text,
});
const hideNotification = (id) => ({
type: 'HIDE_NOTIFICATION',
id,
});
let nextNotificationId = 0;
export function showNotificationWithTimeout(dispatch, text) {
const id = nextNotificationId++;
dispatch(showNotification(id, text));
setTimeout(() => {
dispatch(hideNotification(id));
}, 5000);
}
// component.js
showNotificationWithTimeout(this.props.dispatch, 'You just logged in.');
// otherComponent.js
showNotificationWithTimeout(this.props.dispatch, 'You just logged out.');
为啥 showNotificationWithTimeout
函数要接收 dispatch
作为第一个参数呢?
虽然通常一个组件都拥有触发 dispatch 的权限,但是现在我们想让一个外部函数(showNotificationWithTimeout)来触发 dispatch,所以需要将 dispatch 作为参数传入。
2.1.3. 单例 store
可能你会说如果有一个从其他模块中导出的单例 store,那么是不是同样也可以不传递 dispatch 以上代码也可以这样写。
// store.js
export default createStore(reducer);
// actions.js
import store from './store';
// ...
let nextNotificationId = 0;
export function showNotificationWithTimeout(text) {
const id = nextNotificationId++;
store.dispatch(showNotification(id, text));
setTimeout(() => {
store.dispatch(hideNotification(id));
}, 5000);
}
// component.js
showNotificationWithTimeout('You just logged in.');
// otherComponent.js
showNotificationWithTimeout('You just logged out.');
这样看起来似乎更简单一些,不过墙裂不推荐这样的写法。主要的原因是这样的写法强制让 store 成为一个单例。这样一来要实现服务器端渲染(Server Rendering)将十分困难。因为在服务端,为了让不同的用户得到不同的预先获取的数据,你需要让每一个请求都有自己的 store。
并且单例 store 也将让测试变得困难。当测试 action creator 时你将无法自己模拟一个 store,因为它们都引用了从外部导入的那个特定的 store,所以你甚至无法从外部重置状态。
2.1.4. redux-thunk 中间件
首先声明 redux-thunk 这种方案对于小型的应用来说足够日常使用,然鹅对于大型应用来说,你可能会发现一些不方便的地方。(例如对于 action 需要组合、取消、竞争等复杂操作的场景)
首先来明确什么是 thunk...
A thunk is a function that wraps an expression to delay its evaluation.
简单来说 thunk 就是封装了表达式的函数,目的是延迟执行该表达式。不过有啥应用场景呢?
目前为止,在上文中的 2.1.2. 异步 action creator 部分,最后得出的方案有以下明显的缺点
- 我们必须将 dispatch 作为参数传入。
- 这样一来任何使用了异步操作的组件都必须用 props 传递 dispatch(不管有多深...)。我们也没法像之前各种同步操作一样使用 connect 函数来绑定回调函数,因为 showNotificationWithTimeout 函数返回的不是一个 action。
-
此外,在日常使用时,我们还需要区分哪些函数是同步的 action creator,那些是异步的 action creator。(异步的需要传 dispatch...)
- 同步的情况: store.dispatch(actionCreator(payload))
- 异步的情况: asyncActionCreator(store.dispatch, payload)
计将安出?
其实问题的本质在于 Redux “有眼不识 function”,目前为止 dispatch 函数接收的参数只能是 action creator 返回的普通的 action。所以如果我们让 dispatch 对于 function 网开一面,走走后门潜规则一下不就行啦~
实现方式很简单,想想第一节介绍的为 dispatch 添加日志功能的过程。
// redux-thunk 源码
function createThunkMiddleware(extraArgument) {
return ({ dispatch, getState }) => next => action => {
if (typeof action === 'function') {
return action(dispatch, getState, extraArgument);
}
return next(action);
};
}
const thunk = createThunkMiddleware();
thunk.withExtraArgument = createThunkMiddleware;
export default thunk;
以上就是 redux-thunk 的源码,就是这么简单,判断下如果传入的 action 是函数的话,就执行这个函数...(withExtraArgument 是为了添加额外的参数,详情见 redux-thunk 的 README.md)
- 这样一来如果我们 dispatch 了一个函数,redux-thunk 会传给它一个 dispatch 参数,我们就利用 thunk 解决了组件中不方便获取 dispatch 的问题。
- 并且由于 redux-thunk 拦截了函数,也可以防止 reducer 接收到函数而出现异常。
添加了 redux-thunk 中间件后代码可以这么写。
// actions.js
// ...
let nextNotificationId = 0;
export function showNotificationWithTimeout(text) {
// 返回一个函数
return function(dispatch) {
const id = nextNotificationId++;
dispatch(showNotification(id, text));
setTimeout(() => {
dispatch(hideNotification(id));
}, 5000);
};
}
// component.js 像同步函数一样的写法
this.props.dispatch(showNotificationWithTimeout('You just logged in.'));
// 或者 connect 后直接调用
this.props.showNotificationWithTimeout('You just logged in.');
2.2. 接口应用场景
目前我们对于简单的延时异步操作的处理已经了然于胸了,现在让我们来考虑一下通过 ajax 或 jsonp 等接口来获取数据的异步场景。
很自然的,我们会发起一个请求,然后等待请求的响应(请求可能成功或是失败)。
即有基本的三种状态和与之对应的 action:
- 请求开始的 action:isFetching 为真,UI 显示加载界面
{ type: 'FETCH_POSTS_REQUEST' }
- 请求成功的 action:isFetching 为假,隐藏加载界面并显示接收到的数据
{ type: 'FETCH_POSTS_SUCCESS', response: { ... } }
- 请求失败的 action:isFetching 为假,隐藏加载界面,可能保存失败信息并在 UI 中显示出来
{ type: 'FETCH_POSTS_FAILURE', error: 'Oops' }
按照这个思路,举一个简单的栗子。
// Constants
const FETCH_POSTS_REQUEST = 'FETCH_POSTS_REQUEST';
const FETCH_POSTS_SUCCESS = 'FETCH_POSTS_SUCCESS';
const FETCH_POSTS_FAILURE = 'FETCH_POSTS_FAILURE';
// Actions
const requestPosts = (id) => ({
type: FETCH_POSTS_REQUEST,
payload: id,
});
const receivePosts = (res) => ({
type: FETCH_POSTS_SUCCESS,
payload: res,
});
const catchPosts = (err) => ({
type: FETCH_POSTS_FAILURE,
payload: err,
});
const fetchPosts = (id) => (dispatch, getState) => {
dispatch(requestPosts(id));
return api.getData(id)
.then(res => dispatch(receivePosts(res)))
.catch(error => dispatch(catchPosts(error)));
};
// reducer
const reducer = (oldState, action) => {
switch (action.type) {
case FETCH_POSTS_REQUEST:
return requestState;
case FETCH_POSTS_SUCCESS:
return successState;
case FETCH_POSTS_FAILURE:
return errorState;
default:
return oldState;
}
};
尽管这已经是最简单的调用接口场景,我们甚至还没写一行业务逻辑代码,但讲道理的话代码还是比较繁琐的。
而且其实代码是有一定的“套路”的,比如其实整个代码都是针对请求、成功、失败三部分来处理的,这让我们自然联想到 Promise,同样也是分为 pending、fulfilled、rejected 三种状态。
那么这两者可以结合起来让模版代码精简一下么?
2.2.1. redux-promise 中间件[[8]]
首先开门见山地使用 redux-promise 中间件来改写之前的代码看看效果。
// Constants
const FETCH_POSTS_REQUEST = 'FETCH_POSTS_REQUEST';
// Actions
const fetchPosts = (id) => ({
type: FETCH_POSTS_REQUEST,
payload: api.getData(id), // payload 为 Promise 对象
});
// reducer
const reducer = (oldState, action) => {
switch (action.type) {
case FETCH_POSTS_REQUEST:
// requestState 被“吃掉”了
// 而成功、失败的状态通过 status 来判断
if (action.status === 'success') {
return successState;
} else {
return errorState;
}
default:
return oldState;
}
};
可以看出 redux-promise 中间件比较激进、比较原教旨。
不但将发起请求的初始状态被拦截了(原因见下文源码),而且使用 action.status 而不是 action.type 来区分两个 action 这一做法也值得商榷(个人倾向使用 action.type 来判断)。
// redux-promise 源码
import { isFSA } from 'flux-standard-action';
function isPromise(val) {
return val && typeof val.then === 'function';
}
export default function promiseMiddleware({ dispatch }) {
return next => action => {
if (!isFSA(action)) {
return isPromise(action)
? action.then(dispatch)
: next(action);
}
return isPromise(action.payload)
// 直接调用 Promise.then(所以发不出请求开始的 action)
? action.payload.then(
// 自动 dispatch
result => dispatch({ ...action, payload: result }),
// 自动 dispatch
error => {
dispatch({ ...action, payload: error, error: true });
return Promise.reject(error);
}
)
: next(action);
};
}
以上是 redux-promise 的源码,十分简单。主要逻辑是判断如果是 Promise 就执行 then 方法。此外还根据是不是 FSA 决定调用的是 action 本身还是 action.payload 并且对于 FSA 会自动 dispatch 成功和失败的 FSA。
2.2.2. redux-promise-middleware 中间件
尽管 redux-promise 中间件节省了大量代码,然鹅它的缺点除了拦截请求开始的 action,以及使用 action.status 来判断成功失败状态以外,还有就是由此引申出的一个无法实现的场景————乐观更新(Optimistic Update)。
乐观更新比较直观的栗子就是在微信、QQ等通讯软件中,发送的消息立即在对话窗口中展示,如果发送失败了,在消息旁边展示提示即可。由于在这种交互方式中“乐观”地相信操作会成功,因此称作乐观更新。
因为乐观更新发生在用户发起操作时,所以要实现它,意味着必须有表示用户初始动作的 action。
因此为了解决这些问题,相对于比较原教旨的 redux-promise 来说,更加温和派一点的 redux-promise-middleware 中间件应运而生。先看看代码怎么说。
// Constants
const FETCH_POSTS = 'FETCH_POSTS'; // 前缀
// Actions
const fetchPosts = (id) => ({
type: FETCH_POSTS, // 传递的是前缀,中间件会自动生成中间状态
payload: {
promise: api.getData(id),
data: id,
},
});
// reducer
const reducer = (oldState, action) => {
switch (action.type) {
case `${FETCH_POSTS}_PENDING`:
return requestState; // 可通过 action.payload.data 获取 id
case `${FETCH_POSTS}_FULFILLED`:
return successState;
case `${FETCH_POSTS}_REJECTED`:
return errorState;
default:
return oldState;
}
};
如果不需要乐观更新,fetchPosts 函数可以更加简洁。
// 此时初始 actionGET_DATA_PENDING 仍然会触发,但是 payload 为空。
const fetchPosts = (id) => ({
type: FETCH_POSTS, // 传递的是前缀
payload: api.getData(id), // 等价于 payload: { promise: api.getData(id) },
});
相对于 redux-promise 简单粗暴地直接过滤初始 action,从 reducer 可以看出,redux-promise-middleware 会首先自动触发一个 FETCH_POSTS_PENDING 的 action,以此保留乐观更新的能力。
并且,在状态的区分上,回归了通过 action.type 来判断状态的“正途”,其中 _PENDING
、_FULFILLED
、_REJECTED
后缀借用了 Promise 规范 (当然它们是可配置的) 。
后缀可以配置全局或局部生效,例如全局配置可以这么写。
applyMiddleware(
promiseMiddleware({
promiseTypeSuffixes: ['LOADING', 'SUCCESS', 'ERROR']
})
)
源码地址点我,类似 redux-promise 也是在中间件中拦截了 payload 中有 Promise 的 action,并主动 dispatch 三种状态的 action,注释也很详细在此就不赘述了。
注意:redux-promise、redux-promise-middleware 与 redux-thunk 之间并不是互相替代的关系,而更像一种补充优化。
2.3. redux-loop 中间件
简单小结一下,Redux 的数据流如下所示:
UI => action => action creator => reducer => store => react => v-dom => UI
redux-thunk 的思路是保持 action 和 reducer 简单纯粹,然鹅副作用操作(在前端主要体现在异步操作上)的复杂度是不可避免的,因此它将其放在了 action creator 步骤,通过 thunk 函数手动控制每一次的 dispatch。
redux-promise 和 redux-promise-middleware 只是在其基础上做一些辅助性的增强,处理异步的逻辑本质上是相同的,即将维护复杂异步操作的责任推到了用户的身上。
这种实现方式固然很好理解,而且理论上可以应付所有异步场景,但是由此带来的问题就是模版代码太多,一旦流程复杂那么异步代码就会到处都是,很容易导致出现 bug。
因此有一些其他的中间件,例如 redux-loop 就将异步处理逻辑放在 reducer 中。(Redux 的思想借鉴了 Elm,注意并不是“饿了么”,而 Elm 就是将异步处理放在 update(reducer) 层中)。
Synchronous state transitions caused by returning a new state from the reducer in response to an action are just one of all possible effects an action can have on application state.
这种通过响应一个 action,在 reducer 中返回一个新 state,从而引起同步状态转换的方式,只是在应用状态中一个 action 能拥有的所有可能影响的一种。(可能没翻好~欢迎勘误~)
redux-loop 认为许多其他的处理异步的中间件,尤其是通过 action creator 方式实现的中间件,错误地让用户认为异步操作从根本上与同步操作并不相同。这样一来无形中鼓励了中间件以许多特殊的方式来处理异步状态。
与之相反,redux-loop 专注于让 reducer 变得足够强大以便处理同步和异步操作。在具体实现上 reducer 不仅能够根据特定的 action 决定当前的转换状态,而且还能决定接着发生的操作。
应用中所有行为都可以在一个地方(reducer)中被追踪,并且这些行为可以轻易地分割和组合。(redux 作者 Dan 开了个至今依然 open 的 issue:Reducer Composition with Effects in JavaScript,讨论关于对 reducer 进行分割组合的问题。)
redux-loop 模仿 Elm 的模式,引入了 Effect 的概念,在 reducer 中对于异步等操作使用 Effect 来处理。如下官方示例所示:
import { Effects, loop } from 'redux-loop';
function fetchData(id) {
return fetch(`endpoint/${id}`)
.then((r) => r.json())
.then((data) => ({ type: 'FETCH_SUCCESS', payload: data }))
.catch((error) => ({ type: 'FETCH_FAILURE', payload: error.message }));
}
function reducer(state, action) {
switch(action.type) {
case 'FETCH_START':
return loop( // <- 并没有直接返回 state,实际上了返回数组 [state, effect]
{ ...state, loading: true },
Effects.promise(fetchData, action.payload.id)
);
case 'FETCH_SUCCESS':
return { ...state, loading: false, data: action.payload };
case 'FETCH_FAILURE':
return { ...state, loading: false, errorMessage: action.payload };
}
}
虽然这个想法很 Elm 很函数式,不过由于修改了 reducer 的返回类型,这样一来会导致许多已有的 Api 和第三方库无法使用,甚至连 redux 库中的 combineReducers 方法都需要使用 redux-loop 提供的定制版本。因此这也是 redux-loop 最终无法转正的原因:
"If a solution doesn’t work with vanilla combineReducers(), it won’t get into Redux core."
三、复杂异步操作
3.1. 更复杂的通知场景[[9]]
让我们的思路重新回到通知的场景,之前的代码实现了:
- 展示一个通知并在数秒后消失
- 可以同时展示多个通知。
现在假设可亲可爱的产品又提出了新需求:
- 同时不展示多于3个的通知
- 如果已有3个通知正在展示,此时的新通知请求将排队延迟展示。
“这个实现不了...”(全文完)
这个当然可以实现,只不过如果只用之前的 redux-thunk 实现起来会很麻烦。例如可以在 store 中增加两个数组分别表示当前展示列表和等待队列,然后在 reducer 中手动控制各个状态时这俩数组的变化。
3.2. redux-saga 中间件
首先来看看使用了 redux-saga 后代码会变成怎样~(代码来自生产环境的某 app)
function* toastSaga() {
const MaxToasts = 3;
const ToastDisplayTime = 4000;
let pendingToasts = []; // 等待队列
let activeToasts = []; // 展示列表
function* displayToast(toast) {
if ( activeToasts >= MaxToasts ) {
throw new Error("can't display more than " + MaxToasts + " at the same time");
}
activeToasts = [...activeToasts, toast]; // 新增通知到展示列表
yield put(events.toastDisplayed(toast)); // 展示通知
yield call(delay, ToastDisplayTime); // 通知的展示时间
yield put(events.toastHidden(toast)); // 隐藏通知
activeToasts = _.without(activeToasts,toast); // 从展示列表中删除
}
function* toastRequestsWatcher() {
while (true) {
const event = yield take(Names.TOAST_DISPLAY_REQUESTED); // 监听通知展示请求
const newToast = event.data.toastData;
pendingToasts = [...pendingToasts, newToast]; // 将新通知放入等待队列
}
}
function* toastScheduler() {
while (true) {
if (activeToasts.length < MaxToasts && pendingToasts.length > 0) {
const [firstToast,...remainingToasts] = pendingToasts;
pendingToasts = remainingToasts;
yield fork(displayToast, firstToast); // 取出队头的通知进行展示
// 增加一点延迟,这样一来两个并发的通知请求不会同时展示
yield call(delay, 300);
}
else {
yield call(delay, 50);
}
}
}
yield [
call(toastRequestsWatcher),
call(toastScheduler)
]
}
// reducer
const reducer = (state = {toasts: []}, event) => {
switch (event.name) {
case Names.TOAST_DISPLAYED:
return {
...state,
toasts: [...state.toasts, event.data.toastData]
};
case Names.TOAST_HIDDEN:
return {
...state,
toasts: _.without(state.toasts, event.data.toastData)
};
default:
return state;
}
};
先不要在意代码的细节,简单分析一下上述代码的逻辑:
- store 上只有一个 toasts 节点,且 reducer 十分干净
-
排队等具体的业务逻辑都放到了 toastSaga 函数中
- displayToast 函数负责单个通知的展示和消失逻辑
- toastRequestsWatcher 函数负责监听请求,将其加入等待队列
- toastScheduler 函数负责将等待队列中的元素加入展示列表
基于这样逻辑分离的写法,还可以继续满足更加复杂的需求:
- 如果在等待队列中有太多通知,动态减少通知的展示时间
- 根据窗口大小的变化,改变最多展示的通知数量
- ...
redux-saga V.S. redux-thunk[[11]]
redux-saga 的优点:
- 易于测试,因为 redux-saga 中所有操作都 yield 简单对象,所以测试只要判断返回的对象是否正确即可,而测试 thunk 通常需要你在测试中引入一个 mockStore
- redux-saga 提供了一些方便的辅助方法。(takeLatest、cancel、race 等)
- 在 saga 函数中处理业务逻辑和异步操作,这样一来通常代码更加清晰,更容易增加和更改功能
- 使用 ES6 的 generator,以同步的方式写异步代码
redux-saga 的缺点:
- generator 的语法("又是 * 又是 yield 的,很难理解诶~")
- 学习曲线陡峭,有许多概念需要学习("fork、join 这不是进程的概念么?这些 yield 是以什么顺序执行的?")
- API 的稳定性,例如新增了 channel 特性,并且社区也不是很大。
通知场景各种中间件写法的完整代码可以看这里
3.3. 理解 Saga Pattern[[14]]
3.3.1. Saga 是什么
Sagas 的概念来源于这篇论文,该论文从数据库的角度谈了 Saga Pattern。
Saga 就是能够满足特定条件的长事务(Long Lived Transaction)
暂且不提这个特定条件是什么,首先一般学过数据库的都知道事务(Transaction)是啥~
如果不知道的话可以用转账来理解,A 转给 B 100 块钱的操作需要保证完成 A 先减 100 块钱然后 B 加 100 块钱这两个操作,这样才能保证转账前后 A 和 B 的存款总额不变。
如果在给 B 加 100 块钱的过程中发生了异常,那么就要返回转账前的状态,即给 A 再加上之前减的 100 块钱(不然钱就不翼而飞了),这样的一次转账(要么转成功,要么失败返回转账前的状态)就是一个事务。
3.3.2. 长事务的问题
长事务顾名思义就是一个长时间的事务。
一般来说是通过给正在进行事务操作的对象加锁,来保证事务并发时不会出错。
例如 A 和 B 都给 C 转 100 块钱。
- 如果不加锁,极端情况下 A 先转给 C 100 块,而 B 读取到了 C 转账前的数值,这时 B 的转账会覆盖 A 的转账,C 只加了 100 块钱,另 100 块不翼而飞了。
- 如果加了锁,这时 B 的转账会等待 A 的转账完成后再进行。所以 C 能正确地收到 200 块钱。
以押尾光太郎的指弹演奏会售票举例,在一个售票的时间段后,最终举办方需要确定售票数量,这就是一个长事务。
然鹅,对于长事务来说总不能一直锁住对应数据吧?
为了解决这个问题,假设一个长事务:T,
可以被拆分成许多相互独立的子事务(subtransaction):t_1 ~ t_n。
以上述押尾桑的表演为例,每个 t
就是一笔售票记录。
假如每次购票都一次成功,且没有退票的话,整个流程就如下图一般被正常地执行。
那假如有某次购票失败了怎么办?
3.3.3. Saga 的特殊条件
A LLT is a saga if it can be written as a sequence of transactions that can be interleaved with other transactions.
Saga 就是能够被写成事务的序列,并且能够在执行过程中被其他事务插入执行的长事务。
Saga 通过引入补偿事务(Compensating Transaction)的概念,解决事务失败的问题。
即任何一个 saga 中的子事务 t_i,都有一个补偿事务 c_i 负责将其撤销(undo)。
注意是撤销该子事务,而不是回到子事务发生前的时间点。
根据以上逻辑,可以推出很简单的公式:
- Saga 如果全部执行成功那么子事务序列看起来像这样:
t_1, t_2, t_3, ..., t_n
- Saga 如果执行全部失败那么子事务序列看起来像这样:
t_1, t_2, t_3, ..., t_n, c_n, ..., c_1
注意到图中的 c_4 其实并没有必要,不过因为每次撤销执行都应该是幂等(Idempotent)的,所以也不会出错。
篇幅有限在此就不继续深入介绍...
- 推荐看看从分布式系统方面讲 Saga Pattern 的视频:GOTO 2015 • Applying the Saga Pattern • Caitie McCaffrey
- MSDN 的文章:A Saga on Sagas
3.4. 响应式编程(Reactive Programming)[[15]]
redux-saga 中间件基于 Sagas 的理论,通过监听 action,生成对应的各种子 saga(子事务)解决了复杂异步问题。
而接下来要介绍的 redux-observable 中间件背后的理论是响应式编程(Reactive Programming)。
In computing, reactive programming is a programming paradigm oriented around data flows and the propagation of change.
简单来说,响应式编程是针对异步数据流的编程并且认为:万物皆流(Everything is Stream)。
流(Stream)就是随着时间的流逝而发生的一系列事件。
例如点击事件的示意图就是这样。
用字符表示【上上下下左右左右BABA】可以像这样。(注意顺序是从左往右)
--上----上-下---下----左---右-B--A-B--A---X-|->
上, 下, 左, 右, B, A 是数据流发射的值
X 是数据流发射的错误
| 是完成信号
---> 是时间线
那么我们要根据一个点击流来计算点击次数的话可以这样。(一般响应式编程库都会提供许多辅助方法如 map、filter、scan 等)
clickStream: ---c----c--c----c------c-->
map(c becomes 1)
---1----1--1----1------1-->
scan(+)
counterStream: ---1----2--3----4------5-->
如上所示,原始的 clickStream 经过 map 后产生了一个新的流(注意原始流不变),再对该流进行 scan(+) 的操作就生成了最终的 counterStream。
再来个栗子~,假设我们需要从点击流中得到关于双击的流(250ms 以内),并且对于大于两次的点击也认为是双击。先想一想应该怎么用传统的命令式、状态式的方式来写,然后再想想用流的思考方式又会是怎么样的~。
这里我们用了以下辅助方法:
- 节流:throttle(250ms),将原始流在 250ms 内的所有数据当作一次事件发射
- 缓冲(不造翻译成啥比较好):buffer,将 250ms 内收集的数据放入一个数据包裹中,然后发射这些包裹
- 映射:map,这个不解释
- 过滤:filter,这个也不解释
更多内容请继续学习 RxJS。
3.5. redux-observable 中间件[[16]]
redux-observable 就是一个使用 RxJS 监听每个 action 并将其变成可观测流(observable stream)的中间件。
其中最核心的概念叫做 epic,就是一个监听流上 action 的函数,这个函数在接收 action 并进行一些操作后可以再返回新的 action。
At the highest level, epics are “actions in, actions out”
redux-observable 通过在后台执行 .subscribe(store.dispatch)
实现监听。
Epic 像 Saga 一样也是 Long Lived,即在应用初始化时启动,持续运行到应用关闭。虽然 redux-observable 是一个中间件,但是类似于 redux-saga,可以想象它就像新开的进/线程,监听着 action。
在这个运行流程中,epic 不像 thunk 一样拦截 action,或阻止、改变任何原本 redux 的生命周期的其他东西。这意味着每个 dispatch 的 action 总会经过 reducer 处理,实际上在 epic 监听到 action 前,action 已经被 reducer 处理过了。
所以 epic 的功能就是监听所有的 action,过滤出需要被监听的部分,对其执行一些带副作用的异步操作,然后根据你的需要可以再发射一些新的 action。
举个自动保存的栗子,界面上有一个输入框,每次用户输入了数据后,去抖动后进行自动保存,并在向服务器发送请求的过程中显示正在保存的 UI,最后显示成功或失败的 UI。
使用 redux-observable 中间件编写代码,可以仅用十几行关键代码就实现上述功能。
import { Observable } from 'rxjs/Observable';
import 'rxjs/add/observable/dom/ajax';
import 'rxjs/add/observable/of';
import 'rxjs/add/operator/catch';
import 'rxjs/add/operator/debounceTime';
import 'rxjs/add/operator/map';
import 'rxjs/add/operator/mergeMap';
import 'rxjs/add/operator/startWith';
import {
isSaving, savingSuccess, savingError,
} from '../actions/autosave-actions.js';
const saveField = (action$) => // 一般在变量后面加 $ 表示是个 stream
action$
.ofType('SAVE_FIELD') // 使用 ofType 监听 'SAVE_FIELD' action
.debounceTime(500) // 防抖动
// 即 map + mergeAll 因为异步导致 map 后有多个流需要 merge
.mergeMap(({ payload }) =>
Observable.ajax({ // 发起请求
method: 'PATCH',
url: payload.url,
body: JSON.stringify(payload),
})
.map(res => savingSuccess(res)) // 发出成功的 action
.catch(err => Observable.of(savingError(err))) // 捕捉错误并发出 action
.startWith(isSaving()) // 发出请求开始的 action
);
export default saveField;
篇幅有限在此就不继续深入介绍...
- 关于 redux-observable 的前世今生推荐看看 Netfix 工程师的这个视频:Netflix JavaScript Talks - RxJS + Redux + React = Amazing!
-
如果觉得看视频听英语麻烦的话知乎有人翻译了...
四、总结
本文从为 Redux 应用添加日志功能(记录每一次的 dispatch)入手,引出 redux 的中间件(middleware)的概念和实现方法。
接着从最简单的 setTimeout 的异步操作开始,通过对比各种实现方法引出 redux 最基础的异步中间件 redux-thunk。
针对 redux-thunk 使用时模版代码过多的问题,又介绍了用于优化的 redux-promise 和 redux-promise-middleware 两款中间件。
由于本质上以上中间件都是基于 thunk 的机制来解决异步问题,所以不可避免地将维护异步状态的责任推给了开发者,并且也因为难以测试的原因。在复杂的异步场景下使用起来难免力不从心,容易出现 bug。
所以还简单介绍了一下将处理副作用的步骤放到 reducer 中并通过 Effect 进行解决的 redux-loop 中间件。然鹅因为其无法使用官方 combineReducers 的原因而无法被纳入 redux 核心代码中。
此外社区根据 Saga 的概念,利用 ES6 的 generator 实现了 redux-saga 中间件。虽然通过 saga 函数将业务代码分离,并且可以用同步的方式流程清晰地编写异步代码,但是较多的新概念和 generator 的语法可能让部分开发者望而却步。
同样是基于观察者模式,通过监听 action 来处理异步操作的 redux-observable 中间件,背后的思想是响应式编程(Reactive Programming)。类似于 saga,该中间件提出了 epic 的概念来处理副作用。即监听 action 流,一旦监听到目标 action,就处理相关副作用,并且还可以在处理后再发射新的 action,继续进行处理。尽管在处理异步流程时同样十分方便,但对于开发者的要求同样很高,需要开发者学习关于函数式的相关理论。
五、参考资料
- Redux 英文原版文档
- Redux 中文文档
- Dan Abramov - how to dispatch a redux action with a timeout
- 阮一峰 - Redux 入门教程(二):中间件与异步操作
- Redux 莞式教程
- redux middleware 详解
- Thunk 函数的含义和用法
- Redux异步方案选型
- Sebastien Lorber - how to dispatch a redux action with a timeout
- Sagas 论文
- Pros/cons of using redux-saga with ES6 generators vs redux-thunk with ES7 async/await
- Redux-saga 英文文档
- Redux-saga 中文文档
- Saga Pattern 在前端的應用
- The introduction to Reactive Programming you've been missing
- Epic Middleware in Redux
以上 to be continued...
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。