目录
- 什么是状态
- Redux
- Redux 异步解决方案
- Redux-Observable: 函数响应式编程(FRP)
什么是状态
状态可以是通过 ajax 获取或 websocket 推送的有关应用的数据,比如文档表格的内容、协作者列表、用户信息等等,还可以是一些 UI 状态,根据用户的交互而发生的状态改变,比如按钮的激活状态,当前选中的值等等,这些大部分都是可变状态,页面上所有的 UI 都有对应的状态描述。
早在 jQuery 时期,状态管理大多是使用一些变量(loading = true,loadSuccessed = true, loadFailed)来存储,而界面(View)的更新是直接操作 DOM($(ele).addClass(‘actived’) )来实现的,在当时没有太多状态需要管理的情况下,这种方式简单明了。
随着 SPA (单页面应用)被广泛使用,整个应用的数据、状态散落在各个变量中,同时还存在被任意更改的可能性,这种简单直接的方式对于随着时间不断变化的大规模应用而言的维护成本可想而知。
Backbone 的出现,将 DOM 操作分为数据模型(Model)、视图(View)、控制器(Controller),它的核心思想就是职责分离,将数据和视图分离来改进应用的组织结构,通过 Controller 处理用户事件、统一管理状态来控制 View 的更新。但是这种 Model 和 View 的对应关系往往都是多对多的关系,他们之间的关系很容易就成了一团乱麻。
直到以组件化思想为核心的框架(React、Vue 等)相继出现,这个问题才真正地被解决。以 React 为例,它不仅仅是 MVC 中的 V, 它利用 props 形成的单向数据流,使用 state 来管理组件内部的状态,以及纯粹的 View 更新方式 : View = f(State),开发者无需关心数据变化时如何更新 DOM,更新哪一部分 DOM。
Redux
对于组件之间的状态管理:组件之间需要共享状态,组件 A 需要改变组件 B 的状态。于是就有了 Redux 的出现,它解决了状态放在哪里,以及组件间进行通信的问题。Redux 使用一个全局 Store 来管理整个应用的状态,并通过设定了一定的约束来修改、读取 state,让你的应用状态管理变得可预测、可追溯。
- Store,应用的唯一数据源,即应用的所有状态值存在这唯一一个 store 上。
- Action,用普通的 JavaScript 对象描述动作,通常会包含一个 type 字段,用来代表动作的类型,相当于把事件转成了 action。
- Reducer,这跟 Array.prototype.reduce 的概念是一样的,如下数组类型的 reduce 函数:
const array1 = [1,2,3,4]
const reducer = (accumulator, currentValue) => accumulator + currentValue
// 1 + 2 + 3 + 4
console.log(array1.reduce(reducer)) // output: 10
// 5 + 1 + 2 + 3 + 4
console.log(array1.reducer(reducer, 5)) // output: 15
在 Redux 中, reducer 也是相同的概念:reducer(state, action), state 是当前的状态,即 reducer 中的 accumulator,action 是描述动作的变化,即 reducer 中的 currentValue,reducer 的职责就是基于当前的 state 及 action 所计算出的新 state。
Redux 异步解决方案
Redux 使用 action 和 reducer 来维护状态,给定当前 state 和 action,reducer 就会计算出一个全新的 state。对于同步 action,这个模型即简单又优雅,而在实际的业务场景中,我们是需要处理请求数据、setTimeout、setInterval 等异步操作,Redux 通过中间件来完成这些异步操作。
- Redux-Thunk
在 Redux 的单向数据流中,Redux-Thunk 在 action 被 dispatch 之后, 调用 reducer 函数处理之前插入异步操作。
Redux-Thunk 会检测 action 是普通的对象,还是函数,如果是函数,就执行这个函数,这个函数就可以做某些异步操作,在异步操作的回调函数中再去 dispatch action,如下:
function getComments (id) {
return (dispatch) => {
dispatch({type: 'REQUEST_COMMENTS_START'})
return ajax(`https://shimo.im/comments${id}`)
.then((res) => dispatch({type: 'REQUEST_COMMENTS_SUCCESS', data: res}))
.catch((err) => dispatch({type: 'REQUEST_COMMENTS_FAILED', err}))
}
}
dispatch(getComments())
- Redux-Promise
Redux-Promise 支持 promise 形式的 action,由 Redux-Promise 处理 resolve 的过程,并 dispatch 相应的 action,这个会相比 Redux-Thunk 的写法代码量较少。
function getComments (id) {
return {
type: 'REQUEST_COMMENTS'
payload: getCommentsByAjax(id)
}
}
- Redux-Saga
监听(watch) action 来执行有副作用的操作,以保持 action 的纯粹,引入 saga 这一层集中处理 redux 副作用的问题,并且使用 generator 可以解决用同步代码的方式写异步逻辑,使其容易读写。
function* getComments (id) {
yield put({type: 'REQUEST_COMMENTS_START'})
try {
const commentsRes = yield call(() => ajax(`https://shimo.im/comments${id}`))
yield put({type: 'REQUEST_COMMENTS_SUCCESS', data: commentsRes})
} catch(err) {
yield put({type: 'REQUEST_COMMENTS_FAILED', err})
}
}
Redux-Observable: 函数响应式编程(FRP)
Redux 解耦了状态和视图,同时又可以通过 combineReducer 把应用按照功能拆分成“小的” reducer,体现了分而治之的思想。对于副作用(异步是副作用的一种), Redux 并没有提供针对性的解决方案,但提供了中间件的方式管理异步状态。这节我们着重看下 Redux-Observable 中间件。
Redux-Observable 同样作为 Redux 的中间件,它是 Redux 和 RxJS 的结合,利用 RxJS 实现对异步的操作。
RxJS 是响应式编程的一种实现。响应式编程的思想是把随着时间不断变化的数据、状态、事件等等都转换成可被观察的对象(Observable),然后观察者(Observer) subscribe 这些 Observable 对象的变化,一旦变化就会通知相应的观察者。
RxJS 同时具有函数式编程的特点:声明式、 纯函数、不可变。可以从后面的例子中体会到。
我们再回过头来看 Redux-Observable:
在 Redux-Observable 中有个核心概念是 Epic,它让副作用的处理处于架构中独立的一层,类似 Redux-Saga 中的 saga,均集中处理副作用。而区别在于在 Redux-Observable 中, Epic 也能像 reducer 一样是纯函数,它函数签名如下:
function (action$: Observable<Action>, store: Store): Observable<Action>
它将普通的 action 转换成数据流(即 action$),返回的是一个新数据流。先来看一个简单的例子:
const pingEpic = action$ =>
action$.filter(action => action.type === 'PING')
.delay(100) // Asynchronously wait 1000ms then continue
.mapTo({type: 'PONG'})
// later
dispatch({type: 'PING'})
这里将所有的 action$ 过滤并筛选中类型为 “PING”,然后再等待一秒后,将 action({type: ‘PING’}) ,映射成 action ({Type:‘PONG’}),并Redux-Observable 内部调用 dispatch({type: ‘PONG’}), 所以上述代码相当于:
dispatch({type: 'PING'})
dispatch({type: 'PONG'})
对于石墨在线协同的 Office 产品来说, 它的复杂度除了来自业务本身,同时还因它的数据有不同的来源,需要组合并处理多个不同的事件,或者需要处理一系列离散的事件,更需要精心的管理副作用 。例如会通过 ajax 获取文档内容的成功或失败的事件,通过 websocket 收到其他人对编辑器修改的事件,用户的单元格选中、剪切、复制、粘贴、键盘、鼠标等事件,而这些异步事件可以任意的排列组合,比如:
- 用户的一个单元格编辑操作,除了会触发单元格内容变化的事件,也有可能触发因单元格的内容撑高引起行高变化的事件,但这两个事件是因为用户的一个操作引起的,应该是属于原子的,所以应该合并这两个事件,统一处理产生一条修改。
const editCellEpic = (action$) => {
return Observable.combineLatest(
action$.ofType('EDIT_END'),
action$.ofType('ROW_HEIGHT_CHANGED'),
(cells, rowHeight) => {type: 'EDIT_CELL', payload: {cells, rowHeight}}
)
}
- 复制粘贴:用户的一个粘贴操作,我们需要检查他所要粘贴的区域是否有编辑权限,同时需要控制粘贴操作的时间间隔(实际粘贴操作的发生次数),类似 lodash 中的 debounce,最后才认为是一个真正的粘贴操作。
const pastedEpic = (action$, store) => {
action$.ofType('PASTING')
.filter(action => !hasEditPermission(action.range))
.debounceTime(300)
.map(action => {type: 'DO_PASTE', payload: action.range})
}
- 排序:用户对表格进行排序操作,同样需要判断排序的区域是否有编辑权限,同时因为排序操作比较耗 cpu,需要在 worker 中计算相关的数据。
const sortEpic = (action$) => {
action$.ofType('SORT')
.filter(action => !hasEditPermission(action.range))
.switchMap(action => calcInWorker(action.range).map(calcRes) => {
return {type: 'CALC_SORT_SUCCESS', payload: calcRes}
})
}
类似这类操作还很多,对于一个操作来说,其背后的处理还比较复杂,在这里把 DOM 的事件、ajax 等都抽象成数据流,将复杂的业务拆分成一个个低耦合的业务,通过 RxJS 的 map、filter、combine、debounce 等操作符进行任意的组合、过滤、转换来清晰的描述它们之间的关系。
虽然我们称这些 DOM 的事件、ajax 等异步操作为“副作用”,但并不意味着它就是“负面”的,它只是在复杂项目中需要精心的去管理。这里我们通过 Redux-Observable 中的 epic 将副作用处理与纯逻辑隔离,同时副作用在将事件转换成数据流这一层又被完全隔离了,后续就是一个个纯函数的组合,使你仅仅专注于业务逻辑中这些数据流之间的关系,尽情体验纯粹的乐趣😁。
回顾下,Redux 解决了状态放在哪里以及组件之间如何进行通信、如何共享状态的问题。而响应式编程帮助我们隔离了副作用并且提供了强大的流处理能力,即增加了异步事件处理能力。就像 RxJS 的介绍中提到的:
Think of RxJS as Lodash for events。
通过这种方式,实际上是减少了可变状态,我们所做的就是像 lodash 操作 collection 一样组合、过滤操作 events。
对于石墨在线协同 Office 产品与生俱来的复杂度,尽量以纯粹和组合的思维方式来管理复杂的随着时间而变化的状态。对于函数式响应式编程,这里是基于流模型体现的,然而流是具有较高成本的抽象,对于工程师而言,需要转换思考问题的思维方式,尽量不用已经习惯了的变量去管理状态,需要放弃之前命令式编程范式,而是在流中(函数响应式)思考。