RedditAPI TypeScript Redux

256 阅读4分钟

参考地址

  1. 异步 Action · Redux

  2. redux-thunk/typescript.ts at master · reduxjs/redux-thunk (github.com)

  3. 【译】10个帮助你捕获更多Bug的TypeScript建议 - SegmentFault 思否

  4. Redux 18 - 进阶:Reddit API Example · 大专栏 (dazhuanlan.com)

  5. Redux 入门教程(二):中间件与异步操作 - 阮一峰的网络日志 (ruanyifeng.com)

  6. Loading... - CodeSandbox

  7. 使用redux-thunk发送axios - 简书 (jianshu.com)

基于TypeScript语言的官方Redux高级教学示例Reddit API

js最终效果图:

image-20210813112637408image-20210813112652352image-20210813112701536

设计思路:

设计State数据结构接口(IStoreState)

Reddit API 数据结构(部分):(使用了谷歌网上商店的Json-handle插件)

Reddit API官网使用的State结构:
image-20210813111434611image-20210813112302039

仔细对比一下 就能发现,其实我们所需要的Reddit API数据 只有两种:

  • items里面的数据
  • postsBySubreddit下面的不定参数(frontend和reactjs)

至于其他属性诸如: isFetchingdidInvalidatelastUpdated等等都与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

这里有个小技巧:把原始数据输进去之后,鼠标停止不动马上就能看到数据的类型构成,这时候可以直接复制到对应的接口上面去,非常方便。

image-20210813120118050

注意事项:

  1. API数据结构的复杂度与store结构的复杂度没有必然联系,两者完全不是一回事,不要有心理负担;

创建Store

这部分没什么好说的 ,照着做就行,注意把loggerMiddleware也加上去,只有这样你才能勉强体会到Redux的魅力。

设计Actions 创建函数接口:

照着网上demo做就行,注意一下subreddit和post变量类型,仔细查看官方文档就能发现subreddit这个变量类型是string,它其实只有:reactjsfronted两个常量名,至于post,不清楚!不过肯定跟Reddit API有关,先用Any代替,等出事再说

  1. 每个Action的type(用emus
  2. 每个Action创建函数的返回类型
  3. Action创建函数的参数类型(不清楚的可以先用any)替代
  4. 添加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修改就行,不过对于函数shouldFetchPostsfetchPostsIfNeededfetchPosts这三个函数需要有部分改用;这一步卡了我好久,尤其注意几点:

  1. 本案例中用到了Thunk中间件,而Thunk本质是对Dispatch进行简单改造。因此在声明dispatch类型时,第一个dispatch的类型是ThunkDispatch<IStoreState, undefined, any>后面的dispatch函数都是引用reduxdispatch接口

  2. 注意 shouldFetchPosts 函数中参数post的类型。在typescript中,对于这种不定参数的赋值,试了很多方法,但都不太行。目前比较可行的只有post定义成let这一种办法

    interface I{
        [key:string]:string
    }
    let posts: IStoreState =
            {"name":lili};
        posts[subreddit] = state.postsBySubreddit[subreddit];
    
  3. 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翻了一些跟头:

  1. nextState的类型确定,还是不定参数那套,以后注意一下。let声明,然后把对象整个包进去
  2. 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函数

这个按照之前的习惯改就行了,问题基本都能解决。注意几点:

  1. AsyncApp中 dispatch类型只能设成 any,这里面的两个dispatch要接收两个完全不一样的函数类型,完全没法兼容,只能用any处理
  2. AsyncApp中的IProps要和mapStateToProps中的state的数据类型要基本保持一致,至少得互相兼容
  3. 对于需要使用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;