Webpacker 3ではじめるRailsエンジニアのためのモダンフロントエンド入門 〜Sprocketsを使わないRailsプロジェクト試案〜

はじめに

Webpack、ES6風味のJavaScript、そして他すべてのモダンなクライアント側開発体験の進歩をまだ試していないなら、Webpacker 3.0は、はじめるのに絶好の機会だ。

—DHH

Rails 5.1Webpacker が導入され、Railsでもモダンなフロントエンド開発が簡単にできるようになりました。 Webpackerはリリースからどんどん進化しており、 3.0でさらに使いやすくなりました。

本記事には2つの目的があります:

また、Webpackerの概要を知りたいRailsエンジニアへの機能と使い方紹介にもなっています。

対象読者

Rails自体の基本的な使い方は習得済みのエンジニアのために書きました。 とは言え、Webpacker以外についての説明は、Railsとは無関係に独立して読めるので、たんにモダンフロントエンドに興味がある人にとっても参考になると思います。

サンプルコード

本記事では、以下のサンプルコードを元に解説していきます。1

https://github.com/tai2/webpacker-react-example

Rails 5.1とWebpacker 3を使った簡単なTodoアプリです。 従来通りのRails MVC2とReactの2通りの方法で、同じ機能を実装しているので、 Railsだけで書いていたコードをモダンフロントエンドで実装するとどのようになるのか、比較し易いと思います。

あくまでサンプルコードではありますが、筆者が実際の案件で使うための検証も兼ねており、ほぼこのままの形でプロダクションに投入する予定のコードでもあります。

このサンプルでは以下のことが実現されています:

環境構築というのは、個々の要素間の相性などにより、得てして問題が発生し、正しく動作させるために試行錯誤が必要になります。 ですから、これらの要素をすべて詰め込んで、動作する組み合わせを選定し、実際に動作検証をしたというだけで、ひとつの成果と言って過言ではありません。

以降、上記の要素について個々に解説していきます。 ただし、ひとつの記事で、すべてを詳細に解説することは難しいので、できる限りコードを添えつつ簡単な概要と参考リンクを紹介するに留めます。あくまで、コードの雰囲気と便利なツールの紹介が目的です。 また、JavaScriptのエコシステムというのは非常に多用で選択肢が豊富であり、これが正解というのものはありません。 ここで紹介するものも、あくまでひとつの例に過ぎないことに注意してください。4

この記事で扱わないもの

昨今のシングルページアプリケーション(SPA)と呼ばれる、JavaScriptのみでUIが構成されるWebアプリでは、 しばしばサーバーサイドレンダリングを行います。 これは、JavaScriptが走る前に、あらかじめサーバー側でHTMLを生成してレスポンスに含めておくことで、 初期表示までの時間を短縮する技術です。 SPAでは、最終的なJavaScriptのサイズが数MB以上になることも珍しくないため、 シビアなパフォーマンスが要求されるサービスでは、このような施策が要求されます。 また、サーバーサイドレンダリングを行うとSEO上も有利になると言われています。 筆者はサーバーサイドレンダリングの経験がないため、この記事では扱いません。

また、アクセスされたURLに応じて、クライアント側で表示内容を変更する、 クライアントサイドルーティングも扱いません。5

Webpackとは

ごくごく最近まで、ブラウザ上のJavaScriptには、モジュール分割のための機能がありませんでした。 そのため、ランタイムに頼らずにモジュール化を行うための手法がいくつも発明されてきました。 その中のひとつが、トランスパイルとバンドル化です。

この手法では、CommonJSやESModuleなどの本来はブラウザ上で使えない(あるいは使えなかった) 仕様を解釈しつつ、それをブラウザが解釈できるソースコードに変換(トランスパイル)します。 また、モジュール機能のないブラウザ上で実行するために、変換したソースコードはすべて 結合してひとつのソースコードにします(バンドル化)。

Webpack

WebpackはJSアプリのアセットをひとまとめにする

JavaScriptのバンドルツールとしては、 RollupFusebox など、いくつものプログラムがありますが、現在もっともポピュラーなのが Webpack です。

また、バンドル化するときには、同時に、ECMAScript 2017(ES2017)などの最新のJavaScript仕様から、 より広範囲のブラウザで実行できるECMAScript 5(ES5)などにトランスパイルします。 これにより、現在のフロントエンドプログラミングでは、最新の便利な言語機能を使って、以前よりも快適に開発ができます。

Webpackはあくまでバンドル化だけを行うツールであり、トランスパイルは別のツールが行います。 JavaScriptのトランスパイラでもっともポピュラーなのが Babel です。 WebpackとBabel、およびそれらのプラグインを組み合わせることで、単にトランスパイル&バンドル化を行うだけでなく、さまざまなことが行えます。

トランスパイラとしてもうひとつメジャーなのが、 TypeScript です。こちらは、名前からも分かる通り、静的型チェックの機能を備えつつ、ECMAScriptとほぼ互換性のある文法を持ったべつの言語になっています。

Webpackerとは

Webpacker は、RailsとWebpackを統合するためのgemおよび、Nodeモジュールです。 これには、以下のような機能が含まれます。

なぜSprocketsを避けるのか

Railsには、もともとSprocketsという、CoffeeScriptやSASSのトランスパイルができる機能が含まれています。 Railsとしては、JavaScriptのコンパイルのみをWebpackerで行い、スタイルシートやその他アセットは従来通りSprockets から利用するというのが当面の方針のようです。

しかしながら、筆者の見るところ、実はWebpackerにはSprocketsがなくてもそれだけで完結できる十分な機能が備わっています。 6

だとすると、同一機能を持ったものが2つ存在しているのはDRYではありません。

また、Rails 5.1では、 Sprockets経由でES2015+の構文を使用することが可能であり、 7npm8からインストールしたモジュールを使用することさえも可能です。しかし、Sprocketsでは、現状、ESModuleのimportステートメントやCommonJSのrequire関数を解釈することができないため、実際には、利用できるNodeモジュールがかなり制限されています。

ですから、Nodeエコシステムの恩恵をフルに受けられるWebpacker一本でいくほうが良いと判断し、それを実証するためにこの記事を書きました。

デメリットとして、Sprocketsが前提になっているようなgem(Mountable Engineなど)は、利用できなくなると思われます。

ディレクトリ構成

Webpackerのデフォルトでは、

  • app/javascript/packs/ 変換元のファイル
  • public/packs/ 変換後のファイル

というディレクトリ構成になっています。 app/javascript/packs 内に置かれたすべてのファイルは、自動的に、Webpackのエントリポイントとして扱われます。 つまり、このディレクトリ内にあるファイルはすべて、public/packs/ に変換後のファイルが出力されるということです。 Webpackerでは、この個々のエントリーポイントをpackと呼びます。

同時に、従来の app/assets/javascripts はそのまま残されています。9 Railsのデフォルトでは、 app/assets はアセットパイプラインの管轄であり、ここに置かれているJavaScriptはSprocketsによってビルドされます。

RailsとWebpackerを併用するRailsのデフォルトであれば、この設定で適切なのですが、 我々は、いまSprocketsを捨ててすべてのアセット管理をWebpackerにまかせようとしています。 JavaScript以外のスタイルシート、画像ファイルといったアセット一般をそこに置くとなった場合に、 app/javascript/ というパスは不適切に思えます。 幸い、この部分は設定ファイルの source_path で変更できるため、app/assets/ に変更します。 pack用のディレクトリは、 app/assets/packs で、それ以外は従来のRailsと同じ形になります。 アセットパイプラインの責務をWebpackerに置き換えるので、これがしっくり来ます。

Viewヘルパー

Webpackerでは3つのViewヘルパーが追加されます。

<%= javascript_pack_tag 'todos' %>

javascript_pack_tag で、packでバンドルされたJavaScriptファイルを出力できます。

<%= stylesheet_pack_tag 'todos' %>

stylesheet_pack_tag で、packでバンドルされたCSSファイルを出力できます。 スタイルシートのバンドルについては後程説明します。

<img class="logo" src="<%= asset_pack_path 'images/rails.svg' %>" />

asset_pack_path で、packに含まれるすべてのアセットへのパスを出力できます。 packに画像などのファイルを含める方法については後程説明します。

これらのヘルパーを使用すると、プロダクション環境では、ファイル名に自動的にダイジェストが付加されます。

デフォルトWebpack設定とそのカスタマイズ

Webpackの設定ファイルは、それ自身がJavaScriptモジュールであり、設定が記述されたオブジェクトをエクスポートしています。 そしてWebpackerのnpmモジュールは、ReactやSCSSなどの変換が使えるように設定されたWebpackの設定を提供するオブジェクトです。 ただし、厳密にはWebpack設定そのものではなく、カスタマイズがしやすいインターフェイスを備えた独自のオブジェクトになっています。

// Webpackerの設定オブジェクトをインポートする
const { environment } = require('@rails/webpacker')

// ここで設定をカスタマイズする

// Webpackerの独自オブジェクトからWebpack設定オブジェクトに変換しエクスポートする
module.exports = environment.toWebpackConfig()

Webpackerのオブジェクトで設定できるのは、ローダーとプラグインのみに制限されています。 ローダーは、拡張子ごとの変換を定義し、プラグインは、それ以外の一般的な拡張、たとえばminifyや(トランスパイル対象のコードへの)環境変数の注入などです。この範囲に収まらないカスタマイズがどうしても必要な場合は、 toWebpackConfig() した後の生のWebpack設定オブジェクトをいじる必要があります。

ジェネレータ

Rails 5.1以降では、プロジェクト生成時にReact、Vue.js、Angular、Elmのどれかを選択して雛型を生成することができます。 Reactの場合は以下のようにします。

rails new myapp --webpack=react

あるいは、既存のプロジェクト(5.1にアップグレード済みかつwebpacker gem追加済みとする)にwebpacker関連の設定を追加するには、 以下のようにします。

./bin/rails webpacker:install
./bin/rails webpacker:install=react

ちなみに、この記事のサンプルプロジェクトは以下のコマンドで生成しました。

rails new webpacker-react-example --webpack=react --skip-turbolinks --skip-coffee --skip-sprockets

Webpackerでアセットを管理する

Webpackでは、JavaScript以外の一般のアセット、たとえばPNGやSVGなどのファイルをバンドルに入れるこのが可能です。 そのためには、JavaScriptのソースから、アセットをモジュールとしてインポートする必要があります。

// react.svgをバンドルに追加する
import reactIcon from 'images/react.svg'

このようにすると images/react.svg が最終的な生成物に含まれることになります。 reactIcon には、デプロイ後の環境で画像を参照するためのパスが入ります。 import の構文自体はES2015+のものですが、画像ファイルなどを対象としてインポートできる機能は、Webpackの独自拡張になります。 こうして、バンドルが依存しているアセットをコードで明示的に表現するのがWebpack流のやりかたです。

本記事では、Railsアプリで使用するすべてのアセットをWebpackerで管理するため、通常のViewから参照する画像などについても、JavaScriptで依存関係を表明しておく必要があります。RailsのViewから使用するすべての画像について個別にインポートするのは現実的ではないので、Webpackの require.context という関数を使います。

require.context('images', true, /\.(png|jpg|jpeg|svg)$/)

本サンプルプログラムでは、Webpackerの source_pathapp/assets に変更しているため、このディレクトリにあるファイルは相対パスを使わないで参照ができます。2番目の引数は再帰的に検索することを意味します。従って、上記のコードで、app/assets/images 以下のすべての画像ファイルをpackに追加することを意味します。

rails-ujs

以前jquery-ujsと呼ばれていたものが、いまではjQuery依存を取り除かれ、 rails-ujs になりました。 Sprocketsを使用する場合はとくになにも設定しなくても有効化されていますが、本記事では、アセットパイプラインを使用しないため、JavaScriptのエントリポイントから明示的に import する必要があります。

import Rails from 'rails-ujs'

Rails.start()

これでフォーム処理中のsubmitボタン自動無効化などが有効になります。

CSRFトークンの取得

JavaScriptのCSRF保護機能を使うには、rails-ujsでCSRFトークンを取得して、リクエストヘッダに設定します。

import { csrfToken } from 'rails-ujs'

request
  .post('/todos.json')
  .set('X-CSRF-Token', csrfToken())

上記コードでは、XHR APIをラップした superagent を使っています。

なお、RailsのViewに csrf_meta_tags を挿入しておく必要があります。

<%= csrf_meta_tags %>

React

昨今のフロントエンド開発では、Virtual DOMなどの仕組みに基いた、宣言的にHTMLを記述できるViewライブラリ群が隆盛を極めています。 その中でもとりわけ人気があるのがfacebookの開発している React です。React自体はただのViewライブラリであり、APIも非常にシンプルでなにも難しいことはありません。

以下に、サンプルコードから、Reactのコンポーネント10定義の一例を挙げます。

import TodoItem from '../TodoItem'

function TodoList(props) {
  return (
    <table className="table">
      <thead>
        <tr>
          <th>Content</th>
          <th>Due date</th>
          <th />
        </tr>
      </thead>
      <tbody>{props.todos.map(id => <TodoItem key={id} id={id} />)}</tbody>
    </table>
  )
}

Reactのコンポーネント描画関数では、Propsと呼ばれる入力パラメータを表すオブジェクトを外部から受け取って、JSXというXMLライクな記法で記述される要素を返します。上記では、todosという配列を受け取って、その各要素を別のコンポーネントに変換しています。 JSXは、HTMLとほぼ互換性があるので、HTMLの知識がそのまま流用できます。11 与えられるPropsが変化すれば、それに応じて表示内容も変化します。

上記のコンポーネントでは、 <TodoItem> という大文字で始まる見慣れないタグが使われています。12これは、アプリケーションで独自に定義したコンポーネントです。HTML標準以外のファイル外部で定義されたタグは、必ずJavaScriptモジュールとして import する必要があります。Reactプログラミングでは、こうして独自に定義したコンポーネントを組み合わせてViewツリーを構築していきます。 また、見ての通りただのJavaScriptの関数なので、iffor などすべてのJavaScript構文を使えます。

エントリポイントは、以下のようになります。

// #todo-appの要素を検索し、その子要素としてAppコンポーネントをレンダリングする
ReactDOM.render(
  <App />,
  document.getElementById('todo-app')
)

これだけ見ると、どこからもPropsを注入していないし、Propsが与えられたとしても変化する余地がないのでインタラクティブなアプリを作れないと思われるかもしれません。どのように状態を扱うかについては、次節で説明します。

乱暴に言ってしまえば、Reactはただのテンプレートです。ただし、Virtual DOMのおかげで、複雑なViewをリアルタイムに書き換えても高速に描画されます。これとよく比較されるものとして、jQueryのようなユーティリティーでDOMの部分部分を手続き的に書き換える旧来の手法があります。 Reactベースのアプリ開発では、宣言的な言語で平易に記述できることや、モジュールシステムのおかげで依存関係が明確化されることで、格段にメンテナンス性が高まります。

参考リンク

FluxアーキテクチャとRedux

前節では紹介しませんでしたが、Reactにも状態を扱う機能はあります。これを使って、クリックなどのイベントに応じてなどインタラクティブに状態を変化させることも可能です。しかし、コンポーネント間でのデータの受け渡し方法は(基本的には)Propsしかないため、アプリの複雑な状態管理をこれだけで行うのは、不可能とは言わないまでも少々心許無いところです。

Reactの世界では、状態を管理するための手法として、 Flux というアーキテクチャが発展してきました。 Fluxライブラリにおいても、例によって激しい競争が行われましたが、これを生き残って今現在一強状態にあるのが Redux です。

Reduxでは、アプリのほぼすべての状態13をストアに格納します。 ストアは、言わば巨大なグローバル変数であり、それは基本的にはJSONシリアライズ可能なJavaScriptのオブジェクトです。14 Reduxでのデータの流れは次の図のようになります。

Redux

Reduxにおけるデータの流れ

アプリ内でのユーザーの操作は、アクションと呼ばれるプレーンなJavaScriptオブジェクトで表現されます。 アクションがコンポーネントから送出されると、Reducerと呼ばれる関数が呼ばれます。 これは、現在のストア状態とアクションを受けて、次のストア状態を返す関数です。 ストアの状態変更は、必ずこのReducerを経由します。 ストアと接続されたコンポーネントはその状態を監視しているので、Reducerによって変更された状態は、コンポーネントに通知されます。 このように、データの流れが一方向に循環することから、Fluxは、一方向データフローであると言われます。

// Reducerは、受け取ったアクションに応じて、新しいストア状態を返す。
// これによりストアが更新される。
function appReducer(state, action) {
  switch (action.type) {
    case actions.SELECT_ORDER:
      return {
        ...state,
        sortBy: action.payload.sortBy,
        sortOrder: action.payload.sortOrder,
      }
    case actions.TOGGLE_DONE_FILTER:
      return {
        ...state,
        doneFilter: !state.doneFilter,
      }
    // ... 中略
    default
      return state
  }
}

// react-reduxのconnect関数によって、コンポーネントとストアが接続される。
connect(
  // 1番目の引数でコンポーネントにストアの状態を渡し、
  (state) => ({
    sortBy: state.app.sortBy,
    sortOrder: state.app.sortOrder,
    doneFilter: state.app.doneFilter,
  }),

  // 2番目の引数でコンポーネントのコールバックを定義し、そこでアクションを送出する
  (dispatch) => ({
    onOrderChange(ev) {
      const [prop, order] = ev.currentTarget.value.split('-')
      dispatch({ type: SELECT_ORDER, payload: { sortBy, sortOrder } })
    },
    onDoneFilterChange() {
      dispatch({ type: TOGGLE_DONE_FILTER })
    },
  })
)(TodoConditions)

Reduxを使用することで次のようなメリットが得られます。

また、アプリ内で起きたイベントが、Actionのシーケンスとして表現されるため、DX向上にも活用できます。 たとえば、 Chrome拡張 を導入すれば、次のスクリーンショットのようにアクションのログをブラウザ内で見ることができます。

redux-devtools-extention

Chrome拡張でアクションログが確認できる

非同期の扱い

Reducerもコンポーネントもなんらかの値を受け取って、即時に値を返すただの関数であるため、 実は、前節までに紹介した枠組みのままでは、HTTPリクエストのような非同期な処理を実行する余地がありません。

Reduxでは、ミドルウェアと呼ばれる拡張機構が用意されており、非同期な処理はここで取り扱います。 非同期処理を扱うミドルウェアは、 redux-thunkredux-promiseredux-api-middleware などさまざまな ものがあり、しばしば論争の種になったりもしますが、筆者は redux-saga というライブラリを使用しています。

Redux with async

Reduxにおけるデータの流れ(非同期版)

redux-sagaでは、sagaと呼ばれる、reduxの通常の枠組みとは別の一種の外部環境を設け、 その中でアプリに関するすべての非同期な処理を扱います。 これは、Actionを受け取り、非同期処理を実行した結果として別のActionを送出する、 Aciton-Action変換として解釈できます。

Reduxの通常の枠組みをそのまま残しつつ、15自然に非同期を取り入れることができるというのが、 筆者がredux-sagaを採用する理由です。 redux-sagaに非常に高機能な非同期処理のためのユーティリティー群が含まれますが、典型的なユースケースではその中のごく一部があれば十分です。

// Todo項目追加時のsaga
// ADD_TODO_REQUESTEDアクションを受け取り、APiを呼び出して、
// ADD_TODO_RECEIVEDアクションを送出する
function* addTodoRequested(action: actions.AddTodoRequested) {
  try {
    const { requestId, item: { content, dueDate } } = action.payload
    const item = yield call(webApi.addTodo, content, dueDate, false)
    yield put(actions.addTodoReceived({ requestId, item }))
  } catch (error) {
    yield put(
      actions.addTodoReceived(
        new IdentifiableError(SINGLETON_ID, error.message)
      )
    )
  }
}

sagaはジェネレータ関数で定義されるため非同期処理を逐次処理のように記述できます。 これもsagaの大きな魅力です。

RailsからReduxへのデータ受け渡し

RailsからReact Reduxアプリにデータを受け渡すには、Viewの中でデータ格納用の要素を用意し、属性としてJSON化した文字列を格納します。

<%= content_tag :div,
  id: 'todos-data',
  data: {
    todos: @todos
  }.to_json do %>
<% end %>

クライアント側からは、この文字列を取り出してパースした上で使用します。 Reduxの ストア作成関数 には、2番目の引数として初期値を指定できるため、これで、 アプリの初期状態をサーバー側から制御できます。

function getPreloadedState() {
  const node = document.getElementById('todos-data')!
  return convert(JSON.parse(node.getAttribute('data')))
}

document.addEventListener('DOMContentLoaded', () => {
  // createAppStoreは、ストア作成用にアプリ内で定義しているヘルパー
  store = createAppStore(getPreloadedState())
  render(App)
})

参考リンク

CSS Modules

コンポーネント指向の昨今のフロントエンドアプリ開発においては、CSSにも変革が起きています。 そのひとつが、 CSS Modules です。 CSSでは、しばしばセレクタの詳細度が問題になり、 スタイル設計において問題を起こさないための技法として、 BEM のような技法が発展してきました。

CSS Modulesでは、コンポーネントごとに専用のCSSファイルを定義します。 そこでは、ファイル(モジュール)が固有の名前空間を持つため、クラス名の衝突が原理的に発生しません。 そのため、BEMのような技法を使わずとも自然と詳細度が1になります。

/* styles.scss */
.logo {
  width: 100px;
}
import styles from './styles.scss'

function App({ todos }) {
  return (
    <div>
      <img className={styles.logo} src={reactIcon} alt="react icon" />
      <TodoConditions />
      <TodoList todos={todos} />
      <TodoAddForm />
    </div>
  )
}

スタイルシートがコンポーネントに属すことは import によってコードで明示 されます。 例えば、上記コードの .logo クラスは、CSS Modulesでなければ、.App .logo のように入れ子のセレクタになっていたかもしれません。 しかし、これでは詳細度が2に上がってしまい柔軟性が下がります。BEMライクであれば、 .App__logo のようになるのでしょうが、やや煩雑です。 我々にはCSS Modulesがあるので、いまやクラス名は、安全なままに、短く明確です。

なお、CSS Modulesとは別に、 CSSinJS という、スタイルをJavaScriptのコードで直接記述するアプローチもあります。

グローバルなクラスとBootstrap

一方で、通常のRailsのViewからCSS Modulesを利用することはできません。 そのためReactコンポーネントと一対一で定義するスタイルシート以外に、グローバルなスタイルシートが必要になります。

サンプルコードでは、Rails View用のpackをひとつ用意し、そこからグローバルに使用するスタイルシートを取り込んでいます。

@import '~bootstrap/dist/css/bootstrap';
@import '~bootstrap/dist/css/bootstrap-theme';
@import '~stylesheets/scaffold';
@import '~stylesheets/react-datetime';

.check {
  width: 1em;
}

.logo {
  width: 100px;
}

このようなスタイルシートを含むpackをレイアウトファイルで取り込んでいます。 Bootstrapもインポートしているため、アプリケーション全体で利用できます。

<!DOCTYPE html>
<html>
  <head>
    <title>WebpakcerReactExampl</title>
    <%= csrf_meta_tags %>

    <%# 'app'は グローバルアセットのためのpack %>
    <%= javascript_pack_tag 'app' %>
    <%= stylesheet_pack_tag 'app' %>
    <%= yield :head %>
  </head>

  <body>
    <div class="container">
      <%= yield %>
    </div>
  </body>
</html>

グローバルなアセットはクライアントアプリとも共有されるため、 Reactアプリからも同様にBootstrapのクラスを利用できます。

function EditButton({ className = '', disabled = false, onClick }) {
  return (
    <button
      type="button"
      className={classNames('btn btn-default btn-xs', className)}
      disabled={disabled}
      aria-label="Edit"
      onClick={onClick}
    >
      <span className="glyphicon glyphicon-edit" aria-hidden="true" />
    </button>
  )
}

CSS ModulesとグローバルCSSを両立するためのWebpack設定

Webpackerの提供するデフォルトの設定では、CSS Modulesは有効にはなっていません。 ドキュメント でCSS Modulesを有効化する方法は紹介されていますが、.scss 拡張子に対するローダーは1つしかなく、その設定を変更してしまっているため、今度はグローバルなCSSが使えなくなります。

本記事のサンプルアプリでは、スタイルシート用のローダを2つ用意した上で、node_modulesapp/assets/stylesheets に置かれているスタイルシートはグローバル、それ以外はCSS Modulesとして、ディレクトリによって設定を分けています。

const globalStylePaths = [
  resolve('app/assets/stylesheets'),
  resolve('node_modules')
]

function enableCssModules(cssLoader) {
  const cssModuleOptions = {
    modules: true,
    sourceMap: true,
    localIdentName: '[name]__[local]___[hash:base64:5]'
  }
  cssLoader.options = merge(cssLoader.options, cssModuleOptions)
}

// デフォルトのstyleローダーは、app/assets/stylesheetsとnode_modulesに限定
const styleLoader = environment.loaders.get('style')
styleLoader.include = globalStylePaths

// styleローダーをコピーしつつ、上記で限定された以外のパスは、CSS Modulesを有効化
delete require.cache[require.resolve('@rails/webpacker/package/loaders/style')]
const moduleStyleLoader = require('@rails/webpacker/package/loaders/style')
moduleStyleLoader.exclude = globalStylePaths
enableCssModules(moduleStyleLoader.use.find(el => el.loader === 'css-loader'))
environment.loaders.set('moduleStyle', moduleStyleLoader)

参考リンク

TypeScript

近年のJavaScript界では、静的型チェックの実施がますます普通のことになってきています。 取り得る選択肢は2つ、TypeScriptと flowtype です。 どちらも素のJavaScriptとほぼ機能的な互換を保ちつつ、静的型付けのために文法を拡張しています。 型システム自体もかかなり似ており、どちらも構造的部分型がベースになっています。

TypeScriptは、ES5などの下位バージョンへのトランスパイラも兼ねていますが、 flowtypeは、純粋に型付けのためのツールという立ち位置になっています。 また、どちらも第三者が作った型付けされていないモジュールに、後付けで型を定義できる仕組みを持っており、 そのための中央リポジトリを持っている点も同じです。

TypeScriptとflowtypeどちらにするかは、非常に悩ましい選択なのですが、 redux-sagaなどの依存しているライブラリが、公式に型定義を提供しているという理由から、 TypeScriptを選択しました。16

型チェックから受けられる恩恵は非常に大きなもので、コードが満たすべき性質を記述することで、かなりのプログラミングエラーを未然に防いでくれます。個人的に、型チェックなしの環境でプログラミングしていると、Reducerでのプログラミングエラーがしばしば発生し、デバッグに時間を取られていたのですが、これがかなり改善されたと思います。以下は型付けされたReducerの抜粋です。

interface TodoMap {
  readonly [id: number]: Readonly<Todo>
}

interface TodosState {
  readonly byId: TodoMap
  readonly ids: number[]
}

function addTodoReceived(state: TodosState, action: actions.AddTodoReceived) {
  if (action.payload instanceof Error) {
    return state
  }

  const newTodo = action.payload.item

  return {
    ...state,
    byId: {
      ...state.byId,
      [newTodo.id]: newTodo,
    },
    ids: [...state.ids, newTodo.id],
  }
}

function todosReducer(
  state: TodosState = initialTodosState,
  action: actions.Action
): TodosState {
  switch (action.type) {
    case actions.ADD_TODO_RECEIVED:
      return addTodoReceived(state, action)
    // ... 中略
    default:
      return state
  }
}

引数に型指定(: の右側)が付いている点に注意してください。 これにより、たとえば state に存在しないプロパティーを参照しようとすると、コンパイルエラーになります。

また、TodoState でストアの形が型で定義されているため、 あらかじめ定義されたストアの形状と異なる状態を作ってしまうことが原理的に発生しなくなります。

ReduxのReducerは純粋関数である必要があります。もし、新しい状態オブジェクトを返すのではなく、state 引数を直接書き換えて返してしまうと、コンポーネントの描画が更新されないという不具合が起きます。 上記定義では、ストアのプロパティーに readonly 修飾子が付いているため、そもそも書き換えることができません。

小粒なJavaScript(Sprinkles)

現状、JavaScriptをSprocketsでビルドする場合、Webpackを通らないため、import ステートメントは使えませんし、TypeScriptを使うこともできません。 本記事の方式であれば、すべてのJavaScriptはWebpackを通るため、Viewの中で使用するいわゆる小粒なJavaScriptでさえも、モダン環境の恩恵をフルに受けられます。以下はその例です。TypeScriptで記述され、import ステートメントを使用しています。

// セレクトボックスの状態に応じてページURLを変更する小粒なJavaScript
import * as queryString from 'query-string'

function prepereSelectElems(): void {
  const doms = document.querySelectorAll(
    'select[data-change-query]'
  ) as NodeListOf<HTMLSelectElement>
  const query = queryString.parse(location.search)

  for (const select of doms) {
    if (query.sort_by) {
      select.value = query.sort_by
    }
    select.addEventListener('change', () => {
      query.sort_by = select.value
      location.search = `?${queryString.stringify(query)}`
    })
  }
}

document.addEventListener('DOMContentLoaded', () => {
  prepereSelectElems()
})

参考リンク

BabelとES2015+

ES5へのトランスパイルにTypeScriptを使用しない場合の選択肢が、Babelを使ったトランスパイルです。 こちらはプラグイン形式になっており、TypeScriptよりも幅広いカスタマイズが可能です。

筆者は、TypeScriptをES5へのトランスパイラとしては使わず単なる型チェッカーとして使っています。 そのため、TypeScriptにはECMAScriptの最新仕様でコードを出力させ、そこからさらにBabelでのトランスパイルを行います。

構成の複雑さが増すというデメリットがある一方、このようにすることで、次のようなメリットが受けられます:

Babelを通すかどうかについては、非常に迷ったのですが、将来的にもBabelを通したほうが多くのメリットを得られるであろうと考え、こちらに賭けることにしました。ただし、実際には設定の変更だけでソースコードはそのままでいいはずなので、後でBabelをはずすのは、おそらく簡単なはずです。

なお、Webpackerのデフォルト設定では、 babel-polyfillが組込まれていない ため、エントリーポイントで、

import 'babel-polyfill'

する必要があります。

参考リンク

Storybook

Storybook は、プロトタイピング、ビジュアルTDD、デザイナーとの協業など、さまざまな可能性を秘めたツールです。 これをを使うと、Reactコンポーネントを状態ごとにカタログとして一覧表示できます。

Storybook

Storybookによるコンポーネントの表示

Storybookでは、コンポーネントごとにstoriesと呼ばれる一連の状態定義を行います。 これは、以下のようにさながらユニットテストのような見た目をした21コードになっています。

storiesOf('TodoAddForm', module)
  .add('typical', () => (
    <TodoAddForm
      addTodoRequest={succeededRequest}
      onAddTodo={action('added')}
    />
  ))
  .add('while adding', () => (
    <TodoAddForm addTodoRequest={loadingRequest} onAddTodo={action('added')} />
  ))
  .add('adding error', () => (
    <TodoAddForm addTodoRequest={errorRequest} onAddTodo={action('added')} />
  ))

ユニットテスト

JavaScriptでのテストフレームワークは、MochaJasmineAvaJest などの選択肢があります。 22

個人的には、アーキテクチャが洗練されていて並列実行に対応していたり、後述のpower-assertベース でアサーションAPIが簡単なAvaを使いたかったのですが、 残念ながら現段階では トランスパイラのサポートがまだ弱く、 TypeScriptのコードをテストするのは厳しそうだっったため、 Mochaを選択しました。

power-assert

power-assert を使うと、Node.jsの標準アサーションAPIを使いつつ、テスト失敗時に結果をわかりやすく表示できます。 アサーションAPIは厳選されており、多数の細分化されたアサーションAPIの使い分けに頭を悩ますことなく、テスト対象という本質にフォーカスできます。

power-assert

power-assertを使えば式のどこが期待と異なるのか一目瞭然

サンプルコードでは、Mocha上でTypeScriptのコードをテストするために、 espower-typescript を使いました。 これを使うと、テスト時にテストコードとテスト対象両方の自動的なトランスパイルが可能になります。 なお、ブラウザ向けビルドと異なり、テストコード自体は、Babelを通さずに直接実行されるため、 TypeScriptの設定ファイルをターゲットに応じて分けています。

Enzyme

Reactコンポーネントのユニットテストには Enzyme を使用します。

describe('<TodoAddForm />', () => {
  describe('display errors', () => {
    context('when request failed', () => {
      it('should render error message', () => {
        const request = { requesting: false, error: new Error('error') }
        const wrapper = enzyme.shallow(
          <TodoAddForm addTodoRequest={request} onAddTodo={_.noop} />
        )
        assert(wrapper.find('.error').exists())
      })
    })
  })
})

このようにテスト内にJSXでコンポーネントを直接記述する形になります。 Viewのテストは壊れやすくなりがちで難しい面がありますが、ReactでTDDを実践したい人などには 便利かもしれません。

参考リンク

Prettier

Prettier はコードの自動フォーマッタです。TypeScriptやSCSSにも対応しています。 自動的にコーディングにある程度の一貫性が得られるのでたいへんありがたいです。

TSLint

TSLint は、TypeScript用の静的解析ツールです。 Prettierと重複する部分がありますが、こちらはコーディングスタイル以外にもコーディングエラーを発見してくれたりします。 TypeScriptとPretteirを導入している環境だと相対的な重要度は低いと言えますが、コストゼロでメリットが得られるので導入しています。

webpack-bundle-analyzer

冒頭でも書きましたが、webpackを通してバンドル化すると、ちょっとしたアプリでもすぐにJavaScriptが数MBを越えます。 ファイルサイズは、アプリのロード時間に直結するため重要です。 webpacker-bundle-analyzer というプラグインを使えば、 以下の画像のようにバンドルサイズの内訳をグラフィカルに表示できるので、最適化のための方針が立てやすくなります。

webpacker-bundle-analyzer

webpacker-bundle-analyzerによる解析結果

まとめ

この記事では、まず、Webpacker 3を使って構築したサンプルアプリを元にしつつ、Webpackerの基本的な機能と使い方を説明しました。 同時に、Sprocketsを使用しなくともRailsアプリが成立することを説明し、そのための設定を紹介しました。

後半では、React Reduxをはじめとして、モダンフロントエンド開発で使用されている便利なライブラリやツールを簡単に紹介しました。 また、CSS Modulesの組込についてはとくに注意が必要なため、設定方法を紹介しました。Babelの節では、TypeScriptとBabelを併用することで得られるささいなメリットについても説明しました。

  1. 記事内で引用しているコードは、型アノテーションを省略するなど、適宜省略した形で抜粋しています。
  2. ほぼscaffoldingが生成したものそのままです
  3. ただし、現状のUglifyJSでは、ES2015+をサポートしていないため実質的に無効化される。将来的にはUglifyJSが改善されて有効化される見込み。
  4. とは言え、紹介しているライブラリ・ツールはどれもJavaScript界で一定の評価を得ているポピュラーなものばかりです。
  5. 筆者の案件では使わないため
  6. 以前であれば、Sprocketsを使わずにWebpackのみでアセットを管理するためには、 ヘルパなどを自前で実装する必要がありましたが、 いまではWebpackerのみで事足ります。
  7. ECMAScriptの仕様は、2015以降毎年更新されています。それらを総称して2015+と呼んだりもします。また、2015以前の仕様であるES5と対比する意味で、ES6,ES7,ES8などと呼ばれたりもします。
  8. Node.jsのパッケージ管理ツール、及びその中央リポジトリ。Rubyで言うところのRubyGems。JavaScriptのエコシステムはnpmを要として発展しています。
  9. Webpackerのディレクトリが app/javascript/ であることには、明確な意図 が込められているようです。
  10. ReactではViewの構成単位をコンポーネントと呼びます。
  11. 例にもある用に、一部、 class のようなJavaScriptの予約語は使えないため、 className のように別のキーワードに置き換えられています。
  12. DOM標準以外のコンポーネントは大文字で始まる必要があります。
  13. コンポーネント自身が状態を管理することもあるが、基本はストアに格納する
  14. 実際にはJSONシリアライズできないオブジェクトを格納することも可能で、それが必要な場合もあるが(FileやBlobなど)、そうするといくつかのReduxの恩恵を受けられなくなる
  15. redux-thunkやredux-promiseでは、アクションの定義を拡張します。これらを使った場合、Actionはもはや、ただのオブジェクトではありません。
  16. redux-sagaの型定義も中央リポジトリにあるにはあるのですが、バージョンアップに追随できていないのが現状です。また、redux-sagaとflowtypeについての筆者の理解度が低いために、自分で型定義を書くことはあきらめました。
  17. ブラウザ実装の足りない部分を補うために、その実装を自分のコードに含めること
  18. 未使用のコードを除去してバンドルサイズを縮小すること
  19. lodashは、JavaScript使いに人気のあるユーティリティーライブラリです。すべての関数をバンドルに入れると、かなりのサイズになります。
  20. ただし、Webpack 4からはtree shakingが強化されてTypeScriptでも同様の効果を得られる模様
  21. 入力値のパターンを列挙しつつ対象コードを実行するという意味で
  22. 筆者もあまり詳しいわけではないので、たぶん他にもいろいろあると思います。

コメント