NO_WAIT

主にプログラミング。趣味的なWebサービスをたくさん作りたいのですが何事も遅々として進みません…

ReactとExpressで作る簡易画像ビューア

React, Redux, react-router の手習いとして、非常に単純な画像ビューアを作ってみました。 表示する画像はWikipediaのthumbnailです。

ソース

wikipedia-thumbnail-gallery.

App

上部にnavbarがあり、テキストボックスが配置されています。 ここにキーワードを入力すると、関連するWikipedia記事が検索されます。

f:id:shinaisan:20170824224222p:plain

検索が完了すると、キーワードにマッチするWikipedia記事がページ中央にリストされます。 それらの中でthumbnailをもつ記事に対してはそれも一緒に表示されます。

f:id:shinaisan:20170824224245p:plain

どれかthumbnailをクリックすると、モーダルウィンドウで大きな画像が表示できます。

f:id:shinaisan:20170824224259p:plain

処理の流れ

  1. 検索キーワードが入力されると、クライアントはpath /searchhistory stackにpushします。
  2. クライアントは検索結果の表示の為のGalleryコンポーネントをマウントします。マウントと同時に、サーバーAPIの非同期呼び出しを行い、START_SEARCHING_FOR_KEYWORD actionをdispatchします。
  3. サーバーはMediawiki APIを呼び出し、与えられたキーワードにマッチするWikipedia記事のタイトルとthumbnail URLを取得します。
  4. サーバーから正常に応答を得ると、クライアントはRECEIVED_THUMBNAILS actionをdispatchします。
  5. クライアントは検索結果を表示します。
  6. 検索結果にあるthumbnailがクリックされると、クライアントはモーダルウィンドウにより大きい画像を表示します。

f:id:shinaisan:20170824224336p:plain

サーバー

API定義

サーバーはExpressで実装されます。 /api/search routeのみ定義してあります。

// Excerpt from ./server/app.js
app.get('/api/search', (req, res) => {
  wikipedia.fetchTitlesAndThumbnails(req.query.kw) // Defined in ./server/wikipedia.js
    .then(result => {
      res.json(result)
    })
    .catch(error => {
      console.error(error.toString());
      res.status(500).json({error: error.toString()});
    });
});

Mediawiki API

2種類のMediawiki APIを使用します: opensearch, query(pageimages)

Mediawikiクエリ例

クライアント

検索窓

ReactコンポーネントTopNavbarには検索窓が配置され、submitイベントハンドラにおいて historyスタックに/search pathがpushされます。 このhistoryオブジェクトはreact-routerのBrowserRouterから渡され、 コンポーネントpropからアクセス可能となります。

Route

Home route /は最初に表示されるページですが、サンプルクエリへのリンクのみを含む ほとんど空白のplaceholderといっても良い程度のものです。

Route /searchGalleryコンポーネントをマウントし、 サーバーAPIの非同期呼び出しを開始します。

        <Switch location={isModal ? this.previousLocation : location}>
          <Route exact path='/' component={Home}/>
          <Route path='/search' component={Gallery} />
        </Switch>

react-routerによるモーダルウィンドウ表示例

モーダルウィンドウ実現はReact Training / React Routerにある例をほぼそのまま真似しています。

location.stateオブジェクトは、thumbnailの一つがクリックされると 定義されます。 フラグisModalがtrueのとき、Switch routeのlocationは 直前のlocation(this.previousLocation)を踏襲します。 Modalコンポーネントは、その直前のlocationを表示した上で 重ねて表示されます。

  render() {
    const { location } = this.props
    const isModal = !!(
      location.state &&
      location.state.modal &&
      this.previousLocation !== location // not initial render
    )
    return (
      <div>
        <Switch location={isModal ? this.previousLocation : location}>
          <Route exact path='/' component={Home}/>
          <Route path='/search' component={Gallery} />
        </Switch>
        {
          isModal
          ? <Route path='/img' component={Modal} />
          : null
        }
      </div>
    )
  }

API呼び出しと状態遷移

GalleryコンポーネントfetchThumbnailというメソッドを定義しています。 これはコンポーネントマウント時またはprops更新時に呼ばれます。 呼ばれるとaxios.getでExpress APIを叩きます。

class Gallery extends React.Component {
  fetchThumbnails(params) {
    const dispatch = this.props.dispatch;
    const loading = this.props.loading;
    const key = params.keyword;
    console.log('fetchThumbnails', key);
    if (loading) {
      return null;
    }
    dispatch({type: 'START_SEARCHING_FOR_KEYWORD', keyword: key});
    return axios.get('/api/search', {params: {kw: key}})
      .then(response => {
        let result = response.data;
        console.log("fetchThumbnails result", result);
        dispatch({
          type: 'RECEIVED_THUMBNAILS',
          keyword: key,
          thumbnails: result
        });
      })
      .catch(error => {
        console.log("Error in fetchThumbnails", error);
        dispatch({
          type: 'ERROR_IN_API',
          keyword: key,
          error: error
        });
      });
  }
  componentDidMount() {
    let query = queryString.parse(this.props.location.search);
    this.fetchThumbnails(query);
  }
  ...
}

下記actionがdispatchされます。

GET直前

  {
    type: 'START_SEARCHING_FOR_KEYWORD',
    keyword: key /* keyword from user */
  }

検索正常終了

  {
    type: 'RECEIVED_THUMBNAILS',
    keyword: key, /* keyword from user */
    thumbnails: result /* result of the API */
  }

エラー発生

  {
    type: 'ERROR_IN_API',
    keyword: key, /* keyword from user */
    error: error /* the error caught during axios.get */
  }

Reducer for Redux Store

Reduxのreducerはsrc/reducer.jsに下記の通り定義されます。

const INITIAL_STATE = {
  loading: false,
  keyword: '',
  thumbnails: {},
  error: false
}

export default (state = INITIAL_STATE, action) => {
  switch (action.type) {
    case 'START_SEARCHING_FOR_KEYWORD':
      return {
        loading: true,
        keyword: action.keyword,
        thumbnails: {},
        error: false
      }
    case 'RECEIVED_THUMBNAILS':
      return {
        loading: false,
        keyword: action.keyword,
        thumbnails: action.thumbnails,
        error: false
      }
    case 'ERROR_IN_API':
      return {
        loading: false,
        keyword: action.keyword,
        thumbnails: {},
        error: action.error
      }
    default:
      return state
  }
}

create-react-appのWebpack Dev ServerとExpress APIの連携

クライアントはcreate-react-appで作られているので、 開発版の動作確認はWebpack Dev Serverで行います。 ところで、本AppにはExpressによるAPIサーバーが別に作られています。 これら2つのサーバーを連携させる必要があります。 これにはpackage.jsonでWebpack Dev Serverのproxyを設定し、 両サーバーを起動することで実現できます。

Fullstack React: How to get “create-react-app” to work with your API が参考になりました。

concurrentlyで同時起動

package.jsonscriptsに2つのサーバーの同時起動を指示します。

  "start": "concurrently \"node server\" \"react-scripts start\""

yarn run startが実際に起動するコマンドです:

concurrently "node server" "react-scripts start"

Express APIサーバーはポート7070で起動するようになっています。 package.jsonにproxyを指定します。

  "proxy": {
    "/api": {
      "target": "http://localhost:7070"
    }
  }

上記サーバーをリモートホスト(例えばEC2インスタンスとか)で起動した場合、 .env.developmentファイルにHOST指定が必要となるかもしれません。

HOST=ec2-xxx.compute.amazonaws.com

参考