65.9K
CodeProject 正在变化。 阅读更多。
Home

使用 React/Redux/Typescript 和 Hooks 的演示应用程序

starIconstarIconstarIconstarIconstarIcon

5.00/5 (2投票s)

2020年4月28日

CPOL

10分钟阅读

viewsIcon

14749

使用 React/Redux 和 hooks 的小型演示应用程序

目录

概述

距离我上次在 CodeProject 上写文章已经有一段时间了。在我停止写作之后,我看到了许多有趣的文章,很多都出自 Honey The Code Witch 之手,我觉得是时候重新开始写文章了。这篇文章是关于 React/Redux/TypeScript 的,我知道这方面的文章已经很多了。但我想做的是探索如何使用 React hooksRedux hooks。因此,本文将围绕一个简单的 WebApi 后端和一个尽可能使用 Redux 和 hooks 的相当直接的 React 前端展开。

Hooks 是一个相当新的特性,它允许你在不创建类的情况下使用 state 和其他 React 特性。

有许多关于如何将现有的 React 类转换为基于 Hook 的组件的文章,例如这些:

因此,我不会对此进行深入探讨。

那么这个应用程序是做什么的?

这个应用程序非常简单。它实现了以下功能:

  • 有两个页面,HomeSearch,通过 React Router 实现路由。
  • Home 页面展示了一个关于电子音乐流派的 D3 力导向图。这是一个硬编码的列表。当你点击一个节点时,它会调用后端的 WebApi 并收集一些关于你所选节点的数据(Lorem Ipsum 文本),这些数据将显示在一个滑出式面板中。
  • Search 页面允许你从一个硬编码的流派列表中进行选择,一旦选中一个,就会调用后端的 WebApi,此时会显示一些与所选流派匹配的硬编码(服务器端)项目的图片。然后你可以点击它们,并在一个 Boostrap 弹出窗口中查看更多信息。

这就是它的全部功能,然而正如我们将看到的,这里有足够的内容让我们深入研究。这个小小的演示应用程序足以展示如下内容:

  • 在 TypeScript 中使用 d3
  • 在 TypeScript 中使用 Redux
  • 如何使用 React hooksRedux hooks 来创建自定义组件

演示视频

完成的演示应用程序的演示视频在这里

代码在哪里?

本文的代码可在 https://github.com/sachabarber/DotNetReactRedux 获取。下载后,你只需要运行 npm install。然后它应该可以在 Visual Studio 中正常运行。

后端

正如我所说,本文的后端是一个简单的 WebApi,其中使用了以下 Controller 类。

GenreController

基本上有两个路由:

  • info/{genre}:这个路由由前端网站 Home 页面上的 D3 力导向图使用,我们稍后会看到。基本上,当你点击图中的一个节点时,它会调用这个端点,并为所选节点的流派显示一些 Lorem Ipsum 文本。
  • details/{genre}:这个路由在搜索屏幕上使用,我们获取一些硬编码的流派项目列表,这些项目是为响应搜索而显示的。

这里唯一需要注意的另一件事是,我使用了 Nuget 包 LoremNET 来生成 Lorem Ipsum 文本。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using DotNetCoreReactRedux.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Formatters;

namespace DotNetCoreReactRedux.Controllers
{
    [ApiController]
    [Route("[controller]")]
    public class GenreController : ControllerBase
    {
        [Route("info/{genre}")]
        [HttpGet]
        public GenreInfo Get(string genre)
        {
            var paragraphs = LoremNET.Lorem.Paragraphs(8, 9, 4, 5, 8, 11);

            return new GenreInfo()
            {
                GenreName = genre,
                Paragraphs = paragraphs.ToArray()
            };
        }

        [Route("details/{genre}")]
        [HttpGet]
        public GenreDetailedItemContainer GetDetailed(string genre)
        {
            if (GenreDetailsFactory.Items.Value.ContainsKey(genre.ToLower()))
            {
                return new GenreDetailedItemContainer()
                {
                    GenreName = genre,
                    Items = GenreDetailsFactory.Items.Value[genre.ToLower()]
                };
            }
            return new GenreDetailedItemContainer()
            {
                GenreName = genre,
                Items = new List<GenreDetailedItem>()
            };
        }
    }

    public static class GenreDetailsFactory
    {
        public static Lazy<Dictionary<string, List<GenreDetailedItem>>> Items = 
               new Lazy<Dictionary<string, 
               List<GenreDetailedItem>>>(CreateItems, LazyThreadSafetyMode.None);

        private static Dictionary<string, List<GenreDetailedItem>> CreateItems()
        {
            var items = new Dictionary<string, List<GenreDetailedItem>>();

            items.Add("gabber", new List<GenreDetailedItem>()
            {
                new GenreDetailedItem()
                {
                    Paragraphs = LoremNET.Lorem.Paragraphs(8, 9, 4, 5, 3, 11).ToArray(),
                    Band = "Rotterdam Termination Squad",
                    Title = "Poing",
                    ImageUrl = "https://img.discogs.com/OvgtN_-O-4MapL7Hr9L5NUNalF8=/300x300/
                                filters:strip_icc():format(jpeg):mode_rgb():quality(40)/
                                discogs-images/R-146496-1140115140.jpeg.jpg"
                },
                new GenreDetailedItem()
                {
                    Paragraphs = LoremNET.Lorem.Paragraphs(8, 9, 4, 5, 7, 11).ToArray(),
                    Band = "De Klootzakken",
                    Title = "Dominee Dimitri",
                    ImageUrl = "https://img.discogs.com/nJ2O1mYa4c5nkIZcuKK_6wN-lH0=/
                                fit-in/300x300/filters:strip_icc():format(jpeg):mode_rgb()
                                :quality(40)/discogs-images/R-114282-1085597479.jpg.jpg"
                },
                new GenreDetailedItem()
                {
                    Paragraphs = LoremNET.Lorem.Paragraphs(8, 9, 4, 5, 8, 11).ToArray(),
                    Band = "Neophyte",
                    Title = "Protracker Ep",
                    ImageUrl = "https://img.discogs.com/YC8l_-aoYt-OcLNTntu57FIA5w8=/
                                300x300/filters:strip_icc():format(jpeg):mode_rgb():
                                quality(40)/discogs-images/R-5039-1149857244.jpeg.jpg"
                },
                new GenreDetailedItem()
                {
                    Paragraphs = LoremNET.Lorem.Paragraphs(8, 9, 4, 5, 2, 11).ToArray(),
                    Band = "Disciples Of Belial",
                    Title = "Goat Of Mendes",
                    ImageUrl = "https://img.discogs.com/vHAvCPck9EHzi78PG5HDtAMxv0M=/
                                fit-in/300x300/filters:strip_icc():format(jpeg):mode_rgb()
                                :quality(40)/discogs-images/R-160557-1546568764-3706.jpeg.jpg"
                },
                new GenreDetailedItem()
                {
                    Paragraphs = LoremNET.Lorem.Paragraphs(8, 9, 4, 5, 7, 11).ToArray(),
                    Band = "Bloodstrike",
                    Title = "Pathogen",
                    ImageUrl = "https://img.discogs.com/SAqIcgp3kiqPaSVZsGn-oh8E4RE=/
                                fit-in/300x300/filters:strip_icc():format(jpeg):mode_rgb()
                                :quality(40)/discogs-images/R-18210-1448556049-2613.jpeg.jpg"
                },
                new GenreDetailedItem()
                {
                    Paragraphs = LoremNET.Lorem.Paragraphs(8, 9, 4, 5, 3, 11).ToArray(),
                    Band = "Mind Of Kane",
                    Title = "The Mind EP",
                    ImageUrl = "https://img.discogs.com/Hc_is4Ga5A1704qshrkXp9LkhKM=/
                                300x300/filters:strip_icc():format(jpeg):mode_rgb():
                                quality(40)/discogs-images/R-160262-1557585935-9794.jpeg.jpg"
                },
                new GenreDetailedItem()
                {
                    Paragraphs = LoremNET.Lorem.Paragraphs(8, 9, 4, 5, 5, 11).ToArray(),
                    Band = "Stickhead",
                    Title = "Worlds Hardest Kotzaak",
                    ImageUrl = "https://img.discogs.com/HFKhwj9ZfVEwLW0YJm_rUHx75lU=/
                                fit-in/300x300/filters:strip_icc():format(jpeg):mode_rgb()
                                :quality(40)/discogs-images/R-20557-1352933734-5019.jpeg.jpg"
                },
            });

            items.Add("acid house", new List<GenreDetailedItem>()
            {
                new GenreDetailedItem()
                {
                    Paragraphs = LoremNET.Lorem.Paragraphs(8, 9, 4, 5, 5, 11).ToArray(),
                    Band = "Various",
                    Title = "ACid House",
                    ImageUrl = "https://img.discogs.com/WmSfj73-GK0TQhpLZTnLaEqWvdU=/
                                300x300/filters:strip_icc():format(jpeg):mode_rgb():
                                quality(40)/discogs-images/R-1224150-1264336074.jpeg.jpg"
                },
                new GenreDetailedItem()
                {
                    Paragraphs = LoremNET.Lorem.Paragraphs(8, 9, 4, 5, 3, 11).ToArray(),
                    Band = "Rififi",
                    Title = "Dr Acid And Mr House",
                    ImageUrl = "https://img.discogs.com/3w5QDa6y7PK7tYZ99hzPnMdxIVE=/300x300/
                               filters:strip_icc():format(jpeg):mode_rgb():quality(40)/
                               discogs-images/R-195695-1484590974-8359.jpeg.jpg"
                },
                new GenreDetailedItem()
                {
                    Paragraphs = LoremNET.Lorem.Paragraphs(8, 9, 4, 5, 6, 11).ToArray(),
                    Band = "Tyree",
                    Title = "Acid Over",
                    ImageUrl = "https://img.discogs.com/rQVeuPgGK0ksQ-g2xJEWrx1ktnc=/300x300/
                               filters:strip_icc():format(jpeg):mode_rgb():quality(40)/
                               discogs-images/R-61941-1080462105.jpg.jpg"
                },
                new GenreDetailedItem()
                {
                    Paragraphs = LoremNET.Lorem.Paragraphs(8, 9, 4, 5, 2, 11).ToArray(),
                    Band = "Acid Jack",
                    Title = "Acid : Can You Jack",
                    ImageUrl = "https://img.discogs.com/ojC7tbyzBe9XLpC9-sPtYiSfu4g=/300x300/
                               filters:strip_icc():format(jpeg):mode_rgb():quality(40)/
                               discogs-images/R-466567-1155405490.jpeg.jpg"
                },
                new GenreDetailedItem()
                {
                    Paragraphs = LoremNET.Lorem.Paragraphs(8, 9, 4, 5, 5, 11).ToArray(),
                    Band = "Bam Bam",
                    Title = "Wheres Your Child",
                    ImageUrl = "https://img.discogs.com/RIsPWasW9OV6iJlGW1dF7x5B_Hg=/
                               fit-in/300x300/
                               filters:strip_icc():format(jpeg):mode_rgb():quality(40)/
                               discogs-images/R-43506-1356639075-5067.jpeg.jpg"
                },
            });

            items.Add("drum & bass", new List<GenreDetailedItem>()
            {
                new GenreDetailedItem()
                {
                    Paragraphs = LoremNET.Lorem.Paragraphs(8, 9, 4, 5, 8, 11).ToArray(),
                    Band = "Bad Company",
                    Title = "Bad Company Classics",
                    ImageUrl = "https://img.discogs.com/uArBfSolc15i_Ys5S4auaHYTo8w=/
                               fit-in/300x300/
                               filters:strip_icc():format(jpeg):mode_rgb():quality(40)/
                               discogs-images/R-1138493-1195902484.jpeg.jpg"
                },
                new GenreDetailedItem()
                {
                    Paragraphs = LoremNET.Lorem.Paragraphs(8, 9, 4, 5, 4, 11).ToArray(),
                    Band = "Adam F",
                    Title = "F Jam",
                    ImageUrl = "https://img.discogs.com/99njVrjJq6ES0l6Va2eTFcjP1AU=/300x300/
                               filters:strip_icc():format(jpeg):mode_rgb():quality(40)/
                               discogs-images/R-5849-1237314693.jpeg.jpg"
                },
                new GenreDetailedItem()
                {
                    Paragraphs = LoremNET.Lorem.Paragraphs(8, 9, 4, 5, 2, 11).ToArray(),
                    Band = "Diesel Boy",
                    Title = "A Soldier's Story - A Drum And Bass DJ Mix",
                    ImageUrl = "https://img.discogs.com/cFV--pJXg69KkvlJ6q8EV8pg218=/
                               fit-in/300x300/
                               filters:strip_icc():format(jpeg):mode_rgb():quality(40)/
                               discogs-images/R-3353-1175897684.jpeg.jpg"
                },
                new GenreDetailedItem()
                {
                    Paragraphs = LoremNET.Lorem.Paragraphs(8, 9, 4, 5, 4, 11).ToArray(),
                    Band = "Future Mind",
                    Title = "Drum & Bass",
                    ImageUrl = "https://img.discogs.com/R46K8de0GA89HoYxJDjUBDexmgs=/300x300/
                               filters:strip_icc():format(jpeg):mode_rgb():quality(40)/
                               discogs-images/R-4685019-1372172049-9885.jpeg.jpg"
                },
            });

            return items;
        }
    }
}

前端

前端完全基于 React/ReactRouter/TypeScript 和 Redux,我们会尽可能使用 hooks。

总体思路

如概述中所述,基本思想是这样的,应用程序

  • 有两个页面 HomeSearch,通过 React Router 实现路由
  • Home 页面展示了一个关于电子音乐流派的 D3 力导向图。这是一个硬编码的列表。当你点击一个节点时,它会调用后端的 WebApi 并收集一些关于你所选节点的数据(Lorem Ipsum 文本),这些数据将显示在一个滑出式面板中。
  • Search 页面允许你从一个硬编码的流派列表中进行选择,一旦选中一个,就会调用后端的 WebApi,此时会显示一些与所选流派匹配的硬编码(服务器端)项目的图片。然后你可以点击它们,并在一个 Boostrap 弹出窗口中查看更多信息。

当你第一次启动应用程序时,它应该看起来像这样

在这里,你可以点击 d3 图中的节点,这会显示一个滑入的信息面板,或者你可以如上所述使用 Search 页面。

Create React App / .NET Core React/Redux 入门模板

该应用程序是使用 .NET Core 命令行 dotnet new reactredux 启动的。这会给你一些入门代码,其中包括一个使用 TypeScript 的示例 WebApi/Router/Redux 代码,该代码内部也使用了 CreateReactApp 模板。所以它是一个非常好的起点。

使用的库

我在应用程序中使用了以下第三方库。

Redux

我正在使用 Redux,关于它的文章数不胜数,所以我不会过多赘述。但对于那些不了解的人来说,Redux 提供了一个状态存储(store),它恰好与 React 配合得非常好。

它允许一个很好的流程,发生以下情况

  • React 组件分发(dispatch)动作(actions),这些动作由一个分发器(dispatcher)接收。
  • 分发器将动作推送给一个 reducer,reducer 负责确定 store 的新状态。
  • reducer 创建新状态。
  • React 组件通过使用 Connect 或通过 hooks 来获知新状态。本文使用 hooks。

我们可以像这样配置 store 和 reducers

import { applyMiddleware, combineReducers, compose, createStore } from 'redux';
import thunk from 'redux-thunk';
import { connectRouter, routerMiddleware } from 'connected-react-router';
import { History } from 'history';
import { ApplicationState, reducers } from './';

export default function configureStore(history: History, initialState?: ApplicationState) {
    const middleware = [
        thunk,
        routerMiddleware(history)
    ];

    const rootReducer = combineReducers({
        ...reducers,
        router: connectRouter(history)
    });

    const enhancers = [];
    const windowIfDefined = typeof window === 'undefined' ? null : window as any;
    if (windowIfDefined && windowIfDefined.__REDUX_DEVTOOLS_EXTENSION__) {
        enhancers.push(windowIfDefined.__REDUX_DEVTOOLS_EXTENSION__());
    }

    return createStore(
        rootReducer,
        initialState,
        compose(applyMiddleware(...middleware), ...enhancers)
    );
}

还有这个

import * as Genre from './Genre';
import * as Search from './Search';


// The top-level state object
export interface ApplicationState {
    genres: Genre.GenreInfoState | undefined;
    search: Search.SearchState | undefined;
}

// Whenever an action is dispatched, Redux will update each top-level application 
// state property using the reducer with the matching name. 
// It's important that the names match exactly, and that the reducer
// acts on the corresponding ApplicationState property type.
export const reducers = {
    genres: Genre.reducer,
    search: Search.reducer
};

// This type can be used as a hint on action creators so that its 'dispatch' 
// and 'getState' params are
// correctly typed to match your store.
export interface AppThunkAction<TAction> {
    (dispatch: (action: TAction) => void, getState: () => ApplicationState): void;
}

ReduxThunk

ReduxThunk 是一个中间件,它允许你向 Redux store 分发函数。对于一个普通的 Redux store,你只能通过分发一个 action 来进行简单的同步更新。中间件扩展了 store 的能力,让你能够编写与 store 交互的异步逻辑。

Thunks 是用于处理 Redux 基本副作用逻辑的推荐中间件,包括需要访问 store 的复杂同步逻辑,以及像 AJAX 请求这样的简单异步逻辑。

这是 ReduxThunk 的全部源代码,很巧妙,不是吗?

function createThunkMiddleware(extraArgument) {
  return ({ dispatch, getState }) => (next) => (action) => {
    if (typeof action === 'function') {
      return action(dispatch, getState, extraArgument);
    }

    return next(action);
  };
}

const thunk = createThunkMiddleware();
thunk.withExtraArgument = createThunkMiddleware;

export default thunk;

这里可能是一个使用 ReduxThunk 的例子

// This type can be used as a hint on action creators so that 
// its 'dispatch' and 'getState' params are
// correctly typed to match your store.
export interface AppThunkAction<TAction> {
    (dispatch: (action: TAction) => void, getState: () => ApplicationState): void;
}

export const actionCreators = {
    requestSearchInfo: (genre: string): 
       AppThunkAction<KnownAction> => (dispatch, getState) => {
        // Only load data if it's something we don't already have (and are not already loading)
        const appState = getState();
        if (appState && appState.search && genre !== appState.search.searchInfo.genreName) {
            fetch(`genre/details/${genre}`)
                .then(response => response.json() as Promise<GenreDetailedItemContainer>)
                .then(data => {
                    dispatch({ type: 'RECEIVE_SEARCH_INFO', genre: genre, searchInfo: data });
                });

            dispatch({ type: 'REQUEST_SEARCH_INFO', genre: genre });
        }
    }
};

ScrollBars

我使用了这个 React 组件,在应用程序中实现漂亮的滚动条,它能给你带来像这样的精美滚动条

这个组件使用起来相当简单。你只需要通过 NPM 安装它,然后在你的 TSX 文件中使用它

import { Scrollbars } from 'react-custom-scrollbars';

<Scrollbars
	autoHeight
	autoHeightMin={200}
	autoHeightMax={600}
	style={{ width: 300 }}>
	<div>Some content in scroll</div>
</Scrollbars>

实用程序

我喜欢内部中介类型总线的想法,因此,我根据这里的演示代码加入了一个基于 RxJs 的实现。这个服务本身看起来是这样的

import { Subject, Observable } from 'rxjs';

export interface IEventMessager
{
    publish(message: IMessage): void;
    observe(): Observable<IMessage>;
}

export interface IMessage {
    
}

export class ShowInfoInSidePanel implements IMessage {

    private _itemClicked: string;

    public constructor(itemClicked: string) {
        this._itemClicked = itemClicked;
    }

    get itemClicked(): string {
        return this._itemClicked;
    }
}

export class EventMessager implements IEventMessager {

    private subject = new Subject<IMessage>();

    publish(message: IMessage) {
        this.subject.next(message);
    }

    observe(): Observable<IMessage> {
        return this.subject.asObservable();
    }
}

而这可能是它的用法示例

import React, { useState, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { IEventMessager } from "./utils/EventMessager";
import { IMessage, ShowInfoInSidePanel } from "./utils/EventMessager";
import { filter, map } from 'rxjs/operators';

export interface HomeProps {
    eventMessager: IEventMessager;
}

const Home: React.FunctionComponent<HomeProps> = (props) => {

    const dispatch = useDispatch();

    const [currentState, setState] = useState(initialState);
                
    useEffect(() => {

        const sub = props.eventMessager.observe()
        .pipe(
            filter((event: IMessage) => event instanceof ShowInfoInSidePanel),
            map((event: IMessage) => event as ShowInfoInSidePanel)
        )
        .subscribe(x => {
            ....
        });

        return () => {
            sub.unsubscribe();
        }
    }, [props.eventMessager]);
}

export default Home;

路由

该演示应用使用了 ReactRouterRedux,因此主挂载点看起来像这样。需要注意的是,我们使用了 ConnectedRouter,这是因为我们正在使用 Redux,我们在外部的 Provider 组件中提供了 store。

import 'bootstrap/dist/css/bootstrap.css';

import * as React from 'react';
import * as ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import { ConnectedRouter } from 'connected-react-router';
import { createBrowserHistory } from 'history';
import configureStore from './store/configureStore';
import App from './App';
import registerServiceWorker from './registerServiceWorker';

import 'bootstrap/dist/css/bootstrap.min.css';
import 'bootstrap/dist/js/bootstrap.min.js';

// Create browser history to use in the Redux store
const baseUrl = document.getElementsByTagName('base')[0].getAttribute('href') as string;
const history = createBrowserHistory({ basename: baseUrl });

// Get the application-wide store instance, prepopulating with state 
// from the server where available.
const store = configureStore(history);

ReactDOM.render(
    <Provider store={store}>
        <ConnectedRouter history={history}>
            <App />
        </ConnectedRouter>
    </Provider>,
    document.getElementById('root'));

registerServiceWorker();

它使用了 App,看起来像这样

import * as React from 'react';
import { Route } from 'react-router';
import Layout from './components/Layout';
import Home from './components/Home';
import Search from './components/Search';
import { EventMessager } from "./components/utils/EventMessager";
import './custom.css'

let eventMessager = new EventMessager();

export default () => (
    <Layout>
        <Route exact path='/' render={(props: any) => <Home {...props} 
         eventMessager={eventMessager} />} />
        <Route path='/search' component={Search} />
    </Layout>
);

它又使用了这个 Layout 组件

import * as React from 'react';
import { Container } from 'reactstrap';
import NavMenu from './NavMenu';

export default (props: { children?: React.ReactNode }) => (
    <React.Fragment>
        <NavMenu/>
        <Container className="main">
            {props.children}
        </Container>
    </React.Fragment>
);

它最终使用了 NavMenu 组件,如下所示

import * as React from 'react';
import { NavItem, NavLink } from 'reactstrap';
import { Link } from 'react-router-dom';
import './css/NavMenu.css';
import HoverImage from './HoverImage';

import homeLogo from './img/Home.png';
import homeHoverLogo from './img/HomeHover.png';
import searchLogo from './img/Search.png';
import searchHoverLogo from './img/SearchHover.png';

const NavMenu: React.FunctionComponent = () => {

    return (
        <div className="sidenav">
            <NavItem>
                <NavLink tag={Link} style={{ textDecoration: 'none' }} to="/">
                    <HoverImage hoverSrc={homeHoverLogo} src={homeLogo} />
                </NavLink>
            </NavItem>
            <NavItem>
                <NavLink tag={Link} style={{ textDecoration: 'none' }} to="/search">
                    <HoverImage hoverSrc={searchHoverLogo} src={searchLogo} />
                </NavLink>
            </NavItem>
        </div>
    );
}

export default NavMenu

眼尖的读者会发现,这里还使用了一个特殊的 HoverImage 组件,这是我写的一个简单的组件,用于通过图像进行导航。如下所示。

HoverImage 组件

如上所示,路由使用了一个简单的 HoverImage 组件,它只是允许用户在 MouseOver(鼠标悬停)时显示不同的图像。这是该组件的代码

import React, { useState } from 'react';

export interface HoverImageProps {
    src?: string;
    hoverSrc?: string;
}

const HoverImage: React.FunctionComponent<HoverImageProps> = (props) => {

    // Declare a new state variable, which we'll call "count"
    const [imgSrc, setSource] = useState(props.src);

    return (
        <div>
            <img
                src={imgSrc}
                onMouseOver={() => setSource(props.hoverSrc)}
                onMouseOut={() => setSource(props.src)} />
        </div>
    );
}

HoverImage.defaultProps = {

    src: '',
    hoverSrc:'',
}

export default HoverImage

Home 组件

Home 组件是一个顶层路由组件,看起来像这样

该组件使用了 Redux,并使用 Redux 调用后端 WebApi,同时也使用了 ReduxThunk。Redux 的流程是这样的

  • 我们分发 requestGenreInfo 动作。
  • 我们使用 Redux hook 来监听 Genres State 的状态变化。

Home 组件标记中最重要的部分如下所示

import React, { useState, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import ForceGraph from './graph/ForceGraph';
import data from "./electronic-genres";
import { Scrollbars } from 'react-custom-scrollbars';
import SlidingPanel, { PanelType } from './slidingpanel/SlidingPanel';
import './css/SlidingPanel.css';
import { IEventMessager } from "./utils/EventMessager";
import { IMessage, ShowInfoInSidePanel } from "./utils/EventMessager";
import { filter, map } from 'rxjs/operators';
import HoverImage from './HoverImage';
import circleLogo from './img/circle.png';
import circleHoverLogo from './img/circleHover.png';

//CSS
import './css/ForceGraph.css';

//redux
import { ApplicationState } from '../store';
import * as GenreStore from '../store/Genre';

export interface HomeProps {
    eventMessager: IEventMessager;
}

const initialState = {
    isopen: false,
    selectedNodeText : ''
}

const Home: React.FunctionComponent<HomeProps> = (props) => {

    const dispatch = useDispatch();

    const [currentState, setState] = useState(initialState);
                
    useEffect(() => {
        const sub = props.eventMessager.observe()
        .pipe(
            filter((event: IMessage) => event instanceof ShowInfoInSidePanel),
            map((event: IMessage) => event as ShowInfoInSidePanel)
        )
        .subscribe(x => {
            //pass callback to setState to prevent currentState
            //  being a dependency
            setState(
                (currentState) => ({
                    ...currentState,
                    isopen: true,
                    selectedNodeText: x.itemClicked
                })
            );
        });

        return () => {
            sub.unsubscribe();
        }
    }, [props.eventMessager]);

    React.useEffect(() => {
        dispatch(GenreStore.actionCreators.requestGenreInfo(currentState.selectedNodeText));
    }, [currentState.selectedNodeText]);


    const storeState: GenreStore.GenreInfoState = useSelector(
        (state: ApplicationState) => state.genres as GenreStore.GenreInfoState
    );

    return (
        <div>
            ....
                    <ForceGraph
                        width={window.screen.availHeight}
                        height={window.screen.availHeight}
                        eventMessager={props.eventMessager}
                        graph={data} />
            ....
		</div>
    );
}

export default Home;

滚动条

这和上面提到的 ScrollBars 是一样的。

D3 力导向图

Home 组件的核心是一个 d3 力导向图。但由于我试图把事情做得更好,我想用 TypeScript 来实现 d3 图。所以我从这篇很棒的博客文章开始,并在此基础上进行了扩展。那篇博客文章中的例子将图表分成了这 4 个部分

  • ForceGraph
  • Labels
  • 链接
  • 节点

ForceGraph

这是包含其他部分的主组件。并且在 Home 组件中使用的就是这个。这是它的代码

import * as React from 'react';
import * as d3 from 'd3';
import { d3Types } from "./GraphTypes";
import Links from "./Links";
import Nodes from "./Nodes";
import Labels from "./Labels";
import '../css/ForceGraph.css';
import { IEventMessager } from "../utils/EventMessager";

interface ForceGraphProps {
    width: number;
    height: number;
    graph: d3Types.d3Graph;
    eventMessager: IEventMessager;
}

export default class App extends React.Component<ForceGraphProps, {}> {
    simulation: any;

    constructor(props: ForceGraphProps) {
        super(props);
        this.simulation = d3.forceSimulation()
            .force("link", d3.forceLink().id(function 
            (node: any, i: number, nodesData: d3.SimulationNodeDatum[]) {
                return node.id;
            }))
            .force("charge", d3.forceManyBody().strength(-100))
            .force("center", d3.forceCenter(this.props.width / 2, this.props.height / 2))
            .nodes(this.props.graph.nodes as d3.SimulationNodeDatum[]);

        this.simulation.force("link").links(this.props.graph.links);
    }

    componentDidMount() {
        const node = d3.selectAll(".node");
        const link = d3.selectAll(".link");
        const label = d3.selectAll(".label");

        this.simulation.nodes(this.props.graph.nodes).on("tick", ticked);

        function ticked() {
            link
                .attr("x1", function (d: any) {
                    return d.source.x;
                })
                .attr("y1", function (d: any) {
                    return d.source.y;
                })
                .attr("x2", function (d: any) {
                    return d.target.x;
                })
                .attr("y2", function (d: any) {
                    return d.target.y;
                });

            node
                .attr("cx", function (d: any) {
                    return d.x;
                })
                .attr("cy", function (d: any) {
                    return d.y;
                });

            label
                .attr("x", function (d: any) {
                    return d.x + 5;
                })
                .attr("y", function (d: any) {
                    return d.y + 5;
                });
        }
    }

    render() {
        const { width, height, graph, eventMessager } = this.props;
        return (
            <svg className="graph-container"
                width={width} height={height}>
                <Links links={graph.links} />
                <Nodes nodes={graph.nodes} simulation={this.simulation} 
                                           eventMessager={eventMessager}/>
                <Labels nodes={graph.nodes} />
            </svg>
        );
    }
}

Labels

这些标签代表链接的标签,代码如下

import * as React from "react";
import * as d3 from "d3";
import { d3Types } from "./GraphTypes";

class Label extends React.Component<{ node: d3Types.d3Node }, {}> {
    ref!: SVGTextElement;

    componentDidMount() {
        d3.select(this.ref).data([this.props.node]);
    }

    render() {
        return <text className="label" ref={(ref: SVGTextElement) => this.ref = ref}>
                   {this.props.node.id}
               </text>;
    }
}

export default class Labels extends React.Component<{ nodes: d3Types.d3Node[] }, {}> {
    render() {
        const labels = this.props.nodes.map((node: d3Types.d3Node, index: number) => {
            return <Label key={index} node={node} />;
        });

        return (
            <g className="labels">
                {labels}
            </g>
        );
    }
}

链接

这些标签代表图表的链接,代码如下

import * as React from "react";
import * as d3 from "d3";
import { d3Types } from "./GraphTypes";

class Link extends React.Component<{ link: d3Types.d3Link }, {}> {
    ref!: SVGLineElement;

    componentDidMount() {
        d3.select(this.ref).data([this.props.link]);
    }

    render() {
        return <line className="link" ref={(ref: SVGLineElement) => this.ref = ref}
                     strokeWidth={Math.sqrt(this.props.link.value)} />;
    }
}

export default class Links extends React.Component<{ links: d3Types.d3Link[] }, {}> {
    render() {
        const links = this.props.links.map((link: d3Types.d3Link, index: number) => {
            return <Link key={index} link={link} />;
        });

        return (
            <g className="links">
                {links}
            </g>
        );
    }
}

节点

这些标签代表图表的节点,代码如下

import * as React from "react";
import * as d3 from "d3";
import { d3Types } from "./GraphTypes";
import { IEventMessager } from "../utils/EventMessager";
import { ShowInfoInSidePanel } from "../utils/EventMessager";

class Node extends React.Component<{ node: d3Types.d3Node, color: string, 
                                     eventMessager: IEventMessager }, {}> {
    ref!: SVGCircleElement;

    componentDidMount() {
        d3.select(this.ref).data([this.props.node]);
    }

    render() {
        return (
            <circle className="node" r={5} fill={this.props.color}
                ref={(ref: SVGCircleElement) => this.ref = ref}
                onClick={() => {
                    this.props.eventMessager.publish
                    (new ShowInfoInSidePanel(this.props.node.id));
                }}>>
                <title>{this.props.node.id}</title>
            </circle>
        );
    }
}

export default class Nodes extends React.Component
    <{ nodes: d3Types.d3Node[], simulation: any, eventMessager: IEventMessager }, {}> {
    componentDidMount() {
        const simulation = this.props.simulation;
        d3.selectAll<any,any>(".node")
            .call(d3.drag()
                .on("start", onDragStart)
                .on("drag", onDrag)
                .on("end", onDragEnd));

        function onDragStart(d: any) {
            if (!d3.event.active) {
                simulation.alphaTarget(0.3).restart();
            }
            d.fx = d.x;
            d.fy = d.y;
        }

        function onDrag(d: any) {
            d.fx = d3.event.x;
            d.fy = d3.event.y;
        }

        function onDragEnd(d: any) {
            if (!d3.event.active) {
                simulation.alphaTarget(0);
            }
            d.fx = null;
            d.fy = null;
        }
    }

    render() {
        const nodes = this.props.nodes.map((node: d3Types.d3Node, index: number) => {
            return <Node key={index} node={node} color="blue" 
            eventMessager={this.props.eventMessager} />;
        });

        return (
            <g className="nodes">
                {nodes}
            </g>
        );
    }
}

节点代码的重要部分是,有一个 onClick 处理程序,它使用 EventMessager 类将消息分派回 Home 组件,该组件将侦听此事件并进行 Redux 调用,以获取所选节点文本的数据。

Redux

这是 Redux 代码,它将 Home 组件的动作创建者和 Redux store 状态变化连接在一起。可以看到,它接收 requestGenreInfo,该请求被发送到后端 WebApi(即 fetch(genre/info/${genre})),然后根据结果构造一个新的 GenreInfoState,并通过 Redux store 分派回 Search 组件的 Redux useSelector hook,该 hook 正在监听此状态变化。

import { Action, Reducer } from 'redux';
import { AppThunkAction } from './';

// -----------------
// STATE - This defines the type of data maintained in the Redux store.

export interface GenreInfoState {
    isLoading: boolean;
    genre: string;
    genreInfo: GenreInfo;
}

export interface GenreInfo {
    genreName: string;
    paragraphs: Array<string>;
}

// -----------------
// ACTIONS - These are serializable (hence replayable) descriptions of state transitions.
// They do not themselves have any side-effects; 
// they just describe something that is going to happen.

interface RequestGenreInfoAction {
    type: 'REQUEST_GENRE_INFO';
    genre: string;
}

interface ReceiveGenreInfoAction {
    type: 'RECEIVE_GENRE_INFO';
    genre: string;
    genreInfo: GenreInfo;
}

// Declare a 'discriminated union' type. 
// This guarantees that all references to 'type' properties contain one of the
// declared type strings (and not any other arbitrary string).
type KnownAction = RequestGenreInfoAction | ReceiveGenreInfoAction;

// ----------------
// ACTION CREATORS - These are functions exposed to UI components 
// that will trigger a state transition.
// They don't directly mutate state, 
// but they can have external side-effects (such as loading data).

export const actionCreators = {
    requestGenreInfo: (genre: string): 
       AppThunkAction<KnownAction> => (dispatch, getState) => {
        // Only load data if it's something we don't already have 
        // (and are not already loading)
        const appState = getState();
        if (appState && appState.genres && genre !== appState.genres.genre) {
            fetch(`genre/info/${genre}`)
                .then(response => response.json() as Promise<GenreInfo>)
                .then(data => {
                    dispatch({ type: 'RECEIVE_GENRE_INFO', genre: genre, genreInfo: data });
                });

            dispatch({ type: 'REQUEST_GENRE_INFO', genre: genre });
        }
    }
};

// ----------------
// REDUCER - For a given state and action, returns the new state. 
// To support time travel, this must not mutate the old state.
let pars: string[] = [];
const emptyGenreInfo = { genreName: '', paragraphs: pars };
const unloadedState: GenreInfoState = 
      { genre: '', genreInfo: emptyGenreInfo, isLoading: false };

export const reducer: Reducer<GenreInfoState> = 
    (state: GenreInfoState | undefined, incomingAction: Action): GenreInfoState => {
    if (state === undefined) {
        return unloadedState;
    }

    const action = incomingAction as KnownAction;
    switch (action.type) {
        case 'REQUEST_GENRE_INFO':
            return {
                genre: action.genre,
                genreInfo: state.genreInfo,
                isLoading: true
            };
        case 'RECEIVE_GENRE_INFO':
            // Only accept the incoming data if it matches the most recent request.
            // This ensures we correctly
            // handle out-of-order responses.
            var castedAction = action as ReceiveGenreInfoAction;
            if (action.genre === state.genre) {
                return {
                    genre: castedAction.genre,
                    genreInfo: castedAction.genreInfo,
                    isLoading: false
                };
            }
            break;
    }

    return state;
};

SlidingPanel

正如本文开头的演示视频所示,当一个 D3 节点被点击时,我们使用一个很酷的滑动面板来显示被点击节点的结果。这时,会针对所选节点的文本向后端 WebApi 控制器发出调用。其思路是,节点使用 EventMessager RX 类分发一条消息,Home 组件监听该消息,然后设置一个名为 isOpen 的 prop 值,该值控制 SlidingPanel 是否过渡滑入。

这个订阅代码如下所示

useEffect(() => {
	const sub = props.eventMessager.observe()
	.pipe(
		filter((event: IMessage) => event instanceof ShowInfoInSidePanel),
		map((event: IMessage) => event as ShowInfoInSidePanel)
	)
	.subscribe(x => {
		//pass callback to setState to prevent currentState
		//  being a dependency
		setState(
			(currentState) => ({
				...currentState,
				isopen: true,
				selectedNodeText: x.itemClicked
			})
		);
	});

	return () => {
		sub.unsubscribe();
	}
}, [props.eventMessager]);

这是 SlidingPanel 组件,我将它从这个 JSX 版本改编成了 TypeScript 版本。

import React from 'react';
import { CSSTransition } from 'react-transition-group';
import '../css/SlidingPanel.css';

export enum PanelType {
    Top = 1,
    Right,
    Bottom,
    Left,
}

type Nullable<T> = T | null;
export interface SliderProps {
    type: PanelType;
    size: number;
    panelClassName?: string;
    isOpen: boolean;
    children: Nullable<React.ReactElement>;
    backdropClicked: () => void;
}

const getPanelGlassStyle = (type: PanelType, size: number, hidden: boolean): 
      React.CSSProperties => {
    const horizontal = type === PanelType.Bottom || type === PanelType.Top;
    return {
        width: horizontal ? `${hidden ? '0' : '100'}vw` : `${100 - size}vw`,
        height: horizontal ? `${100 - size}vh` : `${hidden ? '0' : '100'}vh`,
        ...(type === PanelType.Right && { left: 0 }),
        ...(type === PanelType.Top && { bottom: 0 }),
        position: 'inherit',
    };
};

const getPanelStyle = (type: PanelType, size: number): React.CSSProperties => {
    const horizontal = type === PanelType.Bottom || type === PanelType.Top;
    return {
        width: horizontal ? '100vw' : `${size}vw`,
        height: horizontal ? `${size}vh` : '100vh',
        ...(type === PanelType.Right && { right: 0 }),
        ...(type === PanelType.Bottom && { bottom: 0 }),
        position: 'inherit',
        overflow: 'auto',
    };
};

function getNameFromPanelTypeEnum(type: PanelType): string {

    let result = "";
    switch (type) {
        case PanelType.Right:
            result = "right";
            break;
        case PanelType.Left:
            result = "left";
            break;
        case PanelType.Top:
            result = "top";
            break;
        case PanelType.Bottom:
            result = "bottom";
            break;
    }
    return result;
}

const SlidingPanel: React.SFC<SliderProps> = (props) => {

    const glassBefore = props.type === PanelType.Right || props.type === PanelType.Bottom;
    const horizontal = props.type === PanelType.Bottom || props.type === PanelType.Top;
    return (
        <div>
            <div className={`sliding-panel-container 
                 ${props.isOpen ? 'active' : ''} 'click-through' `}>
                <div className={`sliding-panel-container 
                 ${props.isOpen ? 'active' : ''} 'click-through' `}>
                    <CSSTransition
                        in={props.isOpen}
                        timeout={500}
                        classNames={`panel-container-${getNameFromPanelTypeEnum(props.type)}`}
                        unmountOnExit
                        style={{ display: horizontal ? 'block' : 'flex' }}
                    >
                        <div>
                            {glassBefore && (
                                <div
                                    className="glass"
                                    style={getPanelGlassStyle(props.type, props.size, false)}
                                    onClick={(e: React.MouseEvent<HTMLDivElement, 
                                    MouseEvent>) => { props.backdropClicked(); }}
                                />
                            )}
                            <div className="panel" 
                                 style={getPanelStyle(props.type, props.size)}>
                                 <div className={`panel-content 
                                 ${props.panelClassName || ''}`}>{props.children}</div>
                            </div>
                            {!glassBefore && (
                                <div
                                    className="glass"
                                    style={getPanelGlassStyle(props.type, props.size, false)}
                                    onClick={(e: React.MouseEvent<HTMLDivElement, MouseEvent>) 
                                    => { props.backdropClicked(); }}
                                />
                            )}
                        </div>
                    </CSSTransition>
                </div>
            </div>
        </div>
    );
}

SlidingPanel.defaultProps = {
    type: PanelType.Left,
    size: 50,
    panelClassName: '',
    isOpen: false,
    children: null,
    backdropClicked: () => null
}

export default SlidingPanel;

这大部分功劳真的要归功于原作者。我只是把它 TypeScript 化了。

Search 组件

Search 组件是一个顶层路由组件,看起来像这样

该组件使用了 Redux,并使用 Redux 调用后端 WebApi,同时也使用了 ReduxThunk。Redux 的流程是这样的

  • 我们分发 requestSearchInfo 动作。
  • 我们使用 Redux hook 来监听 Search State 的状态变化。

Search 组件标记中最重要的部分如下所示

import React, { useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { Scrollbars } from 'react-custom-scrollbars';

//css
import './css/Search.css';

//redux
import { ApplicationState } from '../store';
import * as SearchStore from '../store/Search';

export interface SearchProps {

}

interface ISearchState {
    selectedItem: string;
    selectedSearchItem: SearchStore.GenreDetailedItem;
}

const initialState: ISearchState = {
    selectedItem: '',
    selectedSearchItem: null
}

const Search: React.FunctionComponent<SearchProps> = () => {

    const dispatch = useDispatch();

    const [currentState, setState] = useState<ISearchState>(initialState);

    const onGenreChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
        if (e.target.value === '--') {
            return;
        }

        setState(
            {
                selectedItem: e.target.value,
                selectedSearchItem: null
            }
        );
    }

    const onImgMouseDown = (item: SearchStore.GenreDetailedItem) => {
        setState(
            {
                ...currentState,
                selectedSearchItem: item
            }
        );
    }

    const storeState: SearchStore.SearchState = useSelector(
        (state: ApplicationState) => state.search as SearchStore.SearchState
    );

    React.useEffect(() => {
        dispatch(SearchStore.actionCreators.requestSearchInfo(currentState.selectedItem));
    }, [currentState.selectedItem]);

    return (
        <div>
           .....
        </div>
    );
}

export default Search;

滚动条

这和上面提到的 ScrollBars 是一样的。

Redux

这是 Redux 代码,它将 Search 组件的动作创建者和 Redux store 状态变化连接在一起。可以看到,它接收 requestSearchInfo,该请求被发送到后端 WebApi(即 fetch(genre/details/${genre})),然后根据结果构造一个新的 SearchState,并通过 Redux store 分派回 Search 组件的 Redux useSelector hook,该 hook 正在监听此状态变化。

import { Action, Reducer } from 'redux';
import { AppThunkAction } from './';

// -----------------
// STATE - This defines the type of data maintained in the Redux store.

export interface SearchState {
    isLoading: boolean;
    genre: string;
    searchInfo: GenreDetailedItemContainer;
}

export interface GenreDetailedItemContainer {
    genreName: string;
    items: Array<GenreDetailedItem>;
}

export interface GenreDetailedItem {
    title: string;
    band: string;
    imageUrl: string;
    paragraphs: Array<string>;
}

// -----------------
// ACTIONS - These are serializable (hence replayable) descriptions of state transitions.
// They do not themselves have any side-effects; 
// they just describe something that is going to happen.

interface RequestSearchInfoAction {
    type: 'REQUEST_SEARCH_INFO';
    genre: string;
}

interface ReceiveSearchInfoAction {
    type: 'RECEIVE_SEARCH_INFO';
    genre: string;
    searchInfo: GenreDetailedItemContainer;
}

// Declare a 'discriminated union' type. 
// This guarantees that all references to 'type' properties contain one of the
// declared type strings (and not any other arbitrary string).
type KnownAction = RequestSearchInfoAction | ReceiveSearchInfoAction;

// ----------------
// ACTION CREATORS - These are functions exposed to UI components 
// that will trigger a state transition.
// They don't directly mutate state, but they can have external side-effects 
// (such as loading data).

export const actionCreators = {
    requestSearchInfo: (genre: string): 
        AppThunkAction<KnownAction> => (dispatch, getState) => {
        // Only load data if it's something we don't already have (and are not already loading)
        const appState = getState();
        if (appState && appState.search && genre !== appState.search.searchInfo.genreName) {
            fetch(`genre/details/${genre}`)
                .then(response => response.json() as Promise<GenreDetailedItemContainer>)
                .then(data => {
                    dispatch({ type: 'RECEIVE_SEARCH_INFO', genre: genre, searchInfo: data });
                });

            dispatch({ type: 'REQUEST_SEARCH_INFO', genre: genre });
        }
    }
};

// ----------------
// REDUCER - For a given state and action, returns the new state. 
// To support time travel, this must not mutate the old state.
let items: GenreDetailedItem[] = [];
const emptySearchInfo = { genreName: '', items: items };
const unloadedState: SearchState = { genre: '', searchInfo: emptySearchInfo, isLoading: false };

export const reducer: Reducer<SearchState> = 
(state: SearchState | undefined, incomingAction: Action): SearchState => {
    if (state === undefined) {
        return unloadedState;
    }

    const action = incomingAction as KnownAction;
    switch (action.type) {
        case 'REQUEST_SEARCH_INFO':
            return {
                genre: action.genre,
                searchInfo: state.searchInfo,
                isLoading: true
            };
        case 'RECEIVE_SEARCH_INFO':
            // Only accept the incoming data if it matches the most recent request. 
            // This ensures we correctly handle out-of-order responses.
            var castedAction = action as ReceiveSearchInfoAction;
            if (action.genre === state.genre) {
                return {
                    genre: castedAction.genre,
                    searchInfo: castedAction.searchInfo,
                    isLoading: false
                };
            }
            break;
    }

    return state;
};

Bootstrap 弹出窗口

我使用 Bootstrap 来显示一个弹出窗口,运行时看起来像这样。

我使用了这段相当标准的 Bootstrap 代码

<div className="modal fade" id="exampleModal" 
role="dialog" aria-labelledby="exampleModalLabel" 
aria-hidden="true">
    <div className="modal-dialog" role="document">
        <div className="modal-content">
            <div className="modal-header">
                <h5 className="modal-title" 
                id="exampleModalLabel" 
                style={{ color: "#0094FF" }}>
                {currentState.selectedSearchItem.title}</h5>
                <button type="button" className="close" 
                data-dismiss="modal" aria-label="Close">
                    <span aria-hidden="true">×</span>
                </button>
            </div>
            <div className="modal-body">
                <img className="searchImgPopup" 
                src={currentState.selectedSearchItem.imageUrl} />
                <Scrollbars
                    autoHeight
                    autoHeightMin={200}
                    autoHeightMax={600}
                    style={{ width: 300 }}>
                    <div className="mainHeader" 
                    style={{ color: "#0094FF" }}>{currentState.selectedSearchItem.band}</div>
                    <div className="subHeader">
                        {currentState.selectedSearchItem.paragraphs.map((para, index) => (
                            <p key={index}>{para}</p>
                        ))}
                    </div>
                </Scrollbars>
            </div>
        </div>
    </div>
</div> 

结论

写这个小应用我玩得很开心。我确实看到,通过使用 Redux hooks,它确实清理了整个 Connect -> MapDispatchToProps/MapStateToProps 的流程,让代码更整洁了一些。我试图去掉我所有的基于类的组件,转而使用纯组件或函数式组件,但我有时发现 TypeScript 会给我带来一点阻碍。不过总的来说,我发现这相当可行,并且我很享受这个过程。

总之,希望大家喜欢它,一如既往地欢迎投票/评论。

历史

  • 2020年4月28日:初始版本
© . All rights reserved.