Railsのアセットパイプライン周りのコードリーディングをしました。今回はassets:precompileのRakeタスクで何をやっているかをコードベースで追っていきます。Railsのバージョンは5.1.5です。

まずSprockets::Railtie内のrask_tasksのDSLでSprockets::Rails::Taskのインスタンスを生成します。

Sprockets::Rails::TaskはRake::SprocketsTaskを継承しており、コンストラクタで#defineを呼び出します。この#defineにRakeタスクが定義されています。

assets:precompileタスクはSprockets::Manifest#compileを呼び出します。

Sprockets::Manifestのコンストラクタは以下のように定義されています。

Railsの場合、@directory = public/assetsになります。@filenameはマニフェストファイルが存在すればpublic/assets/{マニフェストファイル名}になります。マニフェストファイルは#find_directory_manifestで検索しています。

次にSprockets::Manifest#compileを見ていきます。findでアセットを検索・コンパイルし、ブロック内でコンパイルしたファイルを書き出し、最後に#saveでマニフェストファイルに保存します。concurrent_writersやconcurrent_compressorsのwait!はファイル書き出し(通常ファイル+gzファイル)の非同期処理を待っています。

Sprockets::Manifest#findの中身を見ていきます。

#findの引数は config.assets.precompile です。特に追加されていなければSprockets::Railtie::LOOSE_APP_ASSETSとapplication.{css, js}の正規表現が入っている配列になります。LOOPSE_APP_ASSETSはjs,css以外のapp/assets以下のファイルをコンパイルするためのlambdaです。

#findの定義は以下の通りです。

paths変数はワイルドカードを含まない文字列で指定されたprecompileの要素、filterはワイルドカード・正規表現・Procで表現されたprecompileの要素が入ります。文字列が指定されている場合は、そのままパスとしてファイルを抜き出せば良いのですが、ワイルドカードや正規表現で表現されている場合はアセットの検索パス(=Sprockets::Environment#logical_paths)からファイルを抽出してフィルタリングする必要があります。そのため、2要素に分けてそれぞれに対してSprockets::Base#find_all_linked_assetsを呼び出しています。

#find_all_linked_assetsは以下の通りです。#find_assetでアセットを読みこんでブロックをyieldします。

#find_assetは#resolveした後、#loadを呼び出します。

#resolveは指定したファイルパス名から以下の要素を抽出し、最終的にそれらから生成したuri, depsを返します。

  • ファイル名
  • ファイルタイプ(application/javascriptとかそういうの)
  • pipeline(プロセッサ選択用の文字列)
  • deps(ファイルパスに対する依存関係)

#resolveしたuriをSprockets::Loader#loadに渡します。#loadは#fetch_asset_from_dependency_cacheを呼び出します。

#fetch_asset_from_dependency_cacheはキャッシュ(Railsの場合、キャッシュ先はtmp/cache/assets配下のファイルになります)に無ければ引数無しでyieldします。

引数無しでyieldすると#load_from_unloadedが呼ばれます。

#load_from_unloadedは以下のとおりで、#processors_forによってプロセッサを取得しています。プロセッサはファイルを読み込んだりディレクティブを展開したりソースの結合や圧縮などを行うクラスです。このプロセッサが#call_processors経由でディレクティブを解決してファイル連結したり圧縮したりします。また、#store_assetではキャッシュにAsset情報を保存しています。

ざっくり書くとこんな流れです。

  • 各々のprecompile対象のファイルは、Sprockets::Bundleのプロセッサ+コンプレッサによって処理される
    • Sprockets::Bundleのプロセッサの処理
      • Sprockets::DirectiveProcessorによりディレクティブを再帰的に展開し、Assetの配列を生成
      • reducerによりAssetの配列からソース文字列を結合
    • コンプレッサにより圧縮
  • キャッシュに保存

プロセッサは::Sprockets内で登録されています。Environment#default_processors_forやEnvironment#self_processors_forの戻り値がプロセッサになります。

例えばapplication.jsがprecompile対象でファイル内でjQueryをrequireした場合は以下のようなフローになります。

  • env#loadではpipeline=nilなのでSprockets::Bundleが適用される
    • Sprockets::Bundle内でEnvironment#resolveがpipeline=selfで呼ばれる(env.resolveのところ)
    • pipeline=selfなのでSprockets::FileReaderとSprockets::DirectiveProcessorがapplication.jsに適用される(Utils.dfs(processed_uri...の1ループ目)
    • Sprockets::DirectiveProcessorによってメタデータのrequiredにjQueryがセットされる。この時点ではAssetクラスに展開されていない(Utils.dft(processed_uri...の2ループ目)
      • Sprockets::FileReaderとSprockets::DirectiveProcessorがメタデータのrequiredのjQueryに対して適用される。ここでAssetクラスがセットされる。jQueryにはディレクティブが無いのでDirectiveProcessorは意味がなく、FileReaderの処理だけが適用される。
    • #process_bundle_reducersにより、読み込んだapplication.jsとjQueryの中身を結合する

Sprockets::Bundle.callは以下のような実装になっています。重要なのは required = Utilf.dfs(...の部分で、ここで再帰的にEnvironment#loadしつつファイルのロードや、ディレクティブ解決を行っています。

required変数に格納された各アセットのURIはEnvironment#loadによってAssetの配列になり、#process_bundle_reducersによってファイル結合されます。

reducer自体は::Sprocketsで定義されており、ファイルタイプによって処理内容が変わります。

おまけ

ちなみにEnvironment#logical_pathのアセットのパス検索対象のディレクトリはRails::Engineに定義されています。jsやcssのRailsプラグインはRails::Engineを定義してアセットの検索パスを追加していることになります。jquery-railsのengineなんかがわかりやすいですが、Rails::Engineを継承したクラスをロードするだけでアセットの検索パスが追加されます。

 また、config.asseet.js_compressor=を呼び出すとpipeline=nilのときのbundleのprocessorが増えます。プロセッサは定義順に対してFIFOな感じで処理されるので、圧縮は最後に実行されます。