Skip to content
This repository has been archived by the owner on Mar 13, 2023. It is now read-only.

Latest commit

 

History

History
534 lines (403 loc) · 26 KB

javascript-on-rails.md

File metadata and controls

534 lines (403 loc) · 26 KB

RailsベースのJavaScript開発ガイドライン

索引

はじめに

当ドキュメントは、主に Rails エンジニアに向けた、Rails 内で JavaScript による機能開発を行うためのガイドラインです。

JavaScript や CoffeeScript コードの差分を含むコミットを行う方は、 先頭セクションの JavaScript を使った機能の開発手順 を読み、 可能な限りその手順を守ってください。
次セクションの FAQ は、困ったことがあったら参照する程度で構いません。

ここに記載されていない状況に遭遇して困った場合は、気軽に相談して下さい。

-- :memo: 当ドキュメントにおける "JavaScript" という単語は、しばしば CoffeeScript をも含めることがあります。

JavaScriptを使った機能の開発手順

0. 前提

以下の方法は、主に新規開発を想定した手順を記しています。

新規ではない場合、既存部分の影響で記載通りに設置できない場合があると思います。
その場合は、可能な範囲で対応をお願いします。

1. エントリポイントの設置

例えば、Rails 側に UsersController#index というアクションがあったとします。

そのアクション用に JS のエントリポイントを設置したい場合、 まずは app/assets/javascripts/users/index.js.coffee へ以下の様な CoffeeScript ファイルを設置します。

# MFApp.controller と MFApp.action に
# Rails 側の controller_path と action_name 情報が格納されているので、
# その値で if 文を書く。
if MFApp.controller == 'users' && MFApp.action == 'index'
  $ ->
    # ビジネスロジックはココから書く
    $('.js-foo').on 'click', ->

turbolinks を On にしている場合は以下です。

$ ->
  if MFApp.controller == 'users' && MFApp.action == 'index'
    # do stuff

置きましたか? お疲れ様です。

さて、ブラウザから動かしてみると、今設置したファイルは実行されているでしょうか?
もし不明なら、if ブロックの 1 行目やファイル先頭行に console.log を書くなどして検証するといいでしょう。

ちゃんと動いているなら、ここで完了です!

もし動いていないなら、そのファイルはまだ読み込まれていないので設定が必要です。
次の項目を参照願います。

--

  • 📝 MFApp グローバル変数については、後に FAQ セクションで解説します。現在はその変数が定義されているのを前提として読み進めて下さい。
  • 📝 以降の本ガイドラインの文章では、turbolinks は Off である前提で記述します。

2. エントリポイントの読み込み

以下の手順で、先程追加したファイルを読み込む設定を行います。

  • app/assets/javascripts/application.js を開く
  • //= require 'users/index' の行を追加する
    • 追加する場所は、同じようにエントリポイントのファイル読み込みが並んでいる箇所です
      • 依存関係はディレクトリの構造に従います。同階層間の並び順に決まりはありません
      • require_self が使われている場合は、必ずそれよりも下の位置にして下さい

-- また、もし、エントリポイントからモジュールを別ファイルへ分離するという設計を行った場合、ファイル間に依存関係が生じることになります。 その場合は、読み込み順が依存関係を解決するように、直列に配置してください。

//= require 'users/UserModel'
//= require 'users/index'

これで読み込みは完了し、「コードを書けば動く」状態になったと思います。
次は実装内容についての話です。

--

  • require_tree はファイルの読み込み順が Sprokets の実装依存になるため、ファイル間に依存関係が生じる場合は使ってはいけません
    • 既に使われていた場合は、依存関係が生じた時点で require による読み込みへ書き直して下さい。
      • なお、本家の README でも、同様に require で分解する解決法が推奨されています。
  • ❗ テンプレートに javascript_include_tag を書いて直接読み込む手段は、禁止です。

3. セキュリティを担保する

実装内容について・・・とは言っても、基本的には自由です。
ただし、最低限セキュリティに配慮する必要があり、以下の例示した実装に関しては注意願います。

$el.html()でXSSを発生させない

例えば、以下のコードは、いわゆる HTML 特殊文字のエスケープを行っていないため、XSS を成立させる危険性があります。

foo = 'サーバから取得した文字列'
$el.html("<div>#{ foo }</div>")

Rails 側では Slim や Haml などのテンプレーティングエンジンがその問題を暗黙的に処理してくれますが、今のところ JS 側にはそういった共通の仕組みは用意していません。
そのため、Rails でいうところの html_escape のようなアプローチにより、テンプレート変数を個別に解決する必要があります。

foo = 'サーバから取得した文字列'
$el.html("<div>#{ エスケープする関数(foo) }</div>")

「エスケープする関数」には、大体のプロジェクトには LodashUnderscore.js がインストールされているので、_.escape を使って下さい。

-- なお、Rails 側で生成した安全な HTML 文字列をそのまま使うようなパターンの場合は、エスケープの責務は Rails 側にあるので、JS 側では対応しない方が良いです。

htmlString = '<div>例えば HTML を生成する Web-API を叩いた結果など</div>'
$el.html(htmlString)

テンプレート変数をJS変数として展開する際はto_json.html_safeを使う

まず、良い例です。
to_json で JS ソースコードのサブセットである JSON 形式の文字列に変換し、JS リテラルとして埋め込みます。

javascript:
  var foo = #{foo.to_json.html_safe};
  var obj = {
    foo: #{foo.to_json.html_safe}
  };
  var ary = [#{foo.to_json.html_safe}];

-- 悪い例 その1 です。
foo に JS 文字列リテラルの一部であるダブルクォートが含まれていた場合、構文エラーになります。

javascript:
  var foo = "#{foo.html_safe}";

-- 悪い例 その2 です。
ダブルクォートやシングルクォートが含まれていても構文エラーを発生しなくなりましたが、</script> という文字列により問題が発生する可能性が残ります。

javascript:
  var foo = "#{escape_javascript(foo.html_safe)}";
  var foo2 = "#{j(foo.html_safe)}";

📝 </script> 問題については、お手数ですが コチラ の外部資料を確認して下さい。

-- 悪い例 その3 です。
テンプレーティングエンジン側で HTML 特殊文字の変換を行うことで、結果的にクォートの問題や </script> 問題は回避されました。

javascript:
  var foo = "#{foo}";

しかし、変換後の値は、おそらくは JS 側が期待した値では無いでしょう。

javascript:
  var foo = "#{'M&M'}";
  console.log(foo);  // -> "M&amp;M"

4. 以上

守らねばいけないルールは、以上です。

FAQ

MFApp グローバル変数はどのように定義されているのですか?

ビューのレイアウトファイルの先頭などで、以下の JS コードを実行して定義しています。

javascript:
  window.MFApp = {
    controller: #{params[:controller].to_json.html_safe},
    action: #{params[:action].to_json.html_safe}
  };

Rails 側の ApplicationController の影響下に存在することを想定しており、 その他の共通でサーバ側から JS 側へ渡したい値を含めることも許容しています。

何故エントリポイントにアクション単位の if 文を設定するのですか?

この箇所が何故付与されているかの解説です。

if MFApp.controller == 'users' && MFApp.action == 'index'

まず Rails は、require で読み込んだ全ての JS や Coffee のソースコードを変換しつつ結合し、1 つの application.js という巨大な JS ファイルへ加工して、それを全画面で配信します。
つまり、どの画面でも全ての JS ソースコードを読み込む、という仕組みを採用しています。
そのため、手なりで JS コードを書くと全画面で実行されてしまいます。

それを、Rails 側のエントリポイントである「アクション」という単位で管理できるようにすることで、 一回の URL アクセスによって実行されるコード範囲や副作用の影響を絞ることを目的としています。

-- :memo: 同じ問題について、body タグの class 属性で指定するなどで解決する方法もあります。 その方法で解決しなかったのは以下の点で優れていると判断したからです。

  • DOM に依存しないので、ユニットテストを行う際などに、DOM 全体をモックする必要が無くなる
  • 定義するタイミングが DOM の展開を待たないため早い。$() 実行前に存在できる

$ -> とは何ですか?

呪文のように記述されている$ -> が何かという点の解説です。

if MFApp.controller == 'users' && MFApp.action == 'index'
  $ ->

まず、$ -> を JavaScript に訳すとこうなります。

// コールバック関数を登録している
$(function() {
});

この $(callback) は jQuery が提供する機能で、「callback に登録した処理を、全てのHTMLページがロードされたら実行する」という予約をしています。
つまり、callbackはこの時は実行されません

では、いつ実行されるのかと言うと、あくまでイメージですが HTML 描画後の以下の位置で実行される感じです。

<html>
<body>
  <main>My Awesome Contents!</main>

  <script>
    // HTML 描画後
    callback();
  </script>
</body>
</html>

もし、$ -> の下に入れずにこう書いてみた場合、

if MFApp.controller == 'users' && MFApp.action == 'index'
  # $ -> の下に入れずに直接ロジックを書く
  $('main').on 'click', ->
    # do stuff

先程のイメージを引き合いにすると、HTML 描画前に実行されることになり、

<html>
<head>
  <script>
    // HTML 描画前
    callback();
  </script>
</head>
<body>
  <main>My Awesome Contents!</main>
</body>
</html>

上記例の場合、ランタイムエラーこそ発生しませんが、main へのイベントは付与されません。

エントリポイントの書式は固定ですか?

いいえ。

上記までの内容を理解しているなら、個人の裁量で自由に定義できます。

例えば、このようなアレンジも可です。

onReady = ->
  # do stuff

if MFApp.controller == 'users' && MFApp.action == 'index'
  $(onReady);

gem化されていないサードパーティ製ライブラリを配置する方法は?

プロジェクトによって異なりますが、

  • app/assets/javascripts/libraries
  • app/assets/javascripts/vendor

などの所定の位置にそのファイルをコピーし、application.jsrequire を定義して下さい。

CSS や画像がセットの場合は、それぞれ app/assets/stylesheetsapp/assets/images に振り分けて下さい。

内製のものもサードパーティ製ライブラリとして置くことはできますか?

内製や自作ライブラリであっても、プロジェクトに依存せずに動作するものならここに置いても良いです。

ただし、「サードパーティ製ライブラリ」というものに対しては、 「特定環境に依存しない仕様を持っている」という以外に「高い品質である」ことを期待するものでもあります。

「高い品質」の正確な定義はありませんが、以下にとりあえずは思いついた範囲で要件を例示列挙しておきます。

  • テストを書くなどの、安定性を担保するための何らか施策を別途行っている
  • モジュール読み込み時には、可能な限り実行環境に影響を与えない。例えば以下のようなこと、
    • グローバルな名前空間への影響は、1 つの変数を定義するのみに留める
    • setTimeoutsetInterval などのタイマーを実行しない
    • DOM を操作しない
    • 外部通信をしない
    • alert, confirm, prompt を実行しない
  • 依存する他ライブラリがある場合は、例外で明示しつつ終了させたり、READMEやコメントなどのドキュメントで明示する

コントローラをまたぐ共通処理を定義したい

コントローラをまたぐ複数のエントリポイントから使用する共通処理を定義する手順です。

-- 例えば、こういうライブラリを作りたかったとします。

emojiManager =
  searchEmoji: (id) ->
    # do stuff

-- ファイルは shared 以下に配置します。
今回の例だと、app/assets/javascripts/shared/emoji-manager.js.coffee になるでしょう。

そして、{プロジェクト別のグローバル変数}.Shared 以下に追加するコードを付与します。

emojiManager =
  searchEmoji: (id) ->
    # do stuff

# 追加する
MyApp.Shared.emojiManager = emojiManager

-- それを各エントリポイント内から呼び出す場合は、この様に記述します。

MyApp.Shared.emojiManager.searchEmoji('ok_woman')

# import 風に、ファイル先頭などで別変数に移動しても良いです。
emojiManager = MyApp.Shared.emojiManager
emojiManager.searchEmoji('ok_woman')

-- サードパーティ製ライブラリとして配置する場合との違いは、「プロジェクト専用であること」を前提にできることです。
具体的には、以下のような点が変わります。

  • MFApp グローバル変数や、上記例だと MyApp と書いたプロジェクト用グローバル変数に依存できる
  • 特定の URL や、特定の HTML の出力内容に依存できる

-- :memo: プロジェクト別のグローバル変数は、プロジェクト毎に application.js の先頭部分で、概ね以下のようなシンプルな定義で生成されています。

window.MyApp =
  Controllers: {}
  Shared: {}

あるコントローラ内でのみ使う共通処理を定義したい

require を使って個別にファイルを読み込み、コントローラで共通で使うファイルを先に読み込むことで表現することができます。

以下「users コントローラ全体で共有する UserManager というクラスを定義したい」というストーリーで、例を書きます。

application.js:

//= require users/UserManager
//= require users/index

users/UserManager.js.coffee:

class UserManager
  foo: ->
  bar: ->

# CaApp.Controllers.コントローラ名 に名前空間を作る
#
# 独立しているモジュール同士の読み込み順を自由にしたいなら
# ?= 演算子(Rubyの ||= 相当)で代入してもいい
CaApp.Controllers.users = {}
CaApp.Controllers.users.UserManager = UserManager

users/index.js.coffee:

if MFApp.controller == 'users' && MFApp.action == 'index'
  $ ->
    userManager = new CaApp.Controllers.users.UserManager()
    # do stuff

new/create と edit/update の組み合わせ

1 アクションに対する 1 エントリポイントルールの例外として、 new アクション用の new.jscreate アクションでも使うこと、また edit アクション用の edit.jsupdate アクションでも使うこと、これらは許容します。

例えば、以下のような条件分岐は許可されます。

users/new.js:

if MFApp.controller == 'users' && (MFApp.action == 'new' || MFApp.action == 'create')

users/edit.js:

if MFApp.controller == 'users' && (MFApp.action == 'edit' || MFApp.action == 'update')

理由は、Rails の resources メソッドを使ったルーティング設計を行う場合に、それらが同様の画面を意味することが良くあるためです。

javascript_include_tag を使ってはいけませんか?

限りなく禁止に近い非推奨です。可能な限り使わないで下さい。

javascript_include_tag が非推奨なのは何故ですか?

一番の理由は、「1 枚のファイルにする以外にモジュール同士の依存関係を管理する方法が無いから」というものです。

例えば、A モジュールに依存する B モジュールを定義したい場合に、ページ別に A が存在したりしなかったりすると困ります。 だから、常に A が存在する前提にしてしまおうという意図です。

Ruby は勿論、その他の多くのプログラミング言語(Node.js 含む)では、モジュール管理機構を内包しています。
しかし、ブラウザ内の JavaScript 実行環境には現状は存在しないため、このような歪な対応が必要になってしまいます。

ちなみに、browserifywebpack などのいわゆる「モダン」な JS 開発手法を介す場合でも、最終的に「全ページで 1 枚」という結論はよく見ます。 (いや、そこに至るまでは段違いなのですが)

ただし、圧縮しても数MBあるようなライブラリや、読み込み時点で副作用があるサードパーティ製ライブラリを使う場合など、別段の事情がある場合はこの限りではありません。

あるページでRailsからJSへ値を渡したい

描画内で、このように 1 つのグローバル変数へ格納して渡したり、

javascript:
  window.templareVars = {
    foopoints: #{@foo.points.to_json.html_safe},
    bars: #{@bars.to_json.html_safe}
  };

または、Rails の hidden_field_tag などを使い DOM を介して渡して下さい。

既にJavaScriptで書いているコードはCoffeeScriptに直すべきですか?

いいえ、わざわざそのためだけに書き直す必要はありません。

ただし、.js だと以下のようにグローバルに変数を撒き散らしてしまいがちな点は認識しておいて下さい。

# この foo のスコープは、このファイル内のみ
foo = 1
// この foo のスコープはグローバル変数
var foo = 1;
// こう書いているのと同等
window.foo = 1;

なお、もし後者を簡単に解決したい場合は、以下のように書きます。

(function() {
  var foo = 1;
})();

class @FooClass と書くコードをよく見るのですが

このコードは、

class @FooClass

概ねこのような JS を書いているのと同じです。

// class が function になっている点は、主題から外れるのでスルーして下さい
window.FooClass = function() {};

Rails デフォルト設定の CoffeeScript は、ファイル単位で閉じた変数スコープを生成します。
その状況下でファイル間連携をするために、グローバルスコープにベタ置きしているだけです。

つまり、保守性に難があるコードで、本ガイドライン下では禁止にあたる書き方です。
おそらくは、見た目だけがスマートという理由で広まったのでは無いでしょうか?(要出典)

DOM要素の有無で条件分岐をしないのは何故ですか?

後で書く

何故テンプレートではなくアクションと1対1なのですか?

後で書く

License

CC-BY 3.0