Redux 中的排队操作

Posted

技术标签:

【中文标题】Redux 中的排队操作【英文标题】:Queuing Actions in Redux 【发布时间】:2016-12-09 02:25:16 【问题描述】:

我目前遇到需要连续运行 Redux Actions 的情况。我已经查看了各种中间件,例如 redux-promise,这似乎很好 如果你知道在根点处的连续动作是什么(因为没有更好的术语)动作被触发 。

基本上,我想维护一个可以随时添加到的操作队列。每个对象在其状态中都有一个此队列的实例,并且相关的操作可以相应地入队、处理和出队。我有一个实现,但这样做时我在我的动作创建者中访问状态,这感觉像是一种反模式。

我将尝试提供一些有关用例和实现的背景信息。

用例

假设您想创建一些列表并将它们保存在服务器上。在创建列表时,服务器会使用该列表的 id 进行响应,该 ID 用于与该列表相关的后续 API 端点:

http://my.api.com/v1.0/lists/           // POST returns some id
http://my.api.com/v1.0/lists/<id>/items // API end points include id

想象一下,客户想要对这些 API 点执行乐观更新,以增强用户体验 - 没有人喜欢看微调器。因此,当您创建列表时,您的新列表会立即出现,并带有添加项目的选项:

+-------------+----------+
|  List Name  | Actions  |
+-------------+----------+
| My New List | Add Item |
+-------------+----------+

假设有人在初始创建调用的响应返回之前尝试添加项目。 items API 依赖于 id,因此我们知道在获得该数据之前无法调用它。但是,我们可能希望乐观地显示新项目并将对项目 API 的调用排入队列,以便在创建调用完成后触发它。

一个潜在的解决方案

我目前用来解决这个问题的方法是为每个列表提供一个操作队列 - 即将连续触发的 Redux 操作列表。

用于创建列表的 reducer 功能可能如下所示:

case ADD_LIST:
  return 
    id: undefined, // To be filled on server response
    name: action.payload.name,
    actionQueue: []
  

然后,在动作创建器中,我们将动作加入队列而不是直接触发它:

export const createListItem = (name) => 
    return (dispatch) => 
        dispatch(addList(name));  // Optimistic action
        dispatch(enqueueListAction(name, backendCreateListAction(name));
    

为简洁起见,假设 backendCreateListAction 函数调用 fetch API,该 API 在成功/失败时分派消息以从列表中出列。

问题

这里让我担心的是 enqueueListAction 方法的实现。这是我访问状态以管理队列进展的地方。它看起来像这样(忽略名称上的匹配 - 这实际上使用了 clientId,但我试图保持示例简单):

const enqueueListAction = (name, asyncAction) => 
    return (dispatch, getState) => 
        const state = getState();

        dispatch(enqueue(name, asyncAction));

        const thisList = state.lists.find((l) => 
            return l.name == name;
        );

        // If there's nothing in the queue then process immediately
        if (thisList.actionQueue.length === 0) 
            asyncAction(dispatch);
         
    

这里,假设 enqueue 方法返回一个将异步操作插入列表 actionQueue 的普通操作。

整个事情感觉有点不合时宜,但我不确定是否还有其他方法可以解决。此外,由于我需要在我的 asyncActions 中调度,我需要将调度方法向下传递给它们。

从列表中出列的方法中有类似的代码,如果存在的话,它会触发下一个动作:

const dequeueListAction = (name) => 
    return (dispatch, getState) => 
        dispatch(dequeue(name));

        const state = getState();
        const thisList = state.lists.find((l) => 
            return l.name === name;
        );

        // Process next action if exists.
        if (thisList.actionQueue.length > 0) 
            thisList.actionQueue[0].asyncAction(dispatch);
    

一般来说,我可以接受这一点,但我担心它是一种反模式,并且在 Redux 中可能有更简洁、惯用的方式。

感谢任何帮助。

【问题讨论】:

你应该看看redux-saga。 这是一个很好的问题,我很想看看是否有人对此有一个优雅的解决方案。我一直在使用承诺链 + mapDispatchToProps 来实现类似的目标。 我认为我们所有深入研究过 react/redux 的人都发现需要以任何中间件选项都不支持的方式对操作进行排队/聚合。就个人而言,我认为您最好在组件级别处理它,并在那里跟踪/比较状态,而不是在必须根据 ID 排队/出列的中间件中。 @Pcriulan - 如果我错了,请纠正我,但我不认为 redux-saga 解决了这个问题。从我可以从documentation 收集到的信息来看,由 saga 提取的对同一操作的非并发请求将取消待处理的请求。诚然,我还没有深入研究这些东西。 查看 redux-logic codewinds.com/blog/2016-08-16-business-logic-redux.html 【参考方案1】:

您不必处理排队操作。它将隐藏数据流,并使您的应用程序调试起来更加乏味。

我建议您在创建列表或商品时使用一些临时 ID,然后在您从商店实际收到真实 ID 时更新这些 ID。

可能是这样的? (没有测试,但你得到了 id):

EDIT :我一开始不明白,列表保存时需要自动保存项目。我编辑了createList 动作创建者。

/* REDUCERS & ACTIONS */

// this "thunk" action creator is responsible for :
//   - creating the temporary list item in the store with some 
//     generated unique id
//   - dispatching the action to tell the store that a temporary list
//     has been created (optimistic update)
//   - triggering a POST request to save the list in the database
//   - dispatching an action to tell the store the list is correctly
//     saved
//   - triggering a POST request for saving items related to the old
//     list id and triggering the correspondant receiveCreatedItem
//     action
const createList = (name) => 

  const tempList = 
    id: uniqueId(),
    name
  

  return (dispatch, getState) => 
    dispatch(tempListCreated(tempList))
    FakeListAPI
      .post(tempList)
      .then(list => 
        dispatch(receiveCreatedList(tempList.id, list))

        // when the list is saved we can now safely
        // save the related items since the API
        // certainly need a real list ID to correctly
        // save an item
        const itemsToSave = getState().items.filter(item => item.listId === tempList.id)
        for (let tempItem of itemsToSave) 
          FakeListItemAPI
            .post(tempItem)
            .then(item => dispatch(receiveCreatedItem(tempItem.id, item)))
        
      )
  



const tempListCreated = (list) => (
  type: 'TEMP_LIST_CREATED',
  payload: 
    list
  
)

const receiveCreatedList = (oldId, list) => (
  type: 'RECEIVE_CREATED_LIST',
  payload: 
    list
  ,
  meta: 
    oldId
  
)


const createItem = (name, listId) => 

  const tempItem = 
    id: uniqueId(),
    name,
    listId
  

  return (dispatch) => 
    dispatch(tempItemCreated(tempItem))
  



const tempItemCreated = (item) => (
  type: 'TEMP_ITEM_CREATED',
  payload: 
    item
  
)

const receiveCreatedItem = (oldId, item) => (
  type: 'RECEIVE_CREATED_ITEM',
  payload: 
    item
  ,
  meta: 
    oldId
  
)

/* given this state shape :
state = 
  lists: 
    ids: [ 'list1ID', 'list2ID' ],
    byId: 
      'list1ID': 
        id: 'list1ID',
        name: 'list1'
      ,
      'list2ID': 
        id: 'list2ID',
        name: 'list2'
      ,
    
    ...
  ,
  items: 
    ids: [ 'item1ID','item2ID' ],
    byId: 
      'item1ID': 
        id: 'item1ID',
        name: 'item1',
        listID: 'list1ID'
      ,
      'item2ID': 
        id: 'item2ID',
        name: 'item2',
        listID: 'list2ID'
      
    
  

*/

// Here i'm using a immediately invoked function just 
// to isolate ids and byId variable to avoid duplicate
// declaration issue since we need them for both
// lists and items reducers
const lists = (() => 
  const ids = (ids = [], action = ) => (
    switch (action.type) 
      // when receiving the temporary list
      // we need to add the temporary id 
      // in the ids list
      case 'TEMP_LIST_CREATED':
        return [...ids, action.payload.list.id]

      // when receiving the real list
      // we need to remove the old temporary id
      // and add the real id instead
      case 'RECEIVE_CREATED_LIST':
        return ids
          .filter(id => id !== action.meta.oldId)
          .concat([action.payload.list.id])
      default:
        return ids
    
  )

  const byId = (byId = , action = ) => (
    switch (action.type) 
      // same as above, when the the temp list
      // gets created we store it indexed by
      // its temp id
      case 'TEMP_LIST_CREATED':
        return 
          ...byId,
          [action.payload.list.id]: action.payload.list
        

      // when we receive the real list we first
      // need to remove the old one before
      // adding the real list
      case 'RECEIVE_CREATED_LIST': 
        const 
          [action.meta.oldId]: oldList,
          ...otherLists
         = byId
        return 
          ...otherLists,
          [action.payload.list.id]: action.payload.list
        
      

    
  )

  return combineReducers(
    ids,
    byId
  )
)()

const items = (() => 
  const ids = (ids = [], action = ) => (
    switch (action.type) 
      case 'TEMP_ITEM_CREATED':
        return [...ids, action.payload.item.id]
      case 'RECEIVE_CREATED_ITEM':
        return ids
          .filter(id => id !== action.meta.oldId)
          .concat([action.payload.item.id])
      default:
        return ids
    
  )

  const byId = (byId = , action = ) => (
    switch (action.type) 
      case 'TEMP_ITEM_CREATED':
        return 
          ...byId,
          [action.payload.item.id]: action.payload.item
        
      case 'RECEIVE_CREATED_ITEM': 
        const 
          [action.meta.oldId]: oldList,
          ...otherItems
         = byId
        return 
          ...otherItems,
          [action.payload.item.id]: action.payload.item
        
      

      // when we receive a real list
      // we need to reappropriate all
      // the items that are referring to
      // the old listId to the new one
      case 'RECEIVE_CREATED_LIST': 
        const oldListId = action.meta.oldId
        const newListId = action.payload.list.id
        const _byId = 
        for (let id of Object.keys(byId)) 
          let item = byId[id]
          _byId[id] = 
            ...item,
            listId: item.listId === oldListId ? newListId : item.listId
          
        
        return _byId
      

    
  )

  return combineReducers(
    ids,
    byId
  )
)()

const reducer = combineReducers(
  lists,
  items
)

/* REDUCERS & ACTIONS */

【讨论】:

这是我已经在应用程序中做的事情。也就是说,分配临时 ID 并在服务器响应中协调它们。不可否认,我的形状有点不同。但是,我认为这仍然存在依赖调用的问题。假设 FakeItemApi 在您的情况下需要来自 FakeListApi 的服务器 ID 作为 url 字符串的一部分,如上例所示。现在想象一下,如果有人在我们保证拥有该列表 ID 之前立即尝试添加列表项。他们会将什么作为第二个参数传递给createList?临时 ID 肯定会中断 API 调用。 好的,我更新了答案,您只需要在 createList 操作创建者中调度 FakeItemApi 调用。请参阅上面的更新答案。 感谢@Pcriulan 的修正。我认为这非常优雅地解决了这个特殊情况,但可能不是一般情况。例如,假设它不是只是添加您可能想要连续执行的列表项。也许它是一个 PUT 到列表 API 以更改名称或其他一些任意事件。一般来说,假设您希望在 API 上连续运行彼此不知道的事件(在上面的示例中,FakeListApi 调用知道 FakeListItem 调用将成功)。 范你详细说明一下?为什么说“彼此不了解的行为”?他们从来没有,但你有 再次感谢@Pcriulan 的回复。我的意思是,FakeListAPI 的异步调用的上述动作创建者对它的成功有一个概念——在其 then 方法中,它调用了 FakeListItemAPI。想象一下,如果我们有十个不同的 API,它们都可能相互依赖——我们需要在每个关心的动作创建者中为每个 API 使用相同的模式(我希望这是有道理的)。我采用了一种方法,将异步操作队列与乐观更新的概念结合起来,但仍然高度通用且与域无关。我会在下周尝试写一篇关于它的博客文章。【参考方案2】:

这就是我解决这个问题的方法:

确保每个本地列表都有唯一的标识符。我不是在这里谈论后端ID。名称可能不足以识别列表?尚未持久化的“乐观”列表应该是唯一可识别的,用户可以尝试创建 2 个具有相同名称的列表,即使这是边缘情况。

在创建列表时,将后端 ID 的承诺添加到缓存

CreatedListIdPromiseCache[localListId] = createBackendList(...).then(list => list.id);

在添加项目时,尝试从 Redux 存储中获取后端 id。如果不存在,则尝试从CreatedListIdCache 获取。返回的 id 必须是异步的,因为 CreatedListIdCache 返回一个 promise。

const getListIdPromise = (localListId,state) => 
  // Get id from already created list
  if ( state.lists[localListId] ) 
    return Promise.resolve(state.lists[localListId].id)
  
  // Get id from pending list creations
  else if ( CreatedListIdPromiseCache[localListId] ) 
    return CreatedListIdPromiseCache[localListId];
  
  // Unexpected error
  else 
    return Promise.reject(new Error("Unable to find backend list id for list with local id = " + localListId));
  

在你的addItem中使用这个方法,这样你的addItem会自动延迟到后端id可用

// Create item, but do not attempt creation until we are sure to get a backend id
const backendListItemPromise = getListIdPromise(localListId,reduxState).then(backendListId => 
  return createBackendListItem(backendListId, itemData);
)

// Provide user optimistic feedback even if the item is not yet added to the list
dispatch(addListItemOptimistic());
backendListItemPromise.then(
  backendListItem => dispatch(addListItemCommit()),
  error => dispatch(addListItemRollback())
);

您可能想要清理 CreatedListIdPromiseCache,但对于大多数应用程序来说这可能不是很重要,除非您有非常严格的内存使用要求。


另一种选择是在前端计算后端 id,例如 UUID。你的后端只需要验证这个 id 的唯一性。因此,对于所有乐观创建的列表,您将始终拥有一个有效的后端 ID,即使后端尚未回复。

【讨论】:

【参考方案3】:

看看这个:https://github.com/gaearon/redux-thunk

单独的 id 不应该通过 reducer。在您的动作创建器(thunk)中,首先获取列表 ID,然后 then() 执行第二次调用以将项目添加到列表中。在此之后,您可以根据添加是否成功发送不同的操作。

您可以在执行此操作时分派多个操作,以报告服务器交互何时开始和完成。这将允许您显示消息或微调器,以防操作繁重且可能需要一段时间。

更深入的分析可以在这里找到:http://redux.js.org/docs/advanced/AsyncActions.html

感谢丹·阿布拉莫夫

【讨论】:

【参考方案4】:

我遇到了与您类似的问题。我需要一个队列来保证乐观操作已提交或最终提交(在网络问题的情况下)以它们创建的相同顺序发送到远程服务器,或者如果不可能则回滚。我发现仅使用 Redux 无法做到这一点,主要是因为我相信它不是为此而设计的,而且仅凭 Promise 来做这件事确实是一个难以理解的问题,除了您需要以某种方式管理队列状态这一事实。 .. 恕我直言。

我认为@Pcriulan 关于使用 redux-saga 的建议是一个很好的建议。乍一看,redux-saga 在您到达 channels 之前不会提供任何帮助。由于 JS 生成器,这为您打开了以其他方式处理并发的大门,特别是 CSP(参见 Go 或 Clojure 的异步)。甚至还有questions 说明为什么以 Saga 模式命名而不是 CSP 哈哈……反正。

以下是传奇如何帮助您处理队列:

export default function* watchRequests() 
  while (true) 
    // 1- Create a channel for request actions
    const requestChan = yield actionChannel('ASYNC_ACTION');
    let resetChannel = false;

    while (!resetChannel) 
      // 2- take from the channel
      const action = yield take(requestChan);
      // 3- Note that we're using a blocking call
      resetChannel = yield call(handleRequest, action);
    
  


function* handleRequest( asyncAction, payload ) 
  while (true) 
    try 
      // Perform action
      yield call(asyncAction, payload);
      return false;
     catch(e) 

      if(e instanceof ConflictError) 
        // Could be a rollback or syncing again with server?
        yield put( type: 'ROLLBACK', payload );
        // Store is out of consistency so
        // don't let waiting actions come through
        return true;
       else if(e instanceof ConnectionError) 
        // try again
        yield call(delay, 2000);
      

    
  

所以这里有趣的部分是通道如何充当缓冲区(队列),它不断“侦听”传入的操作,但在完成当前操作之前不会继续执行未来的操作。您可能需要查看他们的文档才能更好地掌握代码,但我认为这是值得的。重置频道部分可能会或不会满足您的需求:思考:

希望对你有帮助!

【讨论】:

【参考方案5】:

我有完美的工具来满足您的需求。当您需要对 redux 进行大量控制(尤其是任何异步操作)并且需要按顺序执行 redux 操作时,没有比 Redux Sagas 更好的工具了。它建立在 es6 生成器之上,为您提供了很多控制权,因为从某种意义上说,您可以在某些点暂停您的代码。

你描述的action queue就是所谓的saga。现在,由于它是为使用 redux 而创建的,因此可以通过在您的组件中调度来触发这些 sagas 运行。

由于 Sagas 使用生成器,您还可以确保您的调度以特定顺序发生并且仅在特定条件下发生。这是他们文档中的一个示例,我将引导您通过它来说明我的意思:

function* loginFlow() 
  while (true) 
    const user, password = yield take('LOGIN_REQUEST')
    const token = yield call(authorize, user, password)
    if (token) 
      yield call(Api.storeItem, token)
      yield take('LOGOUT')
      yield call(Api.clearItem, 'token')
    
  

好吧,起初看起来有点混乱,但这个传奇定义了登录序列需要发生的确切顺序。由于生成器的性质,允许无限循环。当您的代码达到 yield 时,它将停在该行并等待。在您告诉它之前,它不会继续到下一行。所以看看它在哪里说yield take('LOGIN_REQUEST')。 saga 将在此时让出或等待,直到您调度 'LOGIN_REQUEST',之后 saga 将调用授权方法,并一直到下一次让出。下一个方法是异步的yield call(Api.storeItem, token),因此在该代码解析之前它不会转到下一行。

现在,这就是魔法发生的地方。传奇将在yield take('LOGOUT') 再次停止,直到您在应用程序中调度 LOGOUT。 这很重要,因为如果您在 LOGOUT 之前再次发送 LOGIN_REQUEST,登录过程将不会被调用。 现在,如果您发送 LOGOUT,它将循环回到第一个 yield 并等待应用程序发送再次 LOGIN_REQUEST。

到目前为止,Redux Sagas 是我最喜欢与 Redux 一起使用的工具之一。它使您可以对应用程序进行如此多的控制,任何阅读您的代码的人都会感谢您,因为现在所有内容一次读取一行。

【讨论】:

以上是关于Redux 中的排队操作的主要内容,如果未能解决你的问题,请参考以下文章

redis 实现 分布式锁,排队等待取得锁

在回流中排队异步操作

Chrome DevTools - 时间选项卡中的“排队”是啥意思?

ASP.NET Core 中的异步延续在哪里排队?

求银行排队问题的解决方案?

未能将测试运行“xxxx 2011-10-20 13:00:00”排队。您没有执行该操作的适当权限