amakan の React コンポーネント設計

説明用の図

image

例として、amakan anime のトップページ https://anime.amakan.net/ の構造を挙げながら説明する。(ところで amakan anime は今月中に完成予定のサービスで実験的に公開している状態なので、まだまだ至らないところが多々あります…)

登場するコンポーネント一覧

React.Component クラスを継承したクラスをコンポーネントと呼ぶ。主に登場するコンポーネントは以下の通り。

  • Header
  • Layout
  • Router
  • VideoPrograms

Router コンポーネント

最上位のコンポーネントとして、Router コンポーネントが存在する。このコンポーネントを利用して、ページごとにどのコンポーネントを表示すべきかを分岐させる。amakan anime のトップページでは VideoPrograms コンポーネントを描画し、amakan のユーザページでは BookUser コンポーネントを描画するという具合である。

ページというものをどう分類するか

amakan では、ページというものを Rails の action 単位で分類することにしている。ここで言う Rails の action というのは、Model-View-Controller の Controller のインスタンスメソッドの 1 つのことを指しており、Rails ではリクエストが何らかの action で処理されることになっている。例えば、amakan anime のトップページは VideoProgramsController#index というインスタンスメソッドによって描画されるページである。

ルーティング処理の実現方法

Router コンポーネントがどのコンポーネントを描画すべきかどうかを決定するロジックを、ルーティング処理と呼ぶことにする。前述したようにページは action の単位で分類されているので、Router は action の名前を元に適切なコンポーネントを選択して描画すれば良い。

amakan では、action の識別子として "video_programs#index" のような書式からなる文字列を用意し、これを action path という名前で呼ぶことにしている。Router コンポーネントには初回描画時に { actionPath: "video_programs#index", ... } のような props が渡されるようになっており、Router コンポーネントはこの値を元に適切なコンポーネントを選択し、ルーティング処理を実現している。なお、Router に渡される props はそのままルーティング先のコンポーネントに引き継がれる。

ルーティング定義の実装

Router コンポーネントは、実際のコードでは以下のようなルーティング定義を参照している。例えば新しいページを追加したいときは、まずこの Object に値を追加し、対応する action と React コンポーネントをそれぞれ用意することになる。

{
  "video_casts#show": VideoCast,
  "video_monthly_rankings#show": VideoRankingMonthly,
  "video_programs#index": VideoPrograms,
  "video_search_results#show": VideoSearchResult,
  "video_season_works#index": VideoSeasonWorks,
  "video_settings_channels#index": VideoSettingsChannels,
  "video_staffs#show": VideoStaff,
  "video_timelines#show": VideoTimeline,
  "video_user_followees#index": VideoUserFollowees,
  "video_user_followers#index": VideoUserFollowers,
  "video_user_likes#index": VideoUserLikes,
  "video_user_tasted_works#index": VideoUserTastedWorks,
  "video_user_tastes#index": VideoUserTastes,
  "video_users#show": VideoUser,
  "video_weekly_rankings#show": VideoRankingWeekly,
  "video_work_episodes#index": VideoWorkEpisodes,
  "video_work_episodes#show": VideoWorkEpisode,
  "video_work_subscribers#index": VideoWorkSubscribers,
  "video_work_tasters#index": VideoWorkTasters,
  "video_works#show": VideoWork,
  "video_yearly_rankings#show": VideoRankingYearly,
  ...
}

ちなみに識者のために与太話をすると、コンポーネントの数があまりにも大きくなってきてコードを分割したくなってきたら (1つのファイルに全てのコンポーネントを含めると巨大になりすぎるので分割したいという話)、ここのルーティングロジックを少し弄って、ある程度の粒度で dynamic import するようにすれば良いと思う。

Layout コンポーネント

ここまでで、Router コンポーネントによって、video_programs#index action に対応して VideoPrograms コンポーネントが描画されるというところまで説明した。これからは、VideoPrograms の中身を見ていくことにする。

面倒なので client/components/VideoPrograms.jsx の2017年3月15日時点でのコードを掲載する。

import Layout from "../components/Layout.jsx";
import Pagination from "../components/Pagination.jsx";
import React from "react";
import VideoProgramListGroup from "../components/VideoProgramListGroup.jsx";
import VideoTopPageSidebar from "../components/VideoTopPageSidebar.jsx";
import VideoTopPageTabs from "../components/VideoTopPageTabs.jsx";

export default class VideoPrograms extends React.Component {
  render() {
    return(
      <Layout>
        <div className="row">
          <div className="column-small-9">
            <section className="card">
              <VideoTopPageTabs/>
              <VideoProgramListGroup videoPrograms={this.props.video_programs}/>
              <Pagination
                currentPage={this.props.current_page}
                hasNextPage={this.props.has_next_page}
                hasPreviousPage={this.props.has_previous_page}
                totalPages={this.props.total_pages}
              />
            </section>
          </div>
          <div className="column-small-3">
            <VideoTopPageSidebar/>
          </div>
        </div>
      </Layout>
    );
  }
}

VideoPrograms や VideoCast など、action と対応する全てのコンポーネントは、Layout コンポーネントを描画するしきたりになっている。Layout コンポーネントは、

  • head 要素の管理 (title要素、meta要素など)
  • ヘッダーの描画
  • フッターの描画

を担当するコンポーネントで、全ページに共通する HTML の描画を担当してくれる便利なコンポーネントである。上例はたまたまトップページを表現するコンポーネントなので記述されていないが、Layout コンポーネントに title や imageUrl などの props を渡すと、ページタイトルや OPG OGP 用の要素を適切に変更してくれるようになっている (トップページでは何も渡していないのでデフォルトの値が利用される)。

ページ遷移の実現方法

サイト内の別ページへのリンクをクリックしたときに、Web ブラウザでページ遷移を行うのではなく、HTML をリンク先のページのものに書き換えることで、無駄の少ない高速なページ遷移を実現しよう、というのがこの節の主題である。これを実現するには、前述した Router の props を適切に書き換えるだけで良い。

例えば、トップページにタイムラインページへのリンクを配置するとき、通常であれば以下のように a 要素を配置することだろう。

<a href="/timeline">タイムライン</a>

しかし amakan では、サイト内リンクの場合、以下のように Link コンポーネントを配置する。

<Link href="/timeline">タイムライン</Link>

Link コンポーネントは、Web ブラウザでページ遷移を行う代わりに指定された URL に XHR でリクエストを送り、レスポンスの内容で Router の状態を差し替えるという振る舞いをする。さて、amakan のサーバサイドの action は、以下のように実装されている。

  • HTML が期待されている場合には、props を利用して SSR した結果の HTML を返す
  • JSON が期待されている場合には、props を JSONエンコードして返す

Link をクリックして発生したリクエストに対しては、action に対応した props が返ってくるということになる。Link コンポーネントは、Router の props をこのレスポンスの内容に差し替えるように仕組まれているので、これで再描画が発生し、ページ遷移が完了する。

なお、コンポーネントツリーの末端に位置している Link コンポーネントが、根に近いところに位置している Router コンポーネントの状態を変更するために、React の context の機能を利用している。

無駄なデータのやり取り

ログイン中のユーザの情報やヘッダの描画に必要な情報など、ページ間で共通するデータはページ遷移時には含めないようにしたほうが、つまり操作に必要な最小のデータしかやり取りしないようにした方が効率的ではないか、という話題があろうと思う。

必要なデータだけ書き換えるようにマイクロマネジメントするより、一度の操作で全てを書き換えるようにしたほうが単純・安全・気楽ということで、現在のような形に落ち着いている。安全というのは、Server-Side Rendering で HTML を描画するときと、ページ遷移で HTML を描画するときとで同じ props を利用することになるので、壊れるときはどちらも壊れるので開発中にすぐに問題に気付く。よって安全である、ということ。

https://amakan.nethttps://anime.amakan.net の体感速度を見る限りでは問題無く、寧ろこの貧弱な EC2 インスタンスで Docker で動いているにしてはやけに速いという感じなので、この規模ではまだまだ問題が顕在化することは無いだろうと思っている。

コンポーネントのファイル構造

全ての React.Component を継承したクラスは client/components/*.jsx というパターンの命名規則のパスに配置されており、例外は無い。ファイル名は、VideoPrograms.jsx のように CamelCase を採用した。基本的に 1 ファイル 1 オブジェクトを export するようにしているので、その export されるオブジェクトの名前がファイル名に充てられることになる。case-insensitiveファイルシステムで問題起こしがちなのはmacが悪いよmacが。

コンポーネント用の mix-in (ここで言う mix-in は クラス - JavaScript | MDN で紹介されているようなもののことを指している) として振る舞う関数を用意する必要があり、これは現状 client/mixins/*.js に配置している。しかしながら、React コンポーネント用の mix-in であるという情報がファイルパス中に明示されないことになるので、それ以外の用途での mix-in が発生することを考慮すると、検討の余地があると思う。

コンポーネント命名規則

ページごとに用意する (action に対応する) コンポーネントには、やはりその action に対応した名前を付けている。Rails だと books#index には books_path、books#show には book_path という名前が付けられるので (厳密に言うと Rails では action に対して名前を付けているのではなくパスのパターンに対して名前を付けているがこの辺りの話は割愛する)、コンポーネントの名前もそれを CamelCase にしたものにする。例えば今回の例で言うと、video_programs#index 用に VideoPrograms というコンポーネントがある。動画を扱う amakan anime では全ての Controller や Component に prefix として video を付けており、書籍を扱う amakan books では prefix として book を付けることにしている。

両者で共通のコンポーネントには prefix を付けておらず、これには Router、Layout、Header などが該当する。

UI のパーツとして切り出したコンポーネントには、表示形態としての部品を表すような名前を後ろに付けており、例えば amakan anime のトップページの幾つかのページで使う共通のサイドバーは VideoTopPageSidebar、共通のタブは VideoTopPageTabs という名前になっている。1つのアプリケーションで複数のサービスを運用するときは、この辺りの prefix, suffix の細かいルール付けが後々かなり効いてくることになる。実際別サービスで命名ルールがバラバラで苦しめられる時期があった。

なおコンポーネント間の参照に関する依存関係がツリー構造になっていたとしても、安易にディレクトリ構造をそのツリー構造に対応付けるとルール付けが破綻する恐れがあるので、そこにディレクトリ構造を持ち込むのはあまりオススメしない (例: video ディレクトリに amakan anime 関係の全てのコンポーネントを入れるなど)。ディレクトリ構造を利用した適切なルールが用意できてかつ運用もできるという場合でない限り、個々のコンポーネントに適切な名前を付けてフラットに管理するという運用の方が、考えることが少なく命名規則も守られやすい傾向にあるのでオススメする。ちなみに命名ルールは大きいスコープで分類したときの名前から順に後置修飾で命名していく方が、辞書順にソートしたときに同種の要素が近くに並びやすいので良いのだが (例: video > top page > tabs)、細かすぎて伝わらない話になってくるのでこの辺りでやめておく。