参考地址
基于TypeScript语言的官方Redux高级教学示例Reddit API
js最终效果图:
设计思路:
设计State数据结构接口(IStoreState)
Reddit API 数据结构(部分):(使用了谷歌网上商店的Json-handle插件)
| Reddit API | 官网使用的State结构: |
|---|---|
仔细对比一下 就能发现,其实我们所需要的Reddit API数据 只有两种:
- items里面的数据
- postsBySubreddit下面的不定参数(frontend和reactjs)
至于其他属性诸如: isFetching、didInvalidate、lastUpdated等等都与Reddit API 无关,
数据接口的设计如下:
export interface IStoreState {
selectedSubreddit: string;
postsBySubreddit: {
[key: string]: SubReddit
}
}
export interface SubReddit {
isFetching: boolean;
didInvalidate: boolean;
lastUpdated?: number
items: {
id: number;
title: string;
}[];
}
不定参数参考地址:zhuanlan.zhihu.com/p/113126825
这里有个小技巧:把原始数据输进去之后,鼠标停止不动马上就能看到数据的类型构成,这时候可以直接复制到对应的接口上面去,非常方便。
注意事项:
- API数据结构的复杂度与store结构的复杂度没有必然联系,两者完全不是一回事,不要有心理负担;
创建Store
这部分没什么好说的 ,照着做就行,注意把loggerMiddleware也加上去,只有这样你才能勉强体会到Redux的魅力。
设计Actions 创建函数接口:
照着网上demo做就行,注意一下subreddit和post变量类型,仔细查看官方文档就能发现subreddit这个变量类型是string,它其实只有:reactjs和 fronted两个常量名,至于post,不清楚!不过肯定跟Reddit API有关,先用Any代替,等出事再说
- 每个Action的type(用
emus) - 每个Action创建函数的返回类型
- Action创建函数的参数类型(不清楚的可以先用any)替代
- 添加IActionTypes
export enum ActionType {
// 用户选择
SELECT_SUBREDDIT = "SELECT_SUBREDDIT ",
// 刷新
INVALIDATE_SUBREDDIT = "INVALIDATE_SUBREDDIT",
// 指定Post
REQUEST_POSTS = "REQUEST_POSTS ",
// 收到响应
RECEIVE_POSTS = "RECEIVE_POSTS ",
}
interface SelectSubreddit_Action {
type: ActionType.SELECT_SUBREDDIT,
subreddit: string
}
interface InvalidateSubreddit_Action {
type: ActionType.INVALIDATE_SUBREDDIT,
subreddit: string
}
interface RequestPosts_Action {
type: ActionType.REQUEST_POSTS,
subreddit: string
}
interface ReceivePosts_Action {
type: ActionType.RECEIVE_POSTS,
subreddit: string,
posts: {
id: number;
title: string;
}[];
receivedAt: number
}
export type IAction = InvalidateSubreddit_Action | RequestPosts_Action | ReceivePosts_Action | SelectSubreddit_Action;
优化Action创建函数:
照着网上demo修改就行,不过对于函数shouldFetchPosts、 fetchPostsIfNeeded、fetchPosts这三个函数需要有部分改用;这一步卡了我好久,尤其注意几点:
-
本案例中用到了Thunk中间件,而Thunk本质是对Dispatch进行简单改造。因此在声明dispatch类型时,第一个dispatch的类型是
ThunkDispatch<IStoreState, undefined, any>后面的dispatch函数都是引用redux的dispatch接口 -
注意
shouldFetchPosts函数中参数post的类型。在typescript中,对于这种不定参数的赋值,试了很多方法,但都不太行。目前比较可行的只有post定义成let这一种办法interface I{ [key:string]:string } let posts: IStoreState = {"name":lili}; posts[subreddit] = state.postsBySubreddit[subreddit]; -
fetchPostsIfNeeded中,用axios来替代官方的fetch库
function shouldFetchPosts(state: IStoreState, subreddit: string) {
let posts: IStoreState['postsBySubreddit'] =
state.postsBySubreddit;
posts[subreddit] = state.postsBySubreddit[subreddit];
if (!posts[subreddit]) {
return true
} else if (posts[subreddit].isFetching) {
return false
} else {
return posts[subreddit].didInvalidate
}
}
export function fetchPostsIfNeeded(subreddit: string) {
return (dispatch: ThunkDispatch<IStoreState, undefined, any>, getState: () => IStoreState) => {
if (shouldFetchPosts(getState(), subreddit)) {
return dispatch(fetchPosts(subreddit))
}
}
}
export const fetchPosts = (subreddit: string) => {
return (dispatch: Dispatch) => {
let htm = `https://www.reddit.com/r/${subreddit}.json`
axios.get(htm).then((res) => {
const data = res.data.data.children;
dispatch(receivePosts(subreddit, data))
})
}
}
优化Reducer函数
自己照着把红色部分改掉就行了,不过我在postsBySubreddit翻了一些跟头:
nextState的类型确定,还是不定参数那套,以后注意一下。let声明,然后把对象整个包进去state类型,它到底应该属于store中的哪一层级?这个一定要弄清楚,不然后面非常麻烦。
type postsBySubreddit_State = IStoreState['postsBySubreddit']
export function postsBySubreddit(state: postsBySubreddit_State = InitialState.postsBySubreddit, action: IAction) {
const subreddit = action.subreddit;
// type nextStateProps = postsBySubreddit_State[`${subreddit}`];
switch (action.type) {
case ActionType.INVALIDATE_SUBREDDIT:
case ActionType.RECEIVE_POSTS:
case ActionType.REQUEST_POSTS:
// nextState 与 state 是平级的
let nextState: postsBySubreddit_State = {
subreddit: {
isFetching: false,
didInvalidate: false,
items: [{
id: 1,
title: `string`,
}]
}
}
nextState[subreddit] = posts(state[subreddit], action);
return Object.assign({}, state, nextState)
default:
return state
}
}
优化Container函数
这个按照之前的习惯改就行了,问题基本都能解决。注意几点:
- AsyncApp中
dispatch类型只能设成any,这里面的两个dispatch要接收两个完全不一样的函数类型,完全没法兼容,只能用any处理 - AsyncApp中的
IProps要和mapStateToProps中的state的数据类型要基本保持一致,至少得互相兼容 - 对于需要使用map方法的变量类型,可以用
Array<any>暂时取代
另外还有几个安装包
yarn add react-redux axios redux redux-thunk redux-logger @types/react-redux @types/redux @types/axios @types/redux-thunk @types/redux-logger
最终Demo
src
----redux
--------actions
index.tsx
--------containers
index.tsx
--------reducers
demo1.tsx
index.tsx
store.tsx
...
actions.tsx
import axios from 'axios'
import { Dispatch } from "redux";
import { ThunkDispatch } from 'redux-thunk';
import { IStoreState } from "../reducers";
export enum ActionType {
// 选择Subreddit
SELECT_SUBREDDIT = "SELECT_SUBREDDIT ",
// 请求失败
INVALIDATE_SUBREDDIT = "INVALIDATE_SUBREDDIT",
// 发起请求
REQUEST_POSTS = "REQUEST_POSTS ",
// 收到请求
RECEIVE_POSTS = "RECEIVE_POSTS ",
}
interface SelectSubreddit_Action {
type: ActionType.SELECT_SUBREDDIT,
subreddit: string
}
export function selectSubreddit(subreddit: string): SelectSubreddit_Action {
return {
type: ActionType.SELECT_SUBREDDIT,
subreddit
}
}
interface InvalidateSubreddit_Action {
type: ActionType.INVALIDATE_SUBREDDIT,
subreddit: string
}
export function invalidateSubreddit(subreddit: string): InvalidateSubreddit_Action {
return {
type: ActionType.INVALIDATE_SUBREDDIT,
subreddit
}
}
interface RequestPosts_Action {
type: ActionType.REQUEST_POSTS,
subreddit: string
}
export function requestPosts(subreddit: string): RequestPosts_Action {
return {
type: ActionType.REQUEST_POSTS,
subreddit
}
}
interface ReceivePosts_Action {
type: ActionType.RECEIVE_POSTS,
subreddit: string,
posts: Array<any>
receivedAt: number
}
export function receivePosts(subreddit: string, children: Array<any>): ReceivePosts_Action {
return {
type: ActionType.RECEIVE_POSTS,
subreddit,
posts: children.map((child: { data: any; }) => child.data),
receivedAt: Date.now()
}
}
export type IAction = InvalidateSubreddit_Action | RequestPosts_Action | ReceivePosts_Action | SelectSubreddit_Action;
function shouldFetchPosts(state: IStoreState, subreddit: string) {
const posts: IStoreState['postsBySubreddit']['frontend'] =
state.postsBySubreddit[subreddit];
if (!posts) {
return true
} else if (posts.isFetching) {
return false
} else {
return posts.didInvalidate
}
}
export function fetchPostsIfNeeded(subreddit: string) {
return (dispatch: ThunkDispatch<IStoreState, undefined, any>, getState: () => IStoreState) => {
if (shouldFetchPosts(getState(), subreddit)) {
return dispatch(fetchPosts(subreddit))
}
}
}
export const fetchPosts = (subreddit: string) => {
return (dispatch: Dispatch) => {
let htm = `https://www.reddit.com/r/${subreddit}.json`
axios.get(htm).then((res) => {
// console.log(res);
const data = res.data.data.children;
// console.log(data, `fetchPost`)
dispatch(receivePosts(subreddit, data))
})
}
}
containers.tsx
import { Component } from "react";
import { connect } from "react-redux";
import { fetchPostsIfNeeded, invalidateSubreddit, selectSubreddit } from "../actions";
import { IStoreState } from "../reducers";
interface IPicker {
value: string,
onChange: (e: string) => void;
options: string[]
}
const Picker = (props: IPicker) => {
const { value, onChange, options } = props
return (
<span>
<h1>{value}</h1>
<select onChange={e => onChange(e.target.value)}
value={value}>
{options.map((option) =>
<option value={option} key={option}>
{option}
</option>)
}
</select>
</span>
)
}
const Posts = (props: {
posts: {
id: number;
title: string;
}[]
}) => {
const { posts } = props;
return (
<ul>
{posts.map((post, i) =>
<li key={i}>{post.title}</li>
)}
</ul>
)
}
type IState = IStoreState
interface IProps {
selectedSubreddit: string,
posts: {
id: number;
title: string;
}[]
isFetching: boolean,
lastUpdated: number | undefined,
dispatch?: any
}
class AsyncApp extends Component<IProps, IState> {
constructor(props: IProps) {
super(props)
this.handleChange = this.handleChange.bind(this)
this.handleRefreshClick = this.handleRefreshClick.bind(this)
}
componentDidMount() {
const { selectedSubreddit, dispatch } = this.props
dispatch!(fetchPostsIfNeeded(selectedSubreddit))
}
componentWillReceiveProps(nextProps: { selectedSubreddit: any; dispatch?: any; }) {
if (nextProps.selectedSubreddit !== this.props.selectedSubreddit) {
const { dispatch, selectedSubreddit } = nextProps
dispatch(fetchPostsIfNeeded(selectedSubreddit))
}
}
handleChange(nextSubreddit: string) {
this.props.dispatch!(selectSubreddit(nextSubreddit))
}
handleRefreshClick(e: { preventDefault: () => void; }) {
e.preventDefault()
const { dispatch, selectedSubreddit } = this.props
dispatch!(invalidateSubreddit(selectedSubreddit))
dispatch!(fetchPostsIfNeeded(selectedSubreddit))
}
render() {
const { selectedSubreddit, posts, isFetching, lastUpdated } = this.props
return (
<div>
{/* {console.log(selectedSubreddit, isFetching, lastUpdated, posts)} */}
{console.log(this.props)}
<Picker value={selectedSubreddit}
onChange={this.handleChange}
options={['reactjs', 'frontend']} />
<p>
{lastUpdated &&
<span>
Last updated at {new Date(lastUpdated).toLocaleTimeString()}.
{' '}
</span>
}
{!isFetching &&
// eslint-disable-next-line jsx-a11y/anchor-is-valid
<a href='#'
onClick={this.handleRefreshClick}>
Refresh
</a>
}
</p>
{isFetching && posts.length === 0 &&
<h2>Loading...</h2>
}
{!isFetching && posts.length === 0 &&
<h2>Empty.</h2>
}
{posts.length > 0 &&
<div style={{ opacity: isFetching ? 0.5 : 1 }}>
<Posts posts={posts} />
</div>
}
</div>
)
}
}
function mapStateToProps(state: IStoreState) {
// 一级嵌套
const { selectedSubreddit, postsBySubreddit } = state
// console.log(state, `state`)
// 二级嵌套
const {
isFetching,
lastUpdated,
items: posts
} = postsBySubreddit[selectedSubreddit] || {
isFetching: true,
items: []
}
return {
selectedSubreddit,
posts,
isFetching,
lastUpdated
}
}
export default connect(mapStateToProps)(AsyncApp)
reducers
index.tsx
import { combineReducers } from "redux";
import { selectedSubreddit, postsBySubreddit } from "./demo1"
export interface IStoreState {
selectedSubreddit: string;
postsBySubreddit: {
[key: string]: SubReddit
}
}
export interface SubReddit {
isFetching: boolean;
didInvalidate: boolean;
lastUpdated?: number
items: {
id: number;
title: string;
}[];
}
export const reducer = combineReducers(
{
postsBySubreddit,
selectedSubreddit
}
);
demo1.tsx
// State结构
import { IStoreState, SubReddit } from "."
import { ActionType, IAction } from "../actions"
export function selectedSubreddit(state: IStoreState['selectedSubreddit'] = 'reactjs', action: IAction) {
switch (action.type) {
case ActionType.SELECT_SUBREDDIT:
return action.subreddit;
default:
return state
}
}
const InitialState: IStoreState = {
selectedSubreddit: `string`,
postsBySubreddit: {
frontend: {
isFetching: false,
didInvalidate: false,
items: []
}
}
}
/*
这里面的State结构:
postsBySubreddit: {
frontend: {
isFetching: false,
didInvalidate: false,
items: [{
id: 1,
title: `string`,
}]
}
}
*/
type postsBySubreddit_State = IStoreState['postsBySubreddit']
export function postsBySubreddit(state: postsBySubreddit_State = InitialState.postsBySubreddit, action: IAction) {
const subreddit = action.subreddit;
// type nextStateProps = postsBySubreddit_State[`${subreddit}`];
switch (action.type) {
case ActionType.INVALIDATE_SUBREDDIT:
case ActionType.RECEIVE_POSTS:
case ActionType.REQUEST_POSTS:
// nextState 与 state 是平级的
let nextState: postsBySubreddit_State = {
subreddit: {
isFetching: false,
didInvalidate: false,
items: [{
id: 1,
title: `string`,
}]
}
}
nextState[subreddit] = posts(state[subreddit], action);
return Object.assign({}, state, nextState)
default:
return state
}
}
// type IPost = IStoreState['postsBySubreddit']['frontend']
function posts(
state: SubReddit = {
isFetching: false,
didInvalidate: false,
items: []
},
action: IAction
)
// : { (state, action:I)=> IStoreState['postsBySubreddit']['frontend'] }
{
switch (action.type) {
case ActionType.INVALIDATE_SUBREDDIT:
return Object.assign({}, state, {
didInvalidate: true
})
case ActionType.REQUEST_POSTS:
return Object.assign({}, state, {
isFetching: true,
didInvalidate: false
})
case ActionType.RECEIVE_POSTS:
return Object.assign({}, state, {
isFetching: false,
didInvalidate: false,
items: action.posts,
lastUpdated: action.receivedAt
})
default:
return state
}
}
stores.tsx
import { applyMiddleware, createStore } from 'redux';
import { createLogger } from 'redux-logger'
import { reducer } from './reducers';
import thunkMiddleware from 'redux-thunk';
const loggerMiddleware = createLogger({
// ...options
});
export const store = createStore(
reducer,
applyMiddleware(thunkMiddleware,
loggerMiddleware)
);
index.tsx & App.tsx
// index.tsx
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import { Provider } from 'react-redux';
import { store } from './redux/store';
ReactDOM.render(
<React.StrictMode>
<Provider store={store}>
<App />
</Provider>
</React.StrictMode>,
document.getElementById('root')
);
// App.tsx
import React from 'react';
import logo from './logo.svg';
import './App.css';
import AsyncApp from './redux/containers/index'
function App() {
return (
<div className="App">
<AsyncApp />
</div>
);
}
export default App;