Redux-Saga 是 Redux 的一个中间件,它用于处理异步操作和副作用。它类似于 Redux-Thunk,但提供了更多的功能和控制,例如取消异步操作,处理复杂的任务和操作序列等。在本文中,我们将使用 Redux-Saga 构建一个单页应用程序,该应用程序使用 Reddit API 获取新闻和帖子列表,并允许用户通过搜索功能查找这些信息。
什么是 Reddit?
Reddit 是一个全球知名的社交新闻聚合网站,用户可以发布和讨论各种内容,如新闻、图片、视频、游戏、科技、体育等等。Reddit 的用户可以在所述社区内发布内容,通常是以纯文本形式发布,其他用户可在该帖子下面进行讨论和回复。 Reddit 还提供了“投票”功能,可以在投票中选出流行的帖子,使其更加突出。
准备工作
在开始构建RedditSPA之前,请确保您已经安装了必要的软件:
- Node.js 和 npm 包管理器
- Yarn 包管理器
本教程还使用了以下工具和库:
- React 前端框架
- Redux 状态管理库
- Redux-Saga 异步中间件
- Axios HTTP 客户端库
- React-Router 前端路由库
您可以使用以下命令安装它们:
npm install -g create-react-app yarn create-react-app reddit-spa cd reddit-spa yarn add react-redux redux redux-saga axios react-router-dom
我们还需要 Reddit 的开发者 API 密钥。您可以在Reddit 开发者门户注册一个应用程序并获取它。从 Reddit 获取的 API 密钥是机密的,请勿在任何公共代码存储库中包含它。
程序代码已放在 GitHub 上,你可以 clone 到本地执行查看。
实现 Reddit API 接口
为了从 Reddit 的 API 获取信息,我们需要编写一个 API 客户端。使用 Axios 可以轻松地完成 HTTP 请求,而且我们还可以设置请求头和拦截器以处理响应和错误。
// javascriptcn.com 代码示例 import axios from 'axios'; const baseUrl = 'https://www.reddit.com'; const redditApiClient = axios.create({ headers: { 'User-Agent': 'redux-saga' } }); export default redditApiClient; export const fetchPosts = (subreddit, filter) => redditApiClient.get(`${baseUrl}/r/${subreddit}/${filter}.json`);
我们主要是使用 axios.create() 方法创建API客户端,指定了 Reddit 的基本 URL,{'User-Agent': 'redux-saga'}
} 用于 Reddit API 要求一个请求头部分指定用户代理,我们还定义了一个 getPosts 函数,它使用指定的子Reddit,并向 Reddit API 发出 GET 请求以检索新帖子列表。
构建 Redux Store
在我们的应用程序中,我们需要存储 Reddit API 返回的新闻和文章列表。我们将使用 Redux 来管理应用程序状态,创建一个 Redux store。
Actions
我们使用 Redux 定义一些 actions 来控制 store。我们使用它来请求 Reddit API 并将结果存储在 store 中。我们同时定义了一个 setFilter action 以设置当前 Reddit 主题的过滤器值。
// javascriptcn.com 代码示例 export const REQUEST_POSTS = 'REQUEST_POSTS'; export const RECEIVE_POSTS = 'RECEIVE_POSTS'; export const SELECT_SUBREDDIT = 'SELECT_SUBREDDIT'; export const INVALIDATE_SUBREDDIT = 'INVALIDATE_SUBREDDIT'; export const SET_FILTER = 'SET_FILTER'; export const selectSubreddit = subreddit => ({ type: SELECT_SUBREDDIT, subreddit }); export const invalidateSubreddit = subreddit => ({ type: INVALIDATE_SUBREDDIT, subreddit }); export const requestPosts = subreddit => ({ type: REQUEST_POSTS, subreddit }); export const receivePosts = (subreddit, filter, json) => ({ type: RECEIVE_POSTS, subreddit, filter, posts: json.data.children.map(child => child.data), receivedAt: Date.now() }); export const setFilter = filter => ({ type: SET_FILTER, filter });
我们使用 type 字段为每个 action 定义一个唯一的名字,payload 字段存储有关请求和响应的数据。在上面的代码中,我们定义了 5 个 actions:
- SELECT_SUBREDDIT:设置当前 Reddit 的主题子版块。
- INVALIDATE_SUBREDDIT:通知 Redux store 文章已过期,需要重新加载。
- REQUEST_POSTS:请求 Reddit API 并通知 store。
- RECEIVE_POSTS:接收 Reddit API 返回的文章并将其存储在 store 中。
- SET_FILTER:设置当前 Reddit 主题的过滤器值。
我们需要使用 reducer 函数组合上面的actions
Reducers
Redux 应用的 reducer 管理着它的 state(状态)的更新。我们需要定义以下 actions 的状态存储方式以及如何响应 those actions:
// javascriptcn.com 代码示例 import { REQUEST_POSTS, RECEIVE_POSTS, SELECT_SUBREDDIT, INVALIDATE_SUBREDDIT, SET_FILTER } from '../actions'; const posts = ( state = { isFetching: false, didInvalidate: false, items: [] }, action ) => { switch (action.type) { case INVALIDATE_SUBREDDIT: return { ...state, didInvalidate: true }; case REQUEST_POSTS: return { ...state, isFetching: true, didInvalidate: false }; case RECEIVE_POSTS: return { ...state, isFetching: false, didInvalidate: false, items: action.posts, lastUpdated: action.receivedAt, filter: action.filter }; default: return state; } }; const subreddit = ( state = '', action ) => { switch (action.type) { case SELECT_SUBREDDIT: return action.subreddit; default: return state; } }; const filter = ( state = 'hot', action ) => { switch (action.type) { case SET_FILTER: return action.filter; default: return state; } }; const postsBySubreddit = ( state = { }, action ) => { switch (action.type) { case INVALIDATE_SUBREDDIT: case RECEIVE_POSTS: case REQUEST_POSTS: return { ...state, [action.subreddit]: posts(state[action.subreddit], action) }; default: return state; } }; export default postsBySubreddit; export const getFilter = state => state.filter; export const getSubreddit = state => state.subreddit; export const getPosts = state => state.items; export const getLastUpdated = state => state.lastUpdated; export const getIsFetching = state => state.isFetching; export const getDidInvalidate = state => state.didInvalidate; export const getSubredditPosts = (state) => { const subreddit = getSubreddit(state); return state[subreddit] || {}; };
Redux Store 由四个 reducer 函数组成:
- subreddit:纯函数,存储当前 Reddit 的子版块名称。
- filter:纯函数,存储当前 Reddit 主题的过滤器值。
- posts:纯函数,存储 Reddit API 返回的新闻和文章列表。
- postsBySubreddit:纯函数,用来组合上面的 three reducer,并以“子Reddit”为键存储他们的值。
注意以上的reducer函数操作每个状态域的方法,分别是状态key项的名称(如上的 items),不同action可能会改变相同或不同的键的值。
Store
Store 定义了 actions、reducers 和中间件的集合。在我们的应用程序中,它通过存储所有 Reddit 相关的状态信息来帮助我们控制整个应用程序的行为。
// javascriptcn.com 代码示例 import { createStore, applyMiddleware } from 'redux'; import createSagaMiddleware from 'redux-saga'; import rootReducer from '../reducers'; import rootSaga from '../sagas'; const configureStore = (preloadedState) => { const sagaMiddleware = createSagaMiddleware(); const store = createStore( rootReducer, preloadedState, applyMiddleware(sagaMiddleware) ); sagaMiddleware.run(rootSaga); return store; }; export default configureStore;
其中 rootSaga 是 Redux Saga 应用程序里的核心逻辑部分。
实现 Reddit Saga
我们使用 Redux-Saga 中间件模块来处理异步操作和副作用。Sagas 是一些用于处理副作用(如读取和写入磁盘、发出 HTTP 请求、访问浏览器缓存等)的函数, Saga 将它们的执行与 Redux Store 的状态同步。在本文中,我们将大量使用 Sagas 来管理应用程序中的所有操作。
Reddit Api Saga,处理请求和响应
以下是 Reddit API 请求和响应过程的 Saga 函数。
requests-saga.js
// javascriptcn.com 代码示例 import { call, put, takeLatest } from 'redux-saga/effects'; import { getSubreddit, getFilter } from '../reducers'; import { fetchPosts, receivePosts, REQUEST_POSTS } from '../actions'; export function* fetchPostsSaga() { try { const subreddit = yield getSubreddit(); const filter = yield getFilter(); const response = yield call(fetchPosts, subreddit, filter); yield put(receivePosts(subreddit, filter, response.data)); } catch (e) { console.log(e); } } export default function* watchFetchPosts() { yield takeLatest(REQUEST_POSTS, fetchPostsSaga); }
这里我们引入了一些 Redux-Saga 的顶级函数:
- takeLatest:对于同一个 action 的每个新实例,以非阻止屏幕模式较新的 call fork cancel 效果来选择仅最新的一个发生,并立即取代之前的 saga。
- call:在 Saga 中调用函数。
- put:分发一个 Action。
- getSubreddit / getFilter:从 store 中选择当前 Reddit 主题和过滤器值,以便向 Reddit API 发出正确的请求。
- fetchPosts:由我们之前实现的我们的Reddit客户端调用Reddit API。
类 Reddit 页面 Saga
在 Reddit API 请求成功后,我们需要将 HTML 格式的结果渲染为页面元素。我们使用 React 组件来处理 UI,并将 Sagas 用于一些类似于组件生命周期函数的操作。下面是约会 Reddit 页面的 Saga 函数。
front-page-saga.js
// javascriptcn.com 代码示例 import { call, put, select, takeLatest } from 'redux-saga/effects'; import { RECEIVE_POSTS, SELECT_SUBREDDIT, SET_FILTER, } from '../actions'; import { fetchPostsSaga } from './requests-saga'; import { getPosts, getSubredditPosts, getDidInvalidate, getIsFetching, getLastUpdated, getFilter } from '../reducers'; export function* invalidateSubredditSaga() { const subreddit = yield select(getSubreddit); yield call(fetchPostsSaga); } export function* selectSubredditSaga() { yield call(fetchPostsSaga); } export function* setFilterSaga() { yield call(fetchPostsSaga); } export function* shouldFetchPosts() { const isFetching = yield select(getIsFetching); const didInvalidate = yield select(getDidInvalidate); const lastUpdated = yield select(getLastUpdated); return !isFetching && (didInvalidate || !lastUpdated); } export function* fetchPostsIfNeededSaga() { const subredditData = yield select(getSubredditPosts); const shouldFetch = yield shouldFetchPosts(); if (shouldFetch) { yield put({ type: 'REQUEST_POSTS' }); } } export default function* watchActions() { yield takeLatest(SELECT_SUBREDDIT, selectSubredditSaga); yield takeLatest(SET_FILTER, setFilterSaga); yield takeLatest(RECEIVE_POSTS, fetchPostsIfNeededSaga); }
我们定义了 5 个 Saga 函数:
- invalidateSubredditSaga:通知 store subreddit 已失效,需要重新请求新的文章。
- selectSubredditSaga:使用 Reddit API 请求和响应新的文章、更新 store 中的列表。
- setFilterSaga:使用 Reddit API 请求和响应过滤器,更新 store 中的 filter 状态。
- shouldFetchPosts:检查 store 是否具有失效状态、未开始拉取状态、已过期状态。 如果是,我们需要重新请求新的文章。
- fetchPostsIfNeededSaga:该 Saga 函数根据 shouldFetchPosts() 执行是否需要更新 Reddit API,如果需要,则发送 get 请求来获取 Reddit API 新的文章。
以上 5 个 Saga 函数用于监听 Reddit 页面动作,并在页面发生上述动作时调用。
Root Saga:组合所有 Sagas
我们使用根 Saga 将上述所有 Sagas 组合在一起。
// javascriptcn.com 代码示例 import { all } from 'redux-saga/effects'; import watchFetchPosts from './requests-saga'; import watchActions from './front-page-saga'; export default function* rootSaga() { yield all([ watchFetchPosts(), watchActions() ]); }
我们不需要使用一个 Saga 来处理所有的 action,而是使用多个 Saga 并使用 all() 函数将它们组合在一起。
渲染 Reddit SPA 应用
我们需要定义一个使用 React 的 Reddit App 组件,以便渲染 Reddit 应用。 Reddit App 具有以下功能:
- Header:应用程序的标题以及 Reddit 的子版块的菜单。
- Sidebar:显示 Reddit 主题的过滤器。
- Main:Reddit 的新闻和文章列表。
- 新闻和文章列表项。
在 Redux 中,我们将 Reddit App 整体作为一个容器组件实现,Redux 将负责管理 Reddit 的状态信息。根据 store 中的数据,AppComponent会从 Reddit.API 获取数据,并将其传递给 Reddit.Content.
App.jsx
// javascriptcn.com 代码示例 import React, { useEffect } from 'react'; import PropTypes from 'prop-types'; import { connect } from 'react-redux'; import { selectSubreddit, invalidateSubreddit, setFilter, requestPosts } from './actions'; import Filter from './containers/Filter'; import Header from './components/Header'; import Sidebar from './components/Sidebar'; import Content from './components/Content'; import { getSubreddit, getFilter, getPosts, getIsFetching, getDidInvalidate } from './reducers'; const App = ({ subreddit, filter, posts, isFetching, didInvalidate, dispatch }) => { useEffect(() => { dispatch(requestPosts()); }, [subreddit, filter]); return ( <> <Header /> <div className="container"> <Sidebar subreddit={subreddit} posts={posts} isFetching={isFetching} didInvalidate={didInvalidate} onSubredditChange={subreddit => dispatch(selectSubreddit(subreddit))} onInvalidateSubreddit={() => dispatch(invalidateSubreddit())} /> <Filter filter={filter} onChange={filter => dispatch(setFilter(filter))} /> <Content posts={posts} isFetching={isFetching} didInvalidate={didInvalidate} /> </div> </> ); }; App.propTypes = { subreddit: PropTypes.string.isRequired, filter: PropTypes.string.isRequired, posts: PropTypes.array.isRequired, isFetching: PropTypes.bool.isRequired, didInvalidate: PropTypes.bool.isRequired, dispatch: PropTypes.func.isRequired, }; const mapStateToProps = state => ({ subreddit: getSubreddit(state), filter: getFilter(state), posts: getPosts(state), isFetching: getIsFetching(state), didInvalidate: getDidInvalidate(state), }); export default connect(mapStateToProps)(App);
组件
下面是 Reddit 应用程序的组件定义。
Header.jsx
// javascriptcn.com 代码示例 import React from 'react'; const Header = () => ( <nav> <div className="nav-wrapper"> <a href="/" className="brand-logo center">Reddit SPA</a> </div> </nav> ); export default Header;
Sidebar.jsx
// javascriptcn.com 代码示例 import React from 'react'; import PropTypes from 'prop-types'; import classNames from 'classnames'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faSpinner } from '@fortawesome/free-solid-svg-icons'; import { faArrowCircleRight } from '@fortawesome/free-solid-svg-icons'; import Subreddit from './Subreddit'; const Sidebar = ({ subreddit, posts, isFetching, didInvalidate, onSubredditChange, onInvalidateSubreddit }) => ( <div className="row"> <div className="col s12 m4"> <div className="card"> <div className="card-content"> <h5>Select a subreddit</h5> <ul className="collection"> {posts.map(sub => ( <Subreddit key={sub} subreddit={sub} selected={subreddit === sub} onClick={() => onSubredditChange(sub)} /> ))} </ul> </div> <div className="card-action"> {isFetching && !didInvalidate && ( <span> Fetching... <FontAwesomeIcon icon={faSpinner} spin /> </span> )} {!isFetching && didInvalidate && ( <button type="button" onClick={() => onInvalidateSubreddit()}> Retry </button> )} {!isFetching && !didInvalidate && ( <button type="button" onClick={() => onInvalidateSubreddit()}> Refresh <FontAwesomeIcon icon={faArrowCircleRight} /> </button> )} </div> </div> </div> </div> ); Sidebar.propTypes = { subreddit: PropTypes.string.isRequired, posts: PropTypes.array.isRequired, isFetching: PropTypes.bool.isRequired, didInvalidate: PropTypes.bool.isRequired, onSubredditChange: PropTypes.func.isRequired, onInvalidateSubreddit: PropTypes.func.isRequired, }; export default Sidebar;
Subreddit.jsx
// javascriptcn.com 代码示例 import React from 'react'; import PropTypes from 'prop-types'; const Subreddit = ({ subreddit, selected, onClick }) => ( <li className={selected ? 'collection-item active' : 'collection-item'} onClick={onClick} > {subreddit} </li> ); Subreddit.propTypes = { subreddit: PropTypes.string.isRequired, selected: PropTypes.bool.isRequired, onClick: PropTypes.func.isRequired, }; export default Subreddit;
Filter.jsx
// javascriptcn.com 代码示例 import React from 'react'; import PropTypes from 'prop-types'; const Filter = ({ filter, onChange }) => ( <div> <h4 className="center-align">Filter Options</h4> <div className="input-field"> <select className="browser-default" value={filter} onChange={e => onChange(e.target.value)}> <option value="hot">Hot</option> <option value="new">New</option> <option value="rising">Rising</option> </select> </div> </div> ); Filter.propTypes = { filter: PropTypes.string.isRequired, onChange: PropTypes.func.isRequired, }; export default Filter;
Content.jsx
// javascriptcn.com 代码示例 import React from 'react'; import PropTypes from 'prop-types'; import classNames from 'classnames'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faSpinner } from '@fortawesome/free-solid-svg-icons'; import { faExclamationTriangle } from '@fortawesome/free-solid-svg-icons'; import Article from './Article'; const Content = ({ posts, isFetching, didInvalidate }) => ( <div className="col s12 m8"> {didInvalidate && !isFetching && ( <div className="center-align"> Failed to fetch posts. {' '} <FontAwesomeIcon icon={faExclamationTriangle} /> </div> )} {isFetching && ( <div className="center-align"> <FontAwesomeIcon icon={faSpinner} spin /> </div> )} {posts.map(post => ( <Article key={post.id} post={post} /> ))} </div> ); Content.propTypes = { posts: PropTypes.array.isRequired, isFetching: PropTypes.bool.isRequired, didInvalidate: PropTypes.bool.isRequired, }; export default Content;
Article.jsx
// javascriptcn.com 代码示例 import React from 'react'; import PropTypes from 'prop-types'; const Article = ({ post }) => ( <div className="card"> <div className="card-content"> <div className="row valign-wrapper"> <div className="col s3"> <img src={post.thumbnail} alt={post.title} className="responsive-img" style={{ maxWidth: '100%' }} /> </div> <div className="col s9"> <span className="card-title">{post.title}</span> <p>{post.selftext}</p> </div> </div> </div> <div className="card-action"> <a href={post.url} target="_blank" rel="noopener noreferrer">Go to article</a> </div> </div> ); Article.propTypes = { post: PropTypes.object.isRequired, }; export default Article;
到此为止,这个 Reddit 应用程序就完成了。
总结
在示例代码中,我使用了 React、Redux 和 Redux-Saga,还使用了 React-Router 和 Axios 库。这些技术和库对于构建单页应用程序非常有效,我们可以模块化地开发应用程序,拆分组件、代码和功能,使代码易于阅读、维护和扩展。
Redux-Saga 可以协调所有异步操作和副作用,使应用程序非常健壮。 Sagas 的执行也更容易进行测试,因为我们可以一步一步地处理所有副作用。此外,Redux-Saga 对于取消异步操作和处理复杂异步操作非常方便,我们只需设置相应的逻辑即可。
重点在 Saga 代码的接收和处理,比较繁琐,要点需要记清楚。将action提交到store,由reducer进行交互,需要注意的是每个reducer的状态域,注意处理方法。
完整的源码可以参见这里。
希望对初学Redux和Sagas的读者有所启发和帮助。
来源:JavaScript中文网 ,转载请注明来源 本文地址:https://www.javascriptcn.com/post/654f36e97d4982a6eb831d23