Webpacker から importmap-rails に移行した

## 謝辞
**2022-10-30 追記:** 自分がいつも読んでいる TechRacho さんの週刊Railsウォッチで取り上げていただきました！

https://techracho.bpsinc.jp/hachi8833/2022_04_25/117359#2-5

(記事が公開された 2022-04-25 の時点で把握していたのですが、ここにリンクを貼るのを今まで忘れていました。。。)

## はじめに
### きっかけ
Rails アプリで使用していた [Webpacker](https://github.com/rails/webpacker) の開発が終了し、[importmap-rails](https://github.com/rails/importmap-rails) がデフォルトになったため。

Rails7 のリリース後、今後 importmap-rails がデフォルトになることが発表され、その後、Webpacker の[開発終了が発表](https://github.com/rails/webpacker/commit/16bba5d6ca862b950a002f19c10d12e4bf51a87b)されました。また、プリコンパイルが必要な環境向けの選択肢として [jsbundling-rails](https://github.com/rails/jsbundling-rails) などが公開されました。 
Rails チームは Webpacker の開発を終了しましたが、後継のプロジェクトとして [Shakapacker](https://github.com/shakacode/shakapacker)が公開されており、開発中だった Webpacker v6 の機能を引き継いでいます。

自分の個人的なプロジェクトでは Webpacker v6 への移行準備をしていて、後は正式リリースを待つだけ、という状態だったので、開発終了はちょっと残念でした。。。
Shakapacker へ移行する道もあるのですが、なるべくデフォルトのツールを使っていきたいのと、フロントエンドで TypeScript などビルドが必要なライブラリを使用していないので、 importmap-rails に移行することにしました。

移行はそこそこ大変だったので、自分の備忘録も兼ねて移行手順をまとめました。
最終的には Webpacker -> importmap-rails + Propshaft + cssbundling-rails という構成になりました。

### Webpack(er) からの脱却
もともと、自分のプロジェクトでは Asset Pipeline を使用せず、Webpacker で CSS (SCSS)と画像などのアセットも処理していました。
importmap-rails では CSS と画像の処理をしてくれないので、 Asset Pipeline を復活させることにしました。以前は Sprockets がその役目を担っていましたが、新しく [Propshaft](https://github.com/rails/propshaft) が登場したため、こちらを使います。
また、 SCSS を使用しているので、コンパイルのために [cssbundling-rails](https://github.com/rails/cssbundling-rails) を導入します。

## 移行手順
### 前提
この移行手順では WSL2 を使用しています。
また Rails アプリは、ざっくり以下のような構成です。

- Ruby 3.1.0
- Rails 7.0.1
- Webpacker 5.4.3
- Sprockets は未使用
- JavaScript は少なめ (Vanilla JS)
- SCSS を使用している
- SCSS, 画像(favicon 含む)も Webpacker で処理している

### ライブラリの導入
Gemfile から webpacker を削除し、 importmap-rails, cssbundling-rails, propshaft を追加します。

```diff:Gemfile
- gem 'webpacker', '~> 5.4'
+ gem 'cssbundling-rails'
+ gem 'importmap-rails'
+ gem 'propshaft'
```

`bundle install` を実行した後、 `bin/rails importmap:install` を実行します。
するといくつかのファイルが追加/変更されます。

```
$ bin/rails importmap:install
Add Importmap include tags in application layout
      insert  app/views/layouts/application.html.erb
Create application.js module as entrypoint
      create  app/javascript/application.js
Use vendor/javascript for downloaded pins
      create  vendor/javascript
      create  vendor/javascript/.keep
Configure importmap paths in config/importmap.rb
      create  config/importmap.rb
Copying binstub
      create  bin/importmap
```

また、  `bin/rails css:install:sass` も実行します。
こちらでもいくつかのファイルが追加/変更されます。

```
$ bin/rails css:install:sass
Build into app/assets/builds
      create  app/assets/builds
      create  app/assets/builds/.keep
      append  .gitignore
File unchanged! The supplied flag value not found!  .gitignore
Remove app/assets/stylesheets/application.css so build output can take over
      remove  app/assets/stylesheets/application.css
Add stylesheet link tag in application layout
      insert  app/views/layouts/application.html.erb
Add default Procfile.dev
      create  Procfile.dev
Ensure foreman is installed
         run  gem install foreman from "."
Successfully installed foreman-0.87.2
1 gem installed
Add bin/dev to start foreman
      create  bin/dev
Install Sass
      create  app/assets/stylesheets/application.sass.scss
         run  yarn add sass from "."
yarn add v1.22.17
[1/4] Resolving packages...
[2/4] Fetching packages...
[3/4] Linking dependencies...
warning " > webpack-dev-server@3.11.3" has unmet peer dependency "webpack@^4.0.0 || ^5.0.0".
warning "webpack-dev-server > webpack-dev-middleware@3.7.3" has unmet peer dependency "webpack@^4.0.0 || ^5.0.0".
[4/4] Building fresh packages...
success Saved lockfile.
success Saved 3 new dependencies.
info Direct dependencies
└─ sass@1.49.7
info All dependencies
├─ immutable@4.0.0
├─ sass@1.49.7
└─ source-map-js@1.0.2
Done in 4.95s.
Add build:css script
         run  npm set-script build:css "sass ./app/assets/stylesheets/application.sass.scss ./app/assets/builds/application.css --no-source-map --load-path=node_modules" from "."
         run  yarn build:css from "."
yarn run v1.22.17
$ sass ./app/assets/stylesheets/application.sass.scss ./app/assets/builds/application.css --no-source-map --load-path=node_modules
Done in 0.23s.
```

ちなみに、css のインストールコマンドは他にも用意されており、現時点で以下のコマンドが使用できます。

https://github.com/rails/cssbundling-rails#installation

```
./bin/rails css:install:[tailwind|bootstrap|bulma|postcss|sass]
```

### ファイルの変更
インストールコマンドで追加/変更されたファイルに手を入れていきます。

#### app/views/layouts/application.html.erb
コマンド実行後は以下のように2行追加されています。

```diff
   <%= javascript_packs_with_chunks_tag 'application', 'data-turbo-track': 'reload' %>
   <%= stylesheet_packs_with_chunks_tag 'application', media: 'all', 'data-turbo-track': 'reload' %>
   <%= favicon_pack_tag 'favicon.ico' %>
+  <%= javascript_importmap_tags %>
+  <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
```

`javascript_packs_with_chunks_tag`, `stylesheet_packs_with_chunks_tag` はそれぞれ、追加された `javascript_importmap_tags`, `stylesheet_link_tag` に置き換えます。
また、 Webpacker が提供していた `favicon_pack_tag` はもう使用できないため、`favicon_link_tag` に置き換えます。

変更後:

```
  <%= javascript_importmap_tags %>
  <%= stylesheet_link_tag 'application', media: 'all', 'data-turbo-track': 'reload' %>
  <%= favicon_link_tag 'favicon.ico' %>
```

#### アセットファイルの移動
Webpacker のときとファイルの置き場所が変わっているので移動しておきます。

- JavaScript
  - from: `app/packs/src`
  - to: `app/javascript/controllers`  
   controllers というディレクトリ名である必要は無いですが、 javascript ディレクトリの直下には置かないことをオススメします。
- CSS
  - from: `app/packs/stylesheets`
  - to: `app/assets/stylesheets`
- 画像
  - from:  `app/packs/images`
  - to: `app/assets/images`

#### app/javascript/application.js
Webpacker では `app/packs/entrypoints/application.js` でしたが、この内容を `app/javascript/application.js` に移動します。
その際、以下の点に対応する必要があります。

##### SCSS と画像読み込みのコードを削除
これらは今後 Propshaft によって Asset Pipeline で処理されるので、下記のコードは削除します。
 
  ```js
  import './application.scss'
  
  require.context('../images', true)
  ```

##### JavaScript のパス変更に対応
自分で書いた JavaScript のパスを変更しているので、それに合わせて import も変更します。

- 変更前:
  
  ```js
  import '../src/module1'
  ```

- 変更後:

  ```js
  import 'controllers/module1'
  ```

#### app/assets/stylesheets/applications.sass.scss
Webpacker では `app/packs/entrypoints/application.scss` でしたが、この内容を `app/assets/stylesheets/applications.sass.scss` に移動します。
その際、以下の点に対応する必要があります。

##### package.json で管理している外部ライブラリの CSS を使用している場合
Webpacker のときは `@import '~bootstrap/scss/bootstrap';` のようにライブラリ名の前に `~` (チルダ) を付けることで `node_modules` ディレクトリの下から必要なファイルをインポートしていました。
   今後 `sass` ライブラリがコンパイルを行えるようにするには、 `~` を削除すればOKです。

- 変更前:
   
   ```css
   @import '~bootstrap/scss/bootstrap';
   ```

- 変更後:
   
   ```css
   @import 'bootstrap/scss/bootstrap';
   ```
##### CSS ファイルのパス変更に対応
CSS ファイルのパスを変更しているので、それに合わせて import も変更します。

  - 変更前:
  
  ```css
  @import '../stylesheets/style1';
  ```

  - 変更後:
  
  ```css
  @import './style1';
  ```

:::note warn
**SCSS の `@import` の廃止について**
:::

今後、`@import` は廃止されることが発表されています。

https://sass-lang.com/blog/the-module-system-is-launched#future-plans

> However, doing away with `@import` entirely is the ultimate goal for simplicity, performance, and CSS compatibility. As such, we plan to gradually turn down support for `@import` on the following timeline:

将来的に `@use` や `@forward` に置き換わるのですが、単純に置き換えができないので、移行には時間がかかりそうです。この移行例でも `@import` のままにしています。

**2022-10-30 追記:** 当初は 2022-10-01 までには `@import` のサポートを終了する方針だったそうですが、延長されたようです。(ユーザーの80%が Dart Sass に移行するまでは待つ、と言っています)

> **July 2022**: In light of the fact that LibSass was deprecated before ever adding support for the new module system, the timeline for deprecating and removing `@import` has been pushed back. We now intend to wait until 80% of users are using Dart Sass (measured by npm downloads) before deprecating `@import`, and wait at least a year after that and likely more before removing it entirely.

いずれにしても、最終的にはサポートが終了するということは変わらないので、早めに移行しておいた方が良さそうです。

#### config/importmap.rb
```ruby:config/importmap.rb
# Pin npm packages by running ./bin/importmap

pin "application", preload: true
```
以前は JavaScript のライブラリは package.json で管理していましたが、今後はこのファイルで管理します。登録には `bin/importmap pin` コマンドを使います。
例えば bootstrap を追加するとこのようになります。

```
$ bin/importmap pin bootstrap
Pinning "bootstrap" to https://ga.jspm.io/npm:bootstrap@5.1.3/dist/js/bootstrap.esm.js
Pinning "@popperjs/core" to https://ga.jspm.io/npm:@popperjs/core@2.11.2/lib/index.js
```
`config/importmap.rb` には以下の2行が追加されます。

```ruby:config/importmap.rb
pin "bootstrap", to: "https://ga.jspm.io/npm:bootstrap@5.1.3/dist/js/bootstrap.esm.js"
pin "@popperjs/core", to: "https://ga.jspm.io/npm:@popperjs/core@2.11.2/lib/index.js"
```
bootstrap は @popperjs/core に依存していますが、importmap はこの依存関係も解決して必要なライブラリを登録してくれます。
同様の手順で、必要なライブラリを全て登録していきます。
ちなみに pin コマンドの引数には `bin/importmap pin library1 library2` のように複数のライブラリを渡せます。

外部ライブラリ以外の、自分で書いた JavaScript の読み込みには `pin_all_from` が使えます。
`app/javascript/controllers` 配下に配置した場合、以下の記述で読み込めます。

```ruby:config/importmap.rb
pin_all_from "app/javascript/controllers", under: "controllers"
```

:::note warn
**注意**
Ruby の仕様上、文字列を括る文字はシングルクォーテーションでもダブルクオーテーションでも良いのですが、 importmap-rails v1.0.2 時点では、`importmap.rb` の中で文字列をシングルクオーテーションで括った場合には `pin` コマンドと `unpin` コマンドが正常に動作しません。(`unpin` コマンドは `importmap.rb` からライブラリを削除してくれます)
そのうち修正が入るかもしれませんが、それまでは `importmap.rb` ではダブルクオーテーションを使用しましょう。
:::

#### その他
- 画像を表示するために `image_pack_tag` メソッドを使っていた場合、 `image_tag` メソッドに置き換えます。
- jQuery を使っている場合、`application.js` において `require` は `import` に、 `global` は `window` に置き換えます。

  ```js:before
  global.$ = require('jquery')
  ```
  ```js:after
  import $ from 'jquery'
  window.$ = $
  ```

- i18n-js の移行は少し手順が必要だったので、別記事を書きました。 -> [i18n-js を importmap-rails で使う](https://qiita.com/mishina2228/items/1df8396c5a46fc29802a)

### 動作確認
以下のコマンドで動作確認します。

```
bin/dev
```

このコマンドによって、 Rails の起動と sass のビルドが行われます。
これは [foreman](https://github.com/ddollar/foreman) というアプリが `Procfile.dev` の記述を実行することで実現されています。
どんなコマンドが実行されるかは `Profile.dev` を見るとわかります。

自分は Chrome の開発者ツールを見ながら画面操作をして、エラーが発生しないか確認しました。
一連の操作をして問題ないことを確認できたら、importmap-rails への移行がほぼ完了です。
あとは Webpacker の掃除をしましょう。

### 後片付け
#### Webpacker を削除
package.json から Webpacker を削除します。

```diff:package.json
-  "@rails/webpacker": "5.4.3",
-  "webpack-dev-server": "^3.11.3"
```

以下の関連ファイルも削除します。

- `bin/webpack`
- `bin/webpack-dev-server`
- `config/webpack/development.js`
- `config/webpack/environment.js`
- `config/webpack/production.js`
- `config/webpack/test.js`
- `config/webpacker.yml`
- `.browserslistrc`
- `babel.config.js`
- `postcss.config.js`

#### package.json の掃除
`importmap.rb` に移動したライブラリを `package.json` から削除します。
ただし、 bootstrap などのライブラリを使用している場合、JavaScript は CDN から取得しますが、 CSS などスタイルシートについては `node_modules` から読み込む必要があるので、残しておく必要があります。
掃除終了後に、正常に動作するか確認しておきましょう。

ここまでで importmap-rails への移行は終了です！ 🎉

## おまけ
### Dependabot が非対応
GitHub 上のプロジェクトでは、ライブラリの自動更新を行ってくれる Dependabot を簡単に利用できます。
今回、JavaScript ライブラリの依存関係の記述を package.json から importmap.rb に移動しましたが、 2022年2月6日現在、 Dependabot は importmap.rb に対応していません。
これについては、以下の importmap-rails の issue でも言及されています。

https://github.com/rails/importmap-rails/issues/19

セキュリティのためにも、なんとかして Dependabot の自動更新を使いたいので、次のような方法で対応しました。

- Dependabot の自動更新を受ける専用のブランチを用意する。(ここでは yarn-dependabot ブランチにした)
- importmap.rb で管理しているものと同じライブラリを package.json でも管理する。
- 初期状態では Dependabot はデフォルトのブランチにのみ PR を出すので、設定ファイルに追記して、専用ブランチにPRを出してもらうように変更する。

  ```yml:.github/dependabot.yml
    - package-ecosystem: npm
      directory: "/"
      schedule:
        interval: daily
        time: "20:00"
      target-branch: yarn-dependabot
  ```
(`target-branch` オプションの詳細については以下の [ドキュメント](https://docs.github.com/en/code-security/supply-chain-security/keeping-your-dependencies-updated-automatically/configuration-options-for-dependency-updates#target-branch) を参照してください。)

---

**2022年5月24日追記:** `audit` と `outdated` コマンドが v1.1.0 で追加されました。(実際の対応はこのPR↓)

https://github.com/rails/importmap-rails/pull/109

`./bin/importmap outdated` を実行することで、ライブラリの最新版があるか確認できます。
以下は出力例(上記PRより引用)
```
+-----------------+---------------+---------------+
| Package         | Current       | Latest        |
+-----------------+---------------+---------------+
| @jspm/core      | 2.0.0-beta.18 | 2.0.0-beta.19 |
| @jspm/core      | 2.0.0-beta.2  | 2.0.0-beta.19 |
| aaaasssstimulus | 2.0.0         | Not found     |
| glob-parent     | 3.1.0         | 6.0.2         |
| is-glob         | 3.1.0         | 4.0.3         |
| is-svg          | 3.0.0         | 4.3.2         |
| lodash          | 4.17.1        | 4.17.21       |
| nth-check       | 1.0.0         | 2.0.1         |
| react           | 16.0.0        | 17.0.2        |
| stimulus        | 2.0.0         | 3.0.1         |
+-----------------+---------------+---------------+
  10 outdated packages found
```

ただし、まだ Dependabot の方では未対応なので、手動で `importmap outdated` を実行して確認する、ということができるようになったという段階です。
`importmap outdated` を定期実行 -> 出力をパースして結果を通知するという動きを自動化することができれば楽になりますね。(Dependabot が対応してくれれば一番ですが)

## 終わりに
Webpacker が無くなったことで、Hot Module Replacement は使えなくなりましたが、
その代わりに以下の恩恵を受けられました。

- JavaScript のビルドが無いので速い
- node_modules ディレクトリが肥大化しなくなった
- Webpacker によって出ていた Dependabot alerts が消えた
  - セキュリティ上の問題があるバージョンのライブラリを使用していても、 Webpacker が要求しているバージョンが古いために、バージョンアップができずに、脆弱性が残り続けていた。

移行にはそれなりに手間がかかりますが、最新の技術に取り残されないためにも、ぜひ移行しましょう！