使用 Redux Thunk 在 React Redux 应用程序中通过异步调用保持 DRY

Staying DRY with Async Calls in React Redux Application With Redux Thunk

问题

我目前遇到一个问题,我想如何执行一个 AJAX 可以由多个触发的请求 页面上不同的 UI 元素。 AJAX 请求总是去到相同的端点,并且总是发送相同的属性 从 redux 存储到端点(尽管属性值可能会因用户交互而改变)。

我完全意识到我当前的实现很糟糕。

破败

为了画得更清楚,我正在构建一个搜索页面,其中多个 UI 元素可以触发新的搜索被激活。 有一个端点,我们称之为“/api/search/”,它需要一个包含从 Redux Store 中提取的数据的查询字符串。它看起来像这样:

term=some%20string&categories=243,2968,292&tags=11,25,99&articleType=All

我被绊倒的地方是 UI 元素需要触发对商店的同步更新,但还需要触发执行搜索的 Thunk。这是我的顶级组件,我将 "executeSearch" Thunk 函数传递给需要触发搜索的所有子组件。我最初的想法是,我可以使用一个 thunk 来处理所有需要执行搜索的交互,而不是为每个 thunk 编写一个单独的 thunk。

P.S。如果下面的代码对您没有意义,请不要过度分析它。如果您浏览下一节,然后阅读 "Three Scenarios" 节,这可能有助于您更好地理解一切是如何工作的。图片也包含在该部分中。

class App extends Component {
  executeSearch = () => {
    this.props.executeSearch(this.props.store); // This is my Thunk
  };

  render() {
    const { updateSearchTerm, clearAll, dropdownActive, dropdownType } = this.props;

    return (
      <section className="standard-content">
        <div className="inner-container narrow">
          <div className="centered">
            <h1>Search</h1>
            <h2 className="alt">Search our extensive database of research articles.</h2>
          </div>

          <SearchTerm initSearch={this.executeSearch} updateSearchTerm={updateSearchTerm} />
          <ExtraOptions clearAll={clearAll} />
          <Filters executeSearch={this.executeSearch} />
        </div>

        {dropdownActive ? (
          dropdownType === 'categories' ? (
            <CategoryDropdown executeSearch={this.executeSearch} />
          ) : (
            <TagDropdown executeSearch={this.executeSearch} />
          )
        ) : null}

        <SearchResults />
      </section>
    );
  }
}

const mapStateToProps = state => {
  return {
    store: state,
    dropdownActive: state.dropdownActive,
    dropdownType: state.dropdownType
  };
};

executeSearch 函数从存储中获取所有值,但仅使用我在本期开头概述的值。如果有帮助,此 post 底部有整个 redux 存储的代码示例。无论如何,这就是 Thunk 的样子:

export const executeSearch = criteria => {
  const searchQueryUrl = craftSearchQueryUrl(criteria);

  // if (term === '' && !selectedCategories && !selectedTags && articleType === 'All') {
  //   return { type: ABORT_SEARCH };
  // }

  return async dispatch => {
    dispatch({ type: FETCH_SEARCH_RESULTS });

    try {
      const res = await axios.post(`${window.siteUrl}api/search`, searchQueryUrl);
      dispatch({ type: FETCH_SEARCH_RESULTS_SUCCESS, searchResults: res.data });
    } catch (err) {
      dispatch({ type: FETCH_SEARCH_RESULTS_FAILED });
    }
  };
};

// Helper function to craft a proper search query string
const craftSearchQueryUrl = criteria => {
  const { term, articleType, selectedCategories, selectedTags } = criteria;
  let categoriesString = selectedCategories.join(',');
  let tagsString = selectedTags.join(',');

  return `term=${term}&articleType=${articleType}&categories=${categoriesString}&tags=${tagsString}&offset=${offset}`;
};

请记住,这里的 "criteria" 参数是我在 App.js 中作为参数传递的整个商店对象。您会看到我只在 craftSearchQueryUrl 函数中使用了我需要的属性。

三个场景

我附上了一张屏幕截图(标有字母),我希望在其中解释哪些地方有效,哪些地方无效。

A.) 用户应该能够填写这个文本字段,当他们按下放大镜时,应该会触发 Thunk。这很好用,因为文本字段中的值在每次击键时都会在商店中更新,这意味着商店中的值始终是最新的,甚至在用户有机会按下放大镜之前。

B.) 默认情况下,"All" 复选框在初始页面加载时处于选中状态。如果用户单击旁边列出的其他复选框之一,应该会立即启动搜索。这是我的问题开始出现的地方。这是我目前拥有的代码:

export default ({ handleCheckboxChange, articleType, executeSearch }) => (
  <div style={{ marginTop: '15px', marginBottom: '20px' }}>
    <span className="search-title">Type: </span>

    {articleTypes.map(type => (
      <Checkbox
        key={type}
        type={type}
        handleCheckboxChange={() => {
          handleCheckboxChange('articleType', { type });
          executeSearch();
        }}
        isChecked={type === articleType}
      />
    ))}
  </div>
);

当复选框发生变化时,它会更新商店中的 articleType 值(通过 handleCheckboxChange),然后执行从 App.js 传下来的搜索功能。但是,更新后的 articleValue 类型不是更新后的值类型,因为我相信在商店有机会更新此值之前会调用搜索功能。

C.) B 的同样问题也出现在这里。当您单击 "Refine by" 部分中的其中一个按钮(类别或标签)时,会出现此下拉列表,其中包含多个可供选择的复选框。我实际上将哪些复选框变为 checked/unchecked 存储在本地状态,直到用户单击保存按钮。按下保存按钮后,新的 checked/unchecked 复选框值应在商店中更新,然后应通过从 App.js.

传递下来的 Thunk 启动新搜索
export default ({ children, toggleDropdown, handleCheckboxChange, refineBy, executeSearch }) => {
  const handleSave = () => {
    handleCheckboxChange(refineBy.classification, refineBy);
    toggleDropdown('close');
    executeSearch(); // None of the checkbox values that were changed are reflected when the search executes
  };

  return (
    <div className="faux-dropdown">
      <button className="close-dropdown" onClick={() => toggleDropdown('close')}>
        <span>X</span>
      </button>
      <div className="checks">
        <div className="row">{children}</div>
      </div>

      <div className="filter-selection">
        <button className="refine-save" onClick={handleSave}>
          Save
        </button>
      </div>
    </div>
  );
};

请务必注意,执行搜索时使用的值不是 B 和 C 的更新值,但实际上它们在商店内已正确更新。

其他解决方案

我的另一个想法可能是创建一个 redux 中间件,但老实说,在尝试其他任何事情之前,我真的可以在这方面寻求一些专家的帮助。理想情况下,公认的解决方案在其解释中将是彻底的, 并包含一个在处理 Redux 应用程序时考虑最佳架构实践的解决方案。也许我只是在这里做了一些根本性的错误。

杂项

这是我的完整商店(处于初始状态)的样子,如果有帮助的话:

const initialState = {
  term: '',
  articleType: 'All',
  totalJournalArticles: 0,
  categories: [],
  selectedCategories: [],
  tags: [],
  selectedTags: [],
  searchResults: [],
  offset: 0,
  dropdownActive: false,
  dropdownType: '',
  isFetching: false
};

App 中的以下块是问题的症结所在:

  executeSearch = () => {
    this.props.executeSearch(this.props.store); // This is my Thunk
  };

executeSearch 方法在渲染 App 时将 store 融入其中。

当你这样做时:

  handleCheckboxChange('articleType', { type });
  executeSearch();

当您到达 executeSearch 时,您的 Redux 商店将同步更新,但这会产生一个新的 store 对象,这将导致 App 被重新渲染但是不会影响executeSearch正在使用的store对象。

正如其他人在评论中指出的那样,我认为处理此问题的最直接方法是使用中间件,该中间件提供一种机制来在商店更新后执行副作用以响应 redux 操作。为此,我个人会推荐 Redux Saga,但我知道还有其他选择。在这种情况下,您将有一个 saga 来监视任何应该触发搜索的操作,然后 saga 是唯一调用 executeSearch 的东西,并且 saga 将能够传递更新的 storeexecuteSearch.

采用 Ryan Cogswell 解决方案效果非常好。我能够删除所有让事情变得混乱的代码,并且不再需要担心将一些状态从商店传递给我的异步操作创建者,因为我现在正在使用 Redux Saga。

我重构的 App.js 现在看起来像:

class App extends Component {
  render() {
    const {
      updateSearchTerm,
      clearAll,
      dropdownActive,
      dropdownType,
      fetchSearchResults
    } = this.props;

    return (
      <section className="standard-content">
        <div className="inner-container narrow">
          <div className="centered">
            <h1>Search</h1>
            <h2 className="alt">Search our extensive database of research articles.</h2>
          </div>

          <SearchTerm fetchSearchResults={fetchSearchResults} updateSearchTerm={updateSearchTerm} />
          <ExtraOptions clearAll={clearAll} />
          <Filters />
        </div>

        {dropdownActive ? (
          dropdownType === 'categories' ? (
            <CategoryDropdown />
          ) : (
            <TagDropdown />
          )
        ) : null}

        <SearchResults />
      </section>
    );
  }
}

我需要做的就是将 fetchSearchResults 动作创建器导入我需要的任何 redux 连接组件,并确保它被执行。例如:

export default ({ handleCheckboxChange, articleType, fetchSearchResults }) => (
  <div style={{ marginTop: '15px', marginBottom: '20px' }}>
    <span className="search-title">Type: </span>

    {articleTypes.map(type => (
      <Checkbox
        key={type}
        type={type}
        handleCheckboxChange={() => {
          handleCheckboxChange('articleType', { type });
          fetchSearchResults();
        }}
        isChecked={type === articleType}
      />
    ))}
  </div>
);

这是动作创建器现在的样子...

export const fetchSearchResults = () => ({ type: FETCH_SEARCH_RESULTS });

这可以让您的动作创作者保持纯粹和超简单的推理。每当在我的 UI 的任何部分调度此操作时,我的 saga 都会选择它,并使用商店的最新版本执行搜索。

这是让它发挥作用的故事:

import { select, takeLatest, call, put } from 'redux-saga/effects';
import { getSearchCriteria } from '../selectors';
import { Api, craftSearchQueryUrl } from '../utils';
import {
  FETCH_SEARCH_RESULTS,
  FETCH_SEARCH_RESULTS_SUCCESS,
  FETCH_SEARCH_RESULTS_FAILED
} from '../actions';

function* fetchSearchResults() {
  const criteria = yield select(getSearchCriteria);

  try {
    const { data } = yield call(Api.get, craftSearchQueryUrl(criteria));
    yield put({ type: FETCH_SEARCH_RESULTS_SUCCESS, searchResults: data });
  } catch (err) {
    yield put({ type: FETCH_SEARCH_RESULTS_FAILED, error: err });
  }
}

export default function* searchResults() {
  yield takeLatest(FETCH_SEARCH_RESULTS, fetchSearchResults);
}