/ Redux

发布redux-optimistic-thunk

近期因为一些外部的因素,不得不开始尝试使用React + Redux的组合,在学习并使用了一周之后,我实现并发布了这个用于解决Optimistic UI问题的redux-optimistic-thunk中间件:

本文中所有“Optimistic”翻译为“乐观”,大致指在涉及到外部服务的用户交互产生时,通过一定的技术手段在短时间内向用户提供操作的反馈,而不等待外部服务的响应,Cola Chan的文章比较详细地论述了这一模式。

先通过动画展示一下这个中间件工作时的效果(图片4.4MB,请耐心等待):

从图中可以看到一个10s延迟和一个5s延迟的项目在提交时会立刻被添加到列表的顶端,之后进行了删除操作的其它项目也会保留状态,最终因为延迟的不同,在实际响应后2个项目在列表中的位置会被交换(先来者先进入列表)。

本中间件现在版本0.8.0,处于可用状态,如有问题欢迎通过Issue反馈。

先说说为什么要设计这个中间件

在Redux社区中有若干的支持乐观UI更新的中间件,如redux-optimistredux-optimistic-ui是比较常见的一对对手,redux-promise-middlewareredux-optimist-promise则基于Promise对乐观UI更新进行控制。

但是在这些中间件的设计上,我认为都存在着一些共同的瑕疵:

  1. 它们都试图在纯对象的Action上添加自己特有的属性(兼容FSA的使用meta属性,不兼容的则自己乱造属性)。一个复杂的应用中可能会有成百上千种Action对象,这些中间件将自己定义的属性嵌入到了这些对象上,具有很强的侵入性,如果需要更换技术则很难进行清理或者替换,特别是当换用不同中间件且属性冲突的时候,迁移过程会非常痛苦。
  2. 大部分中间件使用了一种“事务 + 提交或回滚”的设计。其传达的理念是将乐观的响应定义为一个未提交的事务,当外部服务实际响应成功时,则将乐观的响应提交,否则进行回滚。但在实际的应用中,乐观响应和真实外部服务的成功响应处理逻辑并不一定是一致的,一个最为简单的案例是,当添加一个项目到列表时,一个乐观的响应的项目可能需要一些标记位来控制其不能被进一步操作(编辑、删除),否则会导致类似“删除操作后发先至,外部服务处理后产生异常”的情况出现。
  3. 基于事务的中间件将“事务的ID”这个概念暴露给了开发者,试图让开发者维护transactionID这一属性。在复杂的逻辑流转中,如果需要将这一ID在多处进行传递、同步,对于开发者来说是不小的负担。

基于上面这些原因,我认为当前并没有一个令我满意的处理乐观UI更新的中间件,因此决定自行设计实现一个,我的准则是:

  1. 低侵入性,不影响Action对象的结构。
  2. 一致性,无论是正常的业务逻辑还是乐观UI的更新都应该是普通的逻辑,所谓逻辑即执行业务流程,派发不同的Action以更新应用的状态。在这一点上正常逻辑和乐观UI的更新不应该区别对待。
  3. 封装性,不将中间件实现内部的依赖暴露给开发者,开发者应当将注意力集中在业务如何流转、逻辑如何编写、状态如何更新上,而不是知晓“事务”、“提交”、“回滚”甚至是transactionID这样的概念。

我的中间件如何工作

redux-optimistic-thunk从名字上就可以看出,它是一个和redux-thunk比较接近的东西,应用了thunk(dispatch的依赖反转)的特性。综合来说,redux-optimistic-thunk由2部分组成:

  • 一个用于处理一种特殊结构的Action的中间件(就如同redux-thunk处理类型为函数的Action)。
  • 一个对reducer的高阶转换函数用于控制状态的回滚和更新。

redux-optimistic-thunk定义了一种特殊结构的中间件,这个结构简单表达为[function, function],即有2个函数组成的一个数组。这其中的每一个函数均为一个标准的由redux-thunk定义的thunk,接受(dispatch, getState, [extraArgument])作为参数,可执行任意的逻辑,并调用dispatch函数派发Action。

在这个数组中,2个函数分别扮演不同的角色:

  1. 第一个函数是一个用于处理正常的业务逻辑,在此称之为“真实thunk”。它的定义和redux-thunk的一模一样,你将业务逻辑编写于此。如果你的应用之前就使用了redux-thunk,那么原本的这些函数都可以直接复用
  2. 第二个函数专用于进行乐观的UI更新,在此称为“乐观thunk”。乐观thunk的定义也是一个普通的thunk,或以通过dispatch派发Action对状态进行更新,但附加了一个前提:乐观thunk必须是同步的

如上所说,在redux-optimistic-thunk接收的参数中,2个函数的签名均为普通的thunk函数,其实现也是普通的业务逻辑,大部分情况下乐观thunk和真实thunk派发的Action是相同的(如创建了新的项目后,者是使用ADD_ITEM来添加至列表上),这既保证了中间件不影响Action的结构,也能让Action之间拥有很高的复用性,换句话说,乐观UI完全是在一个正常的应用上额外附加的部分,随时可以插拨而不影响应用本身

对于开发者而言所需要知道的仅此这些,其它的工作均会由redux-optimistic-thunk内部实现。redux-optimistic-thunk会将乐观thunk产生的Action通过标准的流程(dispatch => reducer => newState)派发从而更新界面,随后当真实thunk在异步过程结束后调用dispatch参数时,乐观thunk产生的Action会被“回滚”,在此期间由其它逻辑产生的Action也同样会得到保留。

如何使用这个中间件

安装中间件

首先安装是最简单的工作:

npm install --save redux-optimistic-thunk

创建store

在安装之后,我们需要进行2步工作。

第一是应用中间件,使用optimisticThunk这个命名导出即可。需要注意的是,与redux-thunk不同,optimisticThunk是一个函数,因此默认情况下你需要写的代码是:

applyMiddleware(optimisticThunk()) // 注意此处的函数调用

如果你需要给所有的thunk额外的参数,则调用optimisticThunk时提供extraArgument参数即可,这与redux-thunk都是一样的。

第二步需要做的是使用createOptimisticReducer这个导出包装你的reducer,该函数接受一个reducer并返回一个新的reducer,新的reducer会负责2件事:

  1. 将状态回滚到乐观thunk派发Action之前。
  2. 在特定的情况下标记当前状态是否为乐观的。

综合起来,你创建store的代码如下:

import {createStore, applyMiddleware} from 'redux';
import {optimisticThunk, createOptimisticReducer} from 'redux-optimistic-thunk';
import reducer from './reducer';

let initialState = {...};

let store = createStore(
    createOptimisticReducer(reducer), // 包装一下reducer
    initialState,
    applyMiddleware(optimisticThunk()) // 应用一下中间件
);

编写thunk

我们假定有这样的需求:

在添加一个TODO之后,将这个TODO立刻显示在列表中,同时向后端发起请求保存内容。

保存成功后后端会返回整个TODO的信息(内容可能会进行敏感词过滤等),随后删除乐观添加的TODO,将后端返回的信息插入到列表中。

如果后端保存失败,则显示错误信息。

乐观创建的项目不能被删除(因为没有后端生成的唯一ID)。

我们可以这样写编写代码:

// action/todo.js

import {addLog} from './log';
import {warn} from './modal';
import {saveTodo} from '../api';

let newTodo = todo => ({type: 'NEW_TODO', todo: todo});

// 一个数组,2个thunk函数
let createTodo = todo => [
    // 第一个thunk为真实thunk,平时怎么写逻辑就怎么写
    async dispatch => {
        // 如果有同步的dispatch调用,那么会在乐观thunk之前就执行
        dispatch(addLog(`Submitted ${todo.title} task`));

        try {
            // 异步调用后端
            let createdTodo = await saveTodo(todo);

            // 第一次的异步调用dispatch会回滚掉乐观thunk产生的所有Action
            dispatch(addLog(`Created ${createdTodo.title} task`));
            dispatch(newTodo(createdTodo));
        }
        catch (ex) {
            // 返回错误时显示错误信息
            dispatch(warn(`Failed to create ${todo.title} task`));
        }
    },

    // 第二个thunk为乐观thunk,除了必须是同步的以外和普通的thunk也没区别
    dispatch => {
        // 如果这里有异步的dispatch调用的话就会抛异常
        dispatch(addLog(`Created ${todo.title} task`));
        // 加一个pending的标记,使用这个标记控制删除功能,可以看到此处与真实thunk中有所区别
        dispatch(newTodo({...todo, pending: true}));
    }
];

代码很简单,2段都是标准的业务逻辑(thunk),你不需要知道中间件做了啥,不需要在Action中添加optimistic或者meta之类的属性,用不用FSA也由你自己决定,更不用自己蛋疼地去管理transactionID这种东西。

一些细节

  • 大部分情况下你根本不需要关心现在的状态是不是乐观的,如果你真的想知道的话,createOptimisticReducer函数其实为你的状态添加了一个optimistic属性,这个属性为true时就表示你的状态是乐观的。
  • 中间件的顺序很重要,一个简单的建议是把redux-optimistic-thunk放在所有中间件的前面。不过redux-optimistic-thunk是会在回滚状态的时候把你以前派发过的Action重新再派发一次的,所以如果你想忽略这些重新派发的Action的话,把你的中间件放在redux-optimistic-thunk之前,一个典型的例子就是logger中间件可以放在前面。
  • 一定要注意你的真实thunk必须异步调用一下dispatch,受限于thunk不像Promise具备状态管理的功能,如果你不异步调用的话redux-optimistic-thunk永远不知道何时应该回滚乐观thunk产生的Action,那么你的状态将永远保持在乐观状态,并且所有的Action都被记录下来以求在“那个永远不会到来的未来”进行回滚,这就成了内存泄露了。
  • redux-optimistic-thunk本身并不支持你传一个函数给dispatch,所以你要用标准的thunk的话自行把redux-thunk也一起加上。
  • 为了进行状态回滚和乐观标记,redux-optimistic-thunk本身会产生一些特殊的Action,这些Action的type属性均以@@redux-optimistic-thunk/开头,建议你自己写中间件的话忽略掉这些Action。

内部实现

简单说一下这个中间件的内部实现。其实很简单,这个中间件在调用开发者给的真实thunk和乐观thunk时,传递的dispatch参数是2个特殊封装过的函数,而不是正常的store.dispatch函数,因此中间件获得了2个方面的信息:

  • 哪些Action是由乐观thunk派发的(称为乐观Action),哪些是由真实Action派发的(称为真实Action)。
  • 哪些乐观和真实Action是一组的(也就是内部有了其它中间件要开发者手动管理的transactionID这东西)。

当第一次派发一个乐观Action时,中间件会把当前的应用状态保存下来,称为一个存档点。存档点永远是“第一个乐观Action出现前的状态”。

在有了存档点之后,所有的Action都会被记录在一个队列中。

而回滚的逻辑则是先将状态恢复到存档点,随后将队列中的Action依次重新派发,这个过程中丢弃掉同一组(transactionID)的Action,同时派发过程中如果又出现(非同一组的)乐观Action则重新生成新的存档点和Action队列。

我们假设有这样的代码:

let slow = [
    async dispatch => {
        dispatch({type: 'PUSH', value: 1});

        await delay(500);

        dispatch({type: 'PUSH', value: 3});
    },
    dispatch => {
        dispatch({type: 'PUSH', value: 2});
    }
];

let fast = [
    async dispatch => {
        dispatch({type: 'PUSH', value: 4});

        await delay(200);

        dispatch({type: 'PUSH', value: 6});
    },
    dispatch => {
        dispatch({type: 'PUSH', value: 5});
    }
];

store.dispatch(slow);
store.dispatch(fast);

从代码中我们可以看出:

  • 1、2、3为一组,其中2为乐观Action;4、5、6为一组,其中5为乐观Action。
  • 根据delay的长短,不考虑乐观thunk的情况下,最终状态应该是1-4-6-3。
  • 乐观Action中的2会被插在1之后,5会被插在4之后,并且在真实thunk的异步结束时消失。

整个过程中实际状态、存档点、记录的Action的演变过程可以用一张图简单说明:

这一切可行的源头都是Redux传递的哲学:

  • Action派发到状态更新是同步的
  • 状态的更新(reducer)是纯函数(无副作用)

因此redux-optimistic-thunk得以通过记录Action并重新派发来进行状态的控制,纯函数的概念在这中间起到了非常大的作用。

事实上除了记录Action外,另一个办法是记录每人上状态的副本,这可以在某些场景下少派发一些Action,但是考虑到保存所有的状态副本对内存的开销比较大,而reducer本身由于其纯函数并且DOM无关的特性应当不会存在性能问题,所以redux-optimistic-thunk选择记录Action作为其内部的实现。