1. Qiita
  2. 投稿
  3. Ruby

Sprockets再考 モダンなJSのエコシステムとRailsのより良い関係を探す

  • 296
    いいね
  • 0
    コメント
この記事は最終更新日から1年以上が経過しています。

すいません。締切守れませんでした…。
やっぱ、java-jaの忘年会の翌日は辛い…。

はじめに

Webシステムを開発していると切っても切れないのがJavaScriptです。
Railsはかなり早い時期からalt-JSや結合、minify等を組み込めるようにフレームワークにそれを取り入れてきました。
それを支えているのがRails3.1から導入されたsprocketsです。

それに伴なってJSのライブラリをどうやって管理するかという点について、独自の路線を取ることになりました。
JSのライブラリを同梱したgemパッケージにラップしてrubygemsとして管理する方法です。
ある程度は上手くいっていたし、今もその流れは続いているんですが、時々問題になることもあります。
例えばメンテナの対応時期がズレてて古いバージョンのままだったり、似たようなgemが乱立してややこしくなったり。(backbone-railsとかrails-backboneとかbackbone-on-railsとか)

そうこうしてる内にNodeが安定してきてJSのエコシステムが整備されてきました。
ブラウザ向けのパッケージ管理システムとしてbowerやcomponent等が使われるようになり、JS単独でライブラリの管理が出来るようになってきました。
すると、そもそもJS側で最新のライブラリをちゃんと管理できるんだから、わざわざgemのメンテに依存しなくていいんでは?という気がしてきます。

そこで、bowerとRailsを組み合わせることを考えるようになります。例えば以下のような手段があります。

  • 直接bowerで管理してディレクトリをSprocketsの管理パスとする。
  • bower-railsを利用する
  • rails-assets.orgを利用する

rails-assets.orgについては以前私が書いた bowerパッケージをbundlerで管理するRails Assetsを使ってみた - Qiita という記事があります。

加えて、最近JS界隈はalt-JSとビルドツールが非常に充実してきたため、今までSprocketsが担っていたことをほぼJSの世界だけで代替できるのではないかという状況になっています。
そうすると、SprocketsというRailsに特化したものを前提にしなくても、もうちょっと他の言語で仕事をしているエンジニアと共通の知識でJSが管理できる方が良いかもしれません。

というわけで、前置きがえらく長くなりましたが、JSのエコシステムとRailsがより良く付き合うために、Sprocketsをどうするか考えてみたいと思います。

そもそもSprocketsの役割とは

Sprocketsが何をやっているかについては、パーフェクトRuby on Railsという本に色々書かれてまして(というか私が書いた所なんですが)、それを読んでください。
と言いたい所ですが、それだと不親切過ぎるので簡単に説明しておきます。

  • JSファイルごとの依存関係の管理
  • alt-JSやJSTのコンパイル
  • JSやCSSの結合、minify

ざっくりこんな所です。
それ以外に、実はここがSprocketsのポイントなのでは、と思う所があるのですが、それは後で説明します。

依存関係の管理

Sprocketsのrequireディレクティブは出た当時だと革命的に便利だったと個人的には思います。requireJSとか書く気しなくなります。
しかし、最近それはbrowserifywebpackによって解決できるようになってきました。

browserifyやwebpackを利用することで、ブラウザで利用するJSもNodeで使われているようなCommonJSスタイルで依存関係を記述することが可能になります。

Marionette = require('backbone.marionette')
TodoModel = require("./models/todo")
TodoView = require("./views/todo_view")

browserifyやwebpackはこういった書き方をしている箇所を判別し、ソースコードを埋め込んでそれをブラウザで読み出せる形に変換してJSをコンパイルしてくれます。
今までSprocketsで書いていたような書き方に非常に近い形で記述できるし見た目にも分かりやすい。

ただ、browserifyはnpmパッケージを利用することを基本としているので、bowerにしか無いパッケージをbrowserifyで管理しようとした時は、少し工夫が必要になります。(debowerifyとかbrowserify-shimを使う)

alt-JSやテンプレートの扱い

これはむしろJSのエコシステムの方が本家であり、Sprocketsはほとんどの場合、execJSインターフェースを経由してJSの実行環境を使いコンパイラを呼び出しているに過ぎません。
前述したbrowserifywebpackにもalt-JSをコンパイルするためのプラグインが用意されています。

更にJSの世界だけで完結できるようになると、AST変換を利用してReactのためのJSXのシンタックスを埋め込んだり、cssやmustacheテンプレートをrequireして埋め込む、といったことも可能になります。

Vue.component 'preview_video_player', require('../templates/preview_video_player.vue')

例えば、これはHTMLのテンプレートと初期データ、CSSを一つのファイルにまとめて、vue.jsの引数として読み込むことができるvueifyというbrowserifyのtransformerを利用しています。

コンパイルしたものをRailsでどうやって読み込むか

これは、単純にpublic/assetsの下に置くというのが今の所妥当じゃないかと思いますが、ここで少し問題があります。
それは、コンパイル前のソースコードをどうやって参照するかという点についてです。

Sprocketsはこの辺りが非常に良く出来ていて、development環境だとJSを結合することなくrequireディレクティブで解決された依存関係の順番に展開されたスクリプトタグになります。
そのため、元のJSファイルを何も気にせずに参照することができます。
また、coffeeを使っている場合も、単体のソースコードを元に1:1でSourceMapを出力できるため、coffeeを参照することも簡単です。

Sprocketsはdevelopment環境でJSをどうやって処理しているか

それを実現しているのがsprockets-railslib/sprockets/rails/helper.rbにある以下のコードです。

def javascript_include_tag(*sources)
  options = sources.extract_options!.stringify_keys

  if options["debug"] != false && request_debug_assets?
    sources.map { |source|
      check_errors_for(source, :type => :javascript)
      if asset = lookup_asset_for_path(source, :type => :javascript)
        asset.to_a.map do |a|
          super(path_to_javascript(a.logical_path, :debug => true), options)
        end
      else
        super(source, options)
      end
    }.flatten.uniq.join("\n").html_safe
  else
    sources.push(options)
    super(*sources)
  end
end

lookup_asset_for_pathSprockets::EnvironmentからAssetオブジェクトを取得します。
この時、何かをrequireしてる場合はBundledAssetというオブジェクトが返ってきます。
BundledAssetto_aすることで依存している各Assetに展開されます。

[4] pry(main)> Rails.application.assets["application.js"].to_a
[
    [ 0] #<Sprockets::ProcessedAsset:0x3ff9fd539c88 pathname="/Users/joker/srcs/shared_bundle/ruby/2.1.0/gems/rails-assets-jquery-2.1.1/app/assets/javascripts/jquery/jquery.js", mtime=2014-11-05 13:21:34 +0900, digest="58903a6923e36a77460
7e4b901b754a4">,
    [ 1] #<Sprockets::ProcessedAsset:0x3ff9fd530c50 pathname="/Users/joker/srcs/shared_bundle/ruby/2.1.0/gems/rails-assets-jquery-2.1.1/app/assets/javascripts/jquery.js", mtime=2014-11-05 13:21:34 +0900, digest="73c85de29ff82c26fd0389ed11
0db603">,
    [ 2] #<Sprockets::ProcessedAsset:0x3ff9fd525508 pathname="/Users/joker/srcs/shared_bundle/ruby/2.1.0/gems/jquery-ui-rails-5.0.2/app/assets/javascripts/jquery-ui/core.js", mtime=2014-11-04 16:45:42 +0900, digest="f9aa88e753c1a8175880c6
8d9bb8e539">,
    [ 3] #<Sprockets::ProcessedAsset:0x3ff9fd51c46c pathname="/Users/joker/srcs/shared_bundle/ruby/2.1.0/gems/jquery-ui-rails-5.0.2/app/assets/javascripts/jquery-ui/datepicker.js", mtime=2014-11-04 16:45:43 +0900, digest="6e9ce2f47ae0fd2a
76e191d9dce07587">

これをそれぞれscriptタグに変換しています。

JSのビルドツールとscriptタグ

もし、browserify等を使ってコンパイルした場合、scriptタグを一々個別に書くなんてことはせず、1ファイルにコンパイルされたJSだけをscriptタグで読み込むことになると思います。
そうするとSourceMapが無いと、エラーが発生した時にどのJSファイルに対応しているのかを調べることが難しくなります。
更に、一度変換したものをminifyするといった様に、二種類以上の変換処理が関係している場合、SourceMapを適切に持ち回って元のJSをちゃんと参照できるようにするのは、かなり難しい問題です。
この点についてはSprocketsが楽な点の一つです。

digestの扱い

SprocketsはJSをprecompileするとファイルのdigest値を計算しファイル名の末尾に付与します。これはURLベースのキャッシュ管理を楽にするためです。
ファイルが更新されると勝手にアクセス先が切り替わるため、キャッシュ無効化の管理を気にせず良くなります。

Sprocketsのヘルパーを使っている場合、scriptタグの参照先は自動で切り替わるのですが、JSを単独でビルドした場合に同一のことをやろうとした場合、少し工夫が必要になります。
ちなみに、JSのビルドツールであるgulpやgruntにはそういったファイルのdigest値を付与するためのプラグイン自体はちゃんと用意されています。

Sprocketsはdigestが付いたファイルをどうやって参照しているのか確認してみましょう。
同じくsprockets-railslib/sprockets/rails/helper.rbです。

def compute_asset_path(path, options = {})
  # Check if we are inside Sprockets context before calling check_dependencies!.
  check_dependencies!(path) if defined?(depend_on)

  if digest_path = asset_digest_path(path)
    path = digest_path if digest_assets
    path += "?body=1" if options[:debug]
    File.join(assets_prefix || "/", path)
  else
    super
  end
end
def asset_digest(path, options = {})
  return unless digest_assets

  if digest_path = asset_digest_path(path, options)
    digest_path[/-(.+)\./, 1]
  end
end
def asset_digest_path(path, options = {})
  if manifest = assets_manifest
    if digest_path = manifest.assets[path]
      return digest_path
    end
  end

  if environment = assets_environment
    if asset = environment[path]
      return asset.digest_path
    end
  end
end

compute_asset_pathはRails本体のactionviewで定義されているヘルパーで、それをsprockets-railsがオーバーライドしている形になります。
そのオーバーライドした処理の中でdigest付きのアセットを探しにいき、見つかればそちらを利用するようになっています。

digest付きのアセットファイルは、manifestファイルを利用して検索していることが分かります。
manifestファイルとはrake assets:precompileした時にpublic/assets/manifest.jsonというのが出来ていますが、そのファイルのことです。
manifestファイルはどの時点で読み込まれるのか。それはSprockets::Railtieが定義しているafter_initializeの中です。

config.after_initialize do |app|
  # .. 省略 ..

  ActiveSupport.on_load(:action_view) do
    include Sprockets::Rails::Helper
    # .. 省略 ..

    context = app.assets.context_class
    context.assets_prefix = config.assets.prefix
    context.digest_assets = config.assets.digest
    context.config        = config.action_controller

    if config.assets.compile
      self.assets_environment = app.assets
      self.assets_manifest    = Sprockets::Manifest.new(app.assets, manifest_assets_path, config.assets.manifest)
    else
      self.assets_manifest = Sprockets::Manifest.new(manifest_assets_path, config.assets.manifest)
    end
  end

  # .. 省略 ..
end

つまり、digest付きファイルの読み込みに関して言うなら、manifest.jsonさえ所定の書式で出力していれば、どんな手段でコンパイルしたものであろうが、Railsのヘルパーは勝手に処理してくれる、というわけです。
そこで、gulp-revというdigestを付与するためのgulpプラグインと併用して利用するためにrailsと書式を合わせたmanifest.jsonを出力できるライブラリを作りました。

joker1007/gulp-rev-rails-manifest

一部の情報は揃っていないため、完全にRailsが出力するものと同一の形式にはなっていないのですが、ヘルパーメソッドで読み込むために必要な情報は揃っています。
この様に、digest付きファイルの読み込みは、Sprocketsのヘルパーメソッドだけを上手く利用することで、今までのRailsから大差無い形で読み込めるようになります。

ちなみに、Railsのassets:precompileは非常に賢く出来ていて、JSによってmanifest.jsonが出力された後、assets:precompileによってSprockets側でコンパイルした場合、ちゃんとその中身はマージされます。
なので、ファイル名の衝突にさえ気を付けていれば、turbolinks等と共存させることも可能です。

開発中の再コンパイルについて

この点も、Sprocketsの方が楽な点の一つです。
Sprocketsで開発しているなら、あるファイルを弄った時は再読み込みをするだけでそのファイルだけ再コンパイルされて上手く更新してくれます。
依存関係が複雑だと、それなりに時間かかったりしますが…。

JSの世界でビルドを行う場合、ファイルを編集したら自動で再コンパイルさせる仕組みを整えておく必要があります。
幸い、JSのエコシステムでは変更検知して再コンパイルするのは非常に一般的なので、そういった仕組みはすぐに整えることができます。
問題は、コンパイル時間の短縮と前述したdigest読み込みの仕組みとの相性の悪さです。

コンパイル時間を短縮するためにビルドツールに合わせたキャッシュ方法を調べる必要があります。
規模が小さければそんなに問題にはならないんですが、フルビルドすると分単位で時間がかかる、とかだと流石に単純に全部コンパイルしなおすわけにはいかなくなります。
この辺りは、まだそんなに詳しくないので調査していきたい所です。

また、開発時からdigest付きのファイルを出力するのはRailsのヘルパーメソッドと相性が悪くて、単純にそれをやるのは余りオススメできません。
何故ならRailsがmanifestファイルを読み込むのはafter_initializeのタイミングだけなので、例えmanifest.jsonが更新されてもメモリ上のデータは変わらないためです。
この問題については、RackミドルウェアなりEngineなりを噛ましてリクエスト毎に変化があれば読み直す、みたいな仕組みを挟めばいいので、解決の余地はあります。
まだ試してないのですが、後日やるかもしれません。

まとめ

Sprocketsの内部の仕組みに触れつつ、JSのエコシステムの中でビルドしたファイルをRailsでどうやって扱うかについて考えを紹介してきました。
Railsの外世界に目を向けると、これがデファクトみたいなやり方はやっぱりまだ確立していなくて、ES6が普通に使えるぐらいの世の中になるまで幸せは来ないのかもしれません。
Sprocketsに縛られてると、例えばreact + reactifyみたいな構成はかなり難しくて、JSの世界の進歩を横目にSprocketsに篭り続けるのもなあ、とこういったやり方に手を出してみましたが、実際やってみるとハマり所が多く、まともに動かせる開発環境からデプロイの流れを作るだけで数日かかりました。
仕事で書くようなちゃんとしたアプリだともっと考えなければいけないことは増えるかも。
というわけで、そんなに簡単にはSprocketsを捨ててJSでヒャッハーコンパイルだー!みたいにはならないような感じです。

JSの専門家みたいな人やJSが好きで先端を追っかけたい人がチーム内に居る場合は、検討してみるのも良いかもしれません。
趣味でやる分には色々と勉強になったので、最近のJSの流行が分からんのだーという人は、手を出してみるのは良いと思います。