Webpackerが提供しているコマンドの内部処理を追ってみた


食べログ Advent Calendar 2019 24日目の記事です。

はじめまして。
好きな筋トレはバーベルシュラッグ。
好きな小説家は宮内悠介。
食べログのフロントエンドチームに所属している@sn_____です。
クリスマスイヴなのでWebpackerの話をします。

皆さんWebpacker使ってます?
個人的にはWebpackerは好みではありません。

Webpackerは面倒なwebpack回りの設定をやってくれるので、Railsアプリケーション開発では重宝されるケースも多いと思います。
しかし、提供されるコマンドの内部処理はブラックボックス化されており、詳細を把握していない人も多いのではないでしょうか。
フロントエンドエンジニア的にはそこらへんも抑えておきたいので、Webpackerが提供しているコマンドの内部処理を調査してみました。

調査対象

調査対象コマンド

  • ./bin/webpack
  • ./bin/webpack-dev-server
  • bundle exec rails webpacker:compile

./bin/webpack

github上にはここにコードがあります。

./bin/webpack
#!/usr/bin/env ruby

ENV["RAILS_ENV"] ||= ENV["RACK_ENV"] || "development"
ENV["NODE_ENV"]  ||= "development"

require "pathname"
ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile",
  Pathname.new(__FILE__).realpath)

require "bundler/setup"

require "webpacker"
require "webpacker/webpack_runner"

APP_ROOT = File.expand_path("..", __dir__)
Dir.chdir(APP_ROOT) do
  Webpacker::WebpackRunner.run(ARGV)
end

./bin/webpackでは環境変数のRAILS_ENVNODE_ENVを規定し、Webpacker::WebpackRunner.run(ARGV)を実行しています。
RAILS_ENVNODE_ENVの中身は./bin/webpack実行時どちらもdevelopmentです。

ではWebpacker::WebpackRunner.run(ARGV)の処理を見に行きましょう。
アプリケーション上の/.bundle/ruby/x.x.x/gems/webpacker-x.x.x/lib/webpacker/webpack_runner.rbが該当します。

github上にはここにコードがあります。

webpack_runner.rb
require "shellwords"
require "webpacker/runner"

module Webpacker
  class WebpackRunner < Webpacker::Runner
    def run
      env = Webpacker::Compiler.env

      cmd = if node_modules_bin_exist?
        ["#{@node_modules_bin_path}/webpack"]
      else
        ["yarn", "webpack"]
      end

      if ARGV.include?("--debug")
        cmd = [ "node", "--inspect-brk"] + cmd
        ARGV.delete("--debug")
      end

      cmd += ["--config", @webpack_config] + @argv

      Dir.chdir(@app_path) do
        Kernel.exec env, *cmd
      end
    end

    private
      def node_modules_bin_exist?
        File.exist?("#{@node_modules_bin_path}/webpack")
      end
  end
end

パッと見で、webpackのビルドコマンドを構築していることがわかります。
ですが、@app_path@node_modules_bin_path@webpack_configといった不明なインスタンス変数が出てきましたね。
これらのインスタンス変数はこちらで宣言されています。
中身は以下です。

変数名 説明 サンプル
@app_path アプリケーションの絶対パス ****/app-root
@node_modules_bin_path アプリケーション内に存在するnode_modulesの絶対パス ****/app-root/node_modules
@webpack_config 環境に応じたwebpack設定ファイルの絶対パス ****/app-root/config/webpack/development.js
(NODE_ENVの中身がdevelopmentだった場合)

つまりKernel.exec env, *cmdで実行している内容は、以下と同一です。

****/app-root/node_modules/.bin/webpack --config ****/app-root/config/webpack/development.js

Nodeコマンドで言い換えると

内部処理を追った結果./bin/webpackのビルド処理は以下と同一でした。

./node_modules/.bin/webpack --config ./config/webpack/development.js

yarnならば以下のように置き換えられます。

yarn webpack --config ./config/webpack/development.js

./bin/webpack-dev-server

github上にはここにコードがあります。

./bin/webpack-dev-server
#!/usr/bin/env ruby

ENV["RAILS_ENV"] ||= ENV["RACK_ENV"] || "development"
ENV["NODE_ENV"]  ||= "development"

require "pathname"
ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile",
  Pathname.new(__FILE__).realpath)

require "bundler/setup"

require "webpacker"
require "webpacker/dev_server_runner"

APP_ROOT = File.expand_path("..", __dir__)
Dir.chdir(APP_ROOT) do
  Webpacker::DevServerRunner.run(ARGV)
end

ほぼ、./bin/webpackと同じですね。
こちらでは、最後にWebpacker::DevServerRunner.run(ARGV)をしているので、その中身を見に行きます。

アプリケーション上の./.bundle/ruby/x.x.x/gems/webpacker-x.x.x/lib/webpacker/dev_server_runner.rbが該当します。

github上にはここにコードがあります。

dev_server_runner.rb
require "shellwords"
require "socket"
require "webpacker/configuration"
require "webpacker/dev_server"
require "webpacker/runner"

module Webpacker
  class DevServerRunner < Webpacker::Runner
    def run
      load_config
      detect_port!
      execute_cmd
    end

    private
      def load_config
        app_root = Pathname.new(@app_path)

        @config = Configuration.new(
          root_path: app_root,
          config_path: app_root.join("config/webpacker.yml"),
          env: ENV["RAILS_ENV"]
        )

        dev_server = DevServer.new(@config)

        @hostname          = dev_server.host
        @port              = dev_server.port
        @pretty            = dev_server.pretty?

      rescue Errno::ENOENT, NoMethodError
        $stdout.puts "webpack dev_server configuration not found in #{@config.config_path}[#{ENV["RAILS_ENV"]}]."
        $stdout.puts "Please run bundle exec rails webpacker:install to install Webpacker"
        exit!
      end

      def detect_port!
        server = TCPServer.new(@hostname, @port)
        server.close

      rescue Errno::EADDRINUSE
        $stdout.puts "Another program is running on port #{@port}. Set a new port in #{@config.config_path} for dev_server"
        exit!
      end

      def execute_cmd
        env = Webpacker::Compiler.env

        cmd = if node_modules_bin_exist?
          ["#{@node_modules_bin_path}/webpack-dev-server"]
        else
          ["yarn", "webpack-dev-server"]
        end

        if ARGV.include?("--debug")
          cmd = [ "node", "--inspect-brk"] + cmd
          ARGV.delete("--debug")
        end

        cmd += ["--config", @webpack_config]
        cmd += ["--progress", "--color"] if @pretty

        Dir.chdir(@app_path) do
          Kernel.exec env, *cmd
        end
      end

      def node_modules_bin_exist?
        File.exist?("#{@node_modules_bin_path}/webpack-dev-server")
      end
  end
end

ちょっとコードが長いですが、ざっくり処理を眺めると以下の流れが見えます。

  • load_configconfig/webpacker.ymlからhost,portの設定を取得
  • detect_portで同一hostname,portが使われていないか調査
  • execute_cmdwebpack-dev-server関連のコマンドを実行

ではexecute_cmdの処理は? と確認すると、webpacker/webpack_runner.rbとかなり似ていますね。
つまりexecute_cmdで実行している内容は、以下と同一です。

****/app-root/node_modules/.bin/webpack-dev-server --config ****/app-root/config/webpack/development.js

Nodeコマンドで言い換えると

内部処理を追った結果./bin/webpack-dev-serverの実行処理は以下と同一でした。

./node_modules/.bin/webpack-dev-server --config ./config/webpack/development.js --port 3035

(port番号はwebpacker.ymlの初期値)

yarnならば以下のように置き換えられます。

yarn webpack-dev-server --config ./config/webpack/development.js --port 3035

bundle exec rails webpacker:compile

アプリケーション上の./.bundle/ruby/x.x.x/gems/webpacker-x.x.x/lib/tasks/webpacker/compile.rakewebpacker:compileと対応しています。
実行される処理のコードは以下です。

github上にはここにコードがあります。

compile.rake
$stdout.sync = true

def yarn_install_available?
  rails_major = Rails::VERSION::MAJOR
  rails_minor = Rails::VERSION::MINOR

  rails_major > 5 || (rails_major == 5 && rails_minor >= 1)
end

def enhance_assets_precompile
  # yarn:install was added in Rails 5.1
  deps = yarn_install_available? ? [] : ["webpacker:yarn_install"]
  Rake::Task["assets:precompile"].enhance(deps) do
    Rake::Task["webpacker:compile"].invoke
  end
end

namespace :webpacker do
  desc "Compile JavaScript packs using webpack for production with digests"
  task compile: ["webpacker:verify_install", :environment] do
    Webpacker.with_node_env(ENV.fetch("NODE_ENV", "production")) do
      Webpacker.ensure_log_goes_to_stdout do
        if Webpacker.compile
          # Successful compilation!
        else
          # Failed compilation
          exit!
        end
      end
    end
  end
end

# Compile packs after we've compiled all other assets during precompilation
skip_webpacker_precompile = %w(no false n f).include?(ENV["WEBPACKER_PRECOMPILE"])

unless skip_webpacker_precompile
  if Rake::Task.task_defined?("assets:precompile")
    enhance_assets_precompile
  else
    Rake::Task.define_task("assets:precompile" => ["webpacker:yarn_install", "webpacker:compile"])
  end
end

以下の処理がwebpack関連の実行処理ですね。

compile.rake
    Webpacker.with_node_env(ENV.fetch("NODE_ENV", "production")) do
      Webpacker.ensure_log_goes_to_stdout do
        if Webpacker.compile
          # Successful compilation!
        else
          # Failed compilation
          exit!
        end
      end
    end

Webpacker.with_node_env(ENV.fetch("NODE_ENV", "production"))という処理が出てきます。
処理はこちらに記載されていますが、引数で受け取った文字列をENV["NODE_ENV"]に突っ込むという処理を行っていますね。
次にensure_log_goes_to_stdoutというメソッドを実行した後にWebpacker.compileを実行しています。

では次にWebpacker.compileの処理内容を見に行きましょう。
処理はこちらに記載されています。

commands.rb
  def compile
    compiler.compile.tap do |success|
      manifest.refresh if success
    end
  end

今度はcompiler.compileというメソッドを実行しているので、その処理を見に行きます。
こちらにに記載されています。

commands.rb
  def compile
    if stale?
      run_webpack.tap do |success|
        # We used to only record the digest on success
        # However, the output file is still written on error, (at least with ts-loader), meaning that the
        # digest should still be updated. If it's not, you can end up in a situation where a recompile doesn't
        # take place when it should.
        # See https://github.com/rails/webpacker/issues/2113
        record_compilation_digest
      end
    else
      logger.info "Everything's up-to-date. Nothing to do"
      true
    end
  end

また色々やっていますが、run_webpack辺りが臭いですね。
なので、こちらに記載されているrun_webpackの中身を見に行きます。

compiler.rb
    def run_webpack
      logger.info "Compiling..."

      stdout, stderr, status = Open3.capture3(
        webpack_env,
        "#{RbConfig.ruby} ./bin/webpack",
        chdir: File.expand_path(config.root_path)
      )

      if status.success?
        logger.info "Compiled all packs in #{config.public_output_path}"
        logger.error "#{stderr}" unless stderr.empty?

        if config.webpack_compile_output?
          logger.info stdout
        end
      else
        non_empty_streams = [stdout, stderr].delete_if(&:empty?)
        logger.error "Compilation failed:\n#{non_empty_streams.join("\n\n")}"
      end

      status.success?
    end

Open3.capture3(webpack_env, "#{RbConfig.ruby} ./bin/webpack")がビルド実行箇所ですね。
変数webpack_env, RbConfig.rubyの中身を確認してみたところ、以下の結果でした。

  • webpack_env = {"WEBPACKER_ASSET_HOST"=>nil, "WEBPACKER_RELATIVE_URL_ROOT"=>nil}
  • RbConfig.ruby = /usr/local/ruby-x.x.x/bin/ruby

つまりbundle exec rails webpacker:compile実行時のビルド処理はざっくり言うと。

  • NODE_ENVproductionにし
  • ./bin/webpackを実行

と同一であると言えます。

Nodeコマンドで言い換えると

内部処理を追った結果bundle exec rails webpacker:compileのビルド処理は以下と同一でした。

NODE_ENV=production ./bin/webpack

前述のように./bin/webpackのコマンドは以下のように置き換えられます。

./node_modules/.bin/webpack --config ./config/webpack/production.js

更にyarnならば以下のように置き換えられます。

yarn webpack --config ./config/webpack/production.js

内部処理を追ってみての感想

どのコマンドも素直なyarnコマンドに置き換えられるなーと感じました。
なので、単純にビルドを実行させたい時は、yarnコマンドでそのまま実行してもよいですね。

最後に調査した各コマンドの対応表を貼っておきます。

Webpacker提供コマンド yarnコマンド
./bin/webpack yarn webpack --config ./config/webpack/development.js
./bin/webpack-dev-server yarn webpack-dev-server --config ./config/webpack/development.js --port 3035
bundle exec rails webpacker:compile yarn webpack --config ./config/webpack/production.js

それでは皆さん良いwebpackライフを。

さてさて明日は、@tkyowaさんの「技術部門にOKRを導入したら3ヶ月で部の雰囲気がめちゃくちゃ良くなった話」です。
いよいよ最後ですね!
明日もよろしくおねがいします!