createAsyncThunk: отменить предыдущий запрос

Я использую createAsyncThunk для выполнения асинхронных запросов к какому-либо API. В любой момент времени должен быть активен только один запрос. Я понимаю, что запрос может быть прерван с помощью предоставленного AbortSignal, если я получил обещание, возвращенное из предыдущего вызова. Вопрос в том, может ли сам преобразователь каким-то образом самостоятельно прервать предыдущий запрос? Я рассматривал два варианта:

  • сохранение последнего AbortSignal в состоянии. Кажется неправильным, потому что состояние должно быть сериализуемым.
  • сохранение последнего обещания и его AbortSignal в глобальной переменной. Это тоже неправильно, потому что, как вы понимаете, глобальные переменные.

Любые идеи? Спасибо.


person ondrej.par    schedule 03.11.2020    source источник
comment
comment
Он не прервет запрос, вы можете вызвать прерывание для обещания, подобное отправке возврата thunk, но он не прервет запрос xhr. Я не уверен, что вы имеете в виду, говоря только об одном активном, означает ли это, что вы хотите писать только о том, был ли разрешенный запрос последним?   -  person HMR    schedule 03.11.2020
comment
@HMR: вызов прерывания для сигнала вызовет прослушиватели событий, установленные для этого сигнала. API установит свой собственный прослушиватель на это событие и будет действовать соответствующим образом (кстати, в моем случае это не HTTP API, это вещь для видеоплеера; но также можно прервать запрос XHR через AbortSignal - fetch поддерживает это). Под только одним активным я подразумеваю, что в состояние должны быть записаны только результаты последнего запроса, но также и то, что предыдущие вызовы должны получить событие прерывания и прекратить делать то, что они делают.   -  person ondrej.par    schedule 03.11.2020


Ответы (2)


Я не знаю, как работает ваш конкретный api, но ниже приведен рабочий пример того, как вы можете поместить логику прерывания в действие и редуктор, он прервет любую ранее активную поддельную выборку при выполнении новой выборки:

import * as React from 'react';
import ReactDOM from 'react-dom';
import {
  createStore,
  applyMiddleware,
  compose,
} from 'redux';
import {
  Provider,
  useDispatch,
  useSelector,
} from 'react-redux';
import {
  createAsyncThunk,
  createSlice,
} from '@reduxjs/toolkit';

const initialState = {
  entities: [],
};
// constant value to reject with if aborted
const ABORT = 'ABORT';
// fake signal constructor
function Signal() {
  this.listener = () => undefined;
  this.abort = function () {
    this.listener();
  };
}
const fakeFetch = (signal, result, time) =>
  new Promise((resolve, reject) => {
    const timer = setTimeout(() => resolve(result), time);
    signal.listener = () => {
      clearTimeout(timer);
      reject(ABORT);
    };
  });
// will abort previous active request if there is one
const latest = (fn) => {
  let previous = false;
  return (signal, result, time) => {
    if (previous) {
      previous.abort();
    }
    previous = signal;
    return fn(signal, result, time).finally(() => {
      //reset previous
      previous = false;
    });
  };
};
// fake fetch that will abort previous active is there is one
const latestFakeFetch = latest(fakeFetch);

const fetchUserById = createAsyncThunk(
  'users/fetchByIdStatus',
  async ({ id, time }) => {
    const response = await latestFakeFetch(
      new Signal(),
      id,
      time
    );
    return response;
  }
);
const usersSlice = createSlice({
  name: 'users',
  initialState: { entities: [], loading: 'idle' },
  reducers: {},
  extraReducers: {
    [fetchUserById.fulfilled]: (state, action) => {
      state.entities.push(action.payload);
    },
    [fetchUserById.rejected]: (state, action) => {
      if (action?.error?.message === ABORT) {
        //do nothing
      }
    },
  },
});

const reducer = usersSlice.reducer;
//creating store with redux dev tools
const composeEnhancers =
  window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
const store = createStore(
  reducer,
  initialState,
  composeEnhancers(
    applyMiddleware(
      ({ dispatch, getState }) => (next) => (action) =>
        typeof action === 'function'
          ? action(dispatch, getState)
          : next(action)
    )
  )
);
const App = () => {
  const dispatch = useDispatch();
  React.useEffect(() => {
    //this will be aborted as soon as the next request is made
    dispatch(
      fetchUserById({ id: 'will abort', time: 200 })
    );
    dispatch(fetchUserById({ id: 'ok', time: 100 }));
  }, [dispatch]);
  return 'hello';
};

ReactDOM.render(
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById('root')
);

Если вам нужно разрешить обещание только в том случае, если это было последнее запрошенное обещание и нет необходимости прерывать или отменять текущие обещания (игнорировать разрешение, если оно не было последним), вы можете сделать следующее:

const REPLACED_BY_NEWER = 'REPLACED_BY_NEWER';
const resolveLatest = (fn) => {
  const shared = {};
  return (...args) => {
    //set shared.current to a unique object reference
    const current = {};
    shared.current = current;
    fn(...args).then((resolve) => {
      //see if object reference has changed
      //  if so it was replaced by a newer one
      if (shared.current !== current) {
        return Promise.reject(REPLACED_BY_NEWER);
      }
      return resolve;
    });
  };
};

Как это используется, показано в этом ответе.

person HMR    schedule 04.11.2020
comment
Потребуется немного больше работы, чтобы дождаться завершения предыдущего обещания (что обычно не требуется, но необходимо в моем случае). Но это отличная идея, спасибо. Для любознательного читателя: основная часть - новейшая функция. Он создает новую функцию (оболочку) из исходного создателя полезной нагрузки и возвращает ее. Обертка хранит свой сигнал в предыдущей переменной, которая является локальной внутри последней функции, поэтому каждая оболочка имеет свою собственную переменную. Чтобы использовать это, оберните создателя полезной нагрузки и передайте его createAsyncThunk. - person ondrej.par; 04.11.2020
comment
@ ondrej.par Я обновил ответ с помощью оболочки, которая позволит разрешить все обещания, но отклонит их, если это не был последний запрос в наборе активных запросов. - person HMR; 04.11.2020
comment
Отклонение вручную при прерывании не обязательно, потому что redux-toolkit автоматически делает это при прерывании сигнала. - person ondrej.par; 04.11.2020
comment
Обратите внимание, что это не сработает, если вы пытаетесь использовать latestFakeFetch несколько раз (для разных ресурсов). Вместо этого вам нужно вызвать latest() один раз для каждого независимого ресурса. - person Bergi; 04.11.2020
comment
Я отредактировал ваш ответ по двум причинам: 1) redux-toolkit уже предоставляет экземпляр AbortSignal, поэтому его следует использовать 2) после завершения previous следует очистить, только если это все еще наш сигнал (его можно было заменить на более новый) . - person ondrej.par; 04.11.2020
comment
@Bergi Последний / последний - это когда вы запускаете асинхронную функцию несколько раз, но интересуетесь только последним разрешением здесь. Если вы хотите сгруппировать по аргументам, вы можете сделать что-то вроде this - person HMR; 04.11.2020
comment
@ ondrej.par AbortSignal asyncThunk должен запускаться извне, поэтому вы должны поместить логику в компонент, который отправляет действие для вызова прервать выполнение обещания, которое оно возвращает. ResolutionLatest имеет поведение, которое вы хотите встроить в преобразователь, поэтому в компоненте, отправляющем действие преобразователя, не требуется дополнительный код. Использование AbortSignal может работать, но не отменяет запрос. - person HMR; 04.11.2020
comment
@HMR О, понятно. Я не могу прервать операцию без AbortController. Это печально, я посмотрю, смогу ли я с этим жить. Пожалуйста, не обращайте внимания на мою правку. По крайней мере, предыдущий преобразователь прерывается, а результаты создателя полезной нагрузки игнорируются. - person ondrej.par; 05.11.2020

Основываясь на ответе @HMR, мне удалось собрать это вместе, но это довольно сложно.

Следующая функция создает внутренний асинхронный преобразователь, который выполняет реальную работу, и внешний асинхронный преобразователь, который делегирует внутреннему и прерывает предыдущие отправки, если таковые имеются.

Создатель полезной нагрузки внутреннего преобразователя также обернут для: 1) ожидания завершения предыдущего вызова создателя полезной нагрузки, 2) пропуска вызова реального создателя полезной нагрузки (и, следовательно, вызова API), если действие было прервано во время ожидания.

import { createAsyncThunk, AsyncThunk, AsyncThunkPayloadCreator, unwrapResult } from '@reduxjs/toolkit';

export function createNonConcurrentAsyncThunk<Returned, ThunkArg>(
  typePrefix: string,
  payloadCreator: AsyncThunkPayloadCreator<Returned, ThunkArg>,
  options?: Parameters<typeof createAsyncThunk>[2]
): AsyncThunk<Returned, ThunkArg, unknown> {
  let pending: {
    payloadPromise?: Promise<unknown>;
    actionAbort?: () => void;
  } = {};

  const wrappedPayloadCreator: AsyncThunkPayloadCreator<Returned, ThunkArg> = (arg, thunkAPI) => {
    const run = () => {
      if (thunkAPI.signal.aborted) {
        return thunkAPI.rejectWithValue({name: 'AbortError', message: 'Aborted'});
      }
      const promise = Promise.resolve(payloadCreator(arg, thunkAPI)).finally(() => {
        if (pending.payloadPromise === promise) {
          pending.payloadPromise = null;
        }
      });
      return pending.payloadPromise = promise;
    }

    if (pending.payloadPromise) {
      return pending.payloadPromise = pending.payloadPromise.then(run, run); // don't use finally(), replace result
    } else {
      return run();
    }
  };

  const internalThunk = createAsyncThunk(typePrefix + '-protected', wrappedPayloadCreator);

  return createAsyncThunk<Returned, ThunkArg>(
    typePrefix,
    async (arg, thunkAPI) => {
      if (pending.actionAbort) {
        pending.actionAbort();
      }
      const internalPromise = thunkAPI.dispatch(internalThunk(arg));
      const abort = internalPromise.abort;
      pending.actionAbort = abort;
      return internalPromise
        .then(unwrapResult)
        .finally(() => {
          if (pending.actionAbort === abort) {
            pending.actionAbort = null;
          }
        });
    },
    options
  );
}
person ondrej.par    schedule 05.11.2020