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

[食べログ Advent Calendar 2019 ](https://qiita.com/advent-calendar/2019/tabelog)24日目の記事です。

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

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

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

### 調査対象
- webpacker@4.2.2(2019/12/24時点での最新版)

### 調査対象コマンド

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

## `./bin/webpack`

github上には[ここ](https://github.com/rails/webpacker/blob/master/lib/install/bin/webpack)にコードがあります。

```ruby:./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_ENV`や`NODE_ENV`を規定し、`Webpacker::WebpackRunner.run(ARGV)`を実行しています。
`RAILS_ENV`や`NODE_ENV`の中身は`./bin/webpack`実行時どちらも`development`です。

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

github上には[ここ](https://github.com/rails/webpacker/blob/master/lib/webpacker/webpack_runner.rb)にコードがあります。

```ruby: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`といった不明なインスタンス変数が出てきましたね。
これらのインスタンス変数は[こちら](https://github.com/rails/webpacker/blob/master/lib/webpacker/runner.rb)で宣言されています。
中身は以下です。

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


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

```sh
****/app-root/node_modules/.bin/webpack --config ****/app-root/config/webpack/development.js
```
### Nodeコマンドで言い換えると
内部処理を追った結果`./bin/webpack`のビルド処理は以下と同一でした。

```sh
./node_modules/.bin/webpack --config ./config/webpack/development.js
```
`yarn`ならば以下のように置き換えられます。

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

## `./bin/webpack-dev-server`

github上には[ここ](https://github.com/rails/webpacker/blob/master/lib/install/bin/webpack-dev-server)にコードがあります。

```ruby:./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上には[ここ](https://github.com/rails/webpacker/blob/master/lib/webpacker/dev_server_runner.rb)にコードがあります。

```ruby: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_config`で`config/webpacker.yml`から`host,port`の設定を取得
- `detect_port`で同一hostname,portが使われていないか調査
- `execute_cmd`で`webpack-dev-server`関連のコマンドを実行

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

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

### Nodeコマンドで言い換えると
内部処理を追った結果`./bin/webpack-dev-server`の実行処理は以下と同一でした。

```sh
./node_modules/.bin/webpack-dev-server --config ./config/webpack/development.js --port 3035
```
(port番号は[webpacker.ymlの初期値](https://github.com/rails/webpacker/blob/master/lib/install/config/webpacker.yml#L62))

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

```sh
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.rake`が`webpacker:compile`と対応しています。
実行される処理のコードは以下です。

github上には[ここ](https://github.com/rails/webpacker/blob/master/lib/tasks/webpacker/compile.rake)にコードがあります。


```ruby: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関連の実行処理ですね。

```ruby: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"))`という処理が出てきます。
処理は[こちら](https://github.com/rails/webpacker/blob/master/lib/webpacker.rb#L17)に記載されていますが、引数で受け取った文字列を`ENV["NODE_ENV"]`に突っ込むという処理を行っていますね。
次に`ensure_log_goes_to_stdout`というメソッドを実行した後に`Webpacker.compile`を実行しています。

では次に`Webpacker.compile`の処理内容を見に行きましょう。
処理は[こちら](https://github.com/rails/webpacker/blob/master/lib/webpacker/commands.rb#L28-L32)に記載されています。

```ruby:commands.rb
  def compile
    compiler.compile.tap do |success|
      manifest.refresh if success
    end
  end
```
今度は`compiler.compile`というメソッドを実行しているので、その処理を見に行きます。
[こちらに](https://github.com/rails/webpacker/blob/master/lib/webpacker/compiler.rb#L19-L33)に記載されています。

```ruby: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`辺りが臭いですね。
なので、[こちら](https://github.com/rails/webpacker/blob/master/lib/webpacker/compiler.rb#L64-L86)に記載されている`run_webpack`の中身を見に行きます。

```ruby: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_ENV`を`production`にし
-  `./bin/webpack`を実行

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

### Nodeコマンドで言い換えると
内部処理を追った結果`bundle exec rails webpacker:compile`のビルド処理は以下と同一でした。

```sh
NODE_ENV=production ./bin/webpack
```

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

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

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

```sh
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ヶ月で部の雰囲気がめちゃくちゃ良くなった話」です。
いよいよ最後ですね！
明日もよろしくおねがいします！