読者です 読者をやめる 読者になる 読者になる

非SPAなサービスにReactを導入する

投稿開発部の外村(@hokaccha)です。今回はReactについてのお話です。

ReactとSPA

最近JavaScriptやそれを取り巻くフレームワークなどの話題では、サーバ側はAPIのみを提供し、View(HTML)は全てJavaScriptで描画するような、いわゆるシングルページアプリケーション(以下SPA)についてよく語られます。

一方で、SPAを構築するにはコストがかかることも事実で、特にフロントエンドエンジニアが多くない環境では、従来通りサーバーサイドでViewを書きつつ動的な部分だけJavaScriptで処理するというアーキテクチャのほうが現実的な場合も往々にしてあります。

今回はこのような、サーバー側でHTMLを生成し、一部の動的な部分だけをReactで書くためのTipsを紹介します。

なお、基本的にサーバーサイドはRails前提ですが、RailsにおけるReactの開発環境の構築方法などについて以下の記事や資料を参照ください。

コンポーネントの例

例えばブログ記事に「いいね」が押せる機能を考えてみましょう。機能としては

  • いいねできる
  • いいねが解除できる
  • 自分がいいねしているかどうかわかる
  • いいねしているユーザー数が見れる
  • いいねユーザーの一覧がポップアップで見れる
  • ユーザー一覧は20件ごとに読み込む
  • いいね押したときにログインしてなければログインの誘導ポップアップが出る

このように、小さいコンポーネントではありますが、複数の状態や機能があり、いいねの付け外しやユーザー一覧の取得はAjaxで行う必要があります。

テンプレートをhamlやerbで書いてjQueryでDOM操作をして実現することもできそうですが、このような機能をjQueryだけでメンテナブルなコードを書くのは簡単ではないと思っています。一方Reactは宣言的で見通しのよいコードでコンポーネントを記述でき、Viewの機能のみを提供するという単機能なライブラリのため、こういった部分的に利用するケースでも導入しやいです*1

また私自身、これと同じような機能をjQueryとReactの両方で実装した経験がありますが、例えこのぐらい小さい機能であってもReactのほうが楽に実装できると感じました。サーバー側のテンプレート言語とReact側のJSXとでテンプレートの言語が分かれてしまうというデメリットはありますが、個人的にはそこを差し引いてもメリットのほうが大きいと思っています。

react-railsを使った自動マウント

このようにReactを動的なコンポーネントだけに使っていくという手法の場合、面倒なのがReactコンポーネントのマウントです。SPAの場合は基本的にルートコンポーネント一つをマウントすれば済みますが、こういった構成の場合は1ページに複数のコンポーネントをマウントするケースが多くなります。

例えばブログ記事のページで、いいねとコメントの2つの動的なコンポーネントがあるとします。まずはテンプレートを次のようにして

<h1><%= @entry.title %></h1>
<%= @entry.body %>

<div class="like-component"></div>
<div class="comment-component"></div>

JavaScript側で対象のDOM要素に作成したReactコンポーネントをマウントします。

document.addEventListener('DOMContentLoaded', () => {
  let like = document.querySelector('like-component');
  let comment = document.querySelector('comment-component');

  ReactDOM.render(React.createElement(LikeComponent), like);
  ReactDOM.render(React.createElement(CommentComponent), comment);
});

2つくらいであればこれでもいいですが、コンポーネントを作る度にこのようなコードを書かないといけないのは面倒です。また、コンポーネントの初期値としてpropsを与えたいというケースも出てくるでしょう。

そこでRailsの場合はreact-railsを使うのがオススメです。react-railsにはサーバーサイドレンダリングなどの興味深い機能もありますが、今回はview helperとreact_ujsを使った自動マウントの機能を紹介します。

先程の例はreact-railsを使うと次のように書くことができます。

<h1><%= @entry.title %></h1>
<%= @entry.body %>

<%= react_component('LikeComponent') %>
<%= react_component('CommentComponent') %>

JavaScript側ではreact_ujsを読み込んでおき、コンポーネントをグローバルから参照できるようにしておくだけで自動的にコンポーネントがマウントされます。

また、引数でpropsを渡すこともできます。

<%= react_component('LikeComponent', liked: @current_user.liked?(@entry), likeCount: @entry.likes.count) %>

このようにすることでLikeComponentに初期値をpropsとして渡すことができ、Ajaxで通信せずとも初期描画を行うことができます。

また、react_ujsの自動マウントはturbolinksにも対応しており、turbolinksでページ遷移したときに自動でマウント・アンマウントを行ってくれるという機能があります。jQuery時代にturbolinksを使って$(document).ready()が走らなくてハマる、という経験されたことがある方には嬉しいかもしれません。

Railsを使わない場合や、それだけのためにreact-railsを使いたくない場合は同じような仕組みを実現するのは大した手間ではないので自作してもいいと思いますが、1ページに複数コンポーネントをマウントする場合は、何かしらこのような自動マウントの仕組みがあると便利です。

react-micro-container

Reactではルートコンポーネントが全ての状態を管理し、子のコンポーネントにはpropsとして値を渡すようにすることで、できるだけコンポーネントから状態を取り除くというプラクティスがあります。このとき子要素で発生したイベントをルートコンポーネントに伝える手段が必要になります。

愚直にやるとイベントハンドラを子要素に渡し、全てのコンポーネントでイベントを拾って一つ上の親にあげていくという処理が必要になります。例えば、いいねコンポーネントで、「いいね」や「もっと見る」を押したときのイベントをルートコンポーネントまで伝えるのは次のようなイメージです*2

f:id:hokaccha:20161026134922p:plain

これがいわゆるイベントのバケツリレーです。今回のような小さいコンポーネントの場合も、内部でコンポーネントを分割していくと容易に数段のネストしたコンポーネントになります。

何かしらのFluxフレームワークを使ってもよいですが、こういった小さいコンポーネントにFluxフレームワークはオーバースペックなことも多いです。そこで拙作ですが、react-micro-containerという小さいライブラリを使うと、イベントのバケツリレーだけを簡略することができます。

f:id:hokaccha:20161026134928p:plain

個人的には小さいコンポーネントではこれぐらいで十分なケースも多いと思っています。詳しい使い方などはこちらの記事を参照してみてください。

小さいReactアプリケーションのためのライブラリ書いた - Qiita

注意点として、これは小さいコンポーネントであればうまくいきますが、規模が大きくなってくるとイベントの数が多くなりすぎて破綻してくるので、そういった場合はFluxフレームワークを導入するなどの対応が必要になるかもしれません。

まとめ

サーバー側で静的なHTMLを出力しつつ、動的にしたい部分だけをReactを使って実装する際のTipsについて紹介しました。

ReactはFluxなどを使って大きいアプリケーションを作ろうとすると、とたんに設計が難しくなってきますが*3、こういった小さいコンポーネントから導入する方法であれば、JavaScriptの設計になれていなくても導入しやすいですし、現状のアプリケーションに一部分導入するということも可能です。

Reactに興味はあるが難しそうで二の足を踏んでいるという方はこのようなところから利用してみてはいかがでしょうか。

*1:PolymerやVue.jsも同じようなことが実現できそうですが今回はReactにフォーカスしています

*2:コンポーネントのツリーはわかりやすくするために簡略化しています

*3:Reactに限った話しではなく、規模が大きくなれば何を使っても難しくなります

/* */ @import "/css/theme/report/report.css"; /* */ /* */ body{ background-image: url('http://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527163350.png'); background-repeat: repeat-x; background-color:transparent; background-attachment: scroll; background-position: left top;} /* */ body{ border-top: 3px solid orange; color: #3c3c3c; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; line-height: 1.8; font-size: 16px; } a { text-decoration: underline; color: #693e1c; } a:hover { color: #80400e; text-decoration: underline; } .entry-title a{ color: rgb(176, 108, 28); cursor: auto; display: inline; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; font-size: 30px; font-weight: bold; height: auto; line-height: 40.5px; text-decoration: underline solid rgb(176, 108, 28); width: auto; line-height: 1.35; } .date a { color: #9b8b6c; font-size: 14px; text-decoration: none; font-weight: normal; } .urllist-title-link { font-size: 14px; } /* Recent Entries */ .recent-entries a{ color: #693e1c; } .recent-entries a:visited { color: #4d2200; text-decoration: none; } .hatena-module-recent-entries li { padding-bottom: 8px; border-bottom-width: 0px; } /*Widget*/ .hatena-module-body li { list-style-type: circle; } .hatena-module-body a{ text-decoration: none; } .hatena-module-body a:hover{ text-decoration: underline; } /* Widget name */ .hatena-module-title, .hatena-module-title a{ color: #b06c1c; margin-top: 20px; margin-bottom: 7px; } /* work frame*/ #container { width: 970px; text-align: center; margin: 0 auto; background: transparent; padding: 0 30px; } #wrapper { float: left; overflow: hidden; width: 660px; } #box2 { width: 240px; float: right; font-size: 14px; word-wrap: break-word; } /*#blog-title-inner{*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-position: left 0px;*/ /*}*/ /*.header-image-only #blog-title-inner {*/ /*background-repeat: no-repeat;*/ /*position: relative;*/ /*height: 200px;*/ /*display: none;*/ /*}*/ /*#blog-title {*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-image: url('http://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527172848.png');*/ /*background-repeat: no-repeat;*/ /*background-position: left 0px;*/ /*}*/