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 中的排队操作的主要内容,如果未能解决你的问题,请参考以下文章