使用 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 将能够传递更新的 store
至 executeSearch
.
采用 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);
}
问题
我目前遇到一个问题,我想如何执行一个 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 将能够传递更新的 store
至 executeSearch
.
采用 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);
}