👏

Rails 7.0 で標準になった importmap-rails とは何なのか?

2022/01/20に公開約12,100字

はじめに

Ruby on Rails 7.0 より、標準では webpack や rollup といった JavaScript bundler を使わず、代わりに Import maps を使うようになりました。

業務の現場では依然として jsbundling-rails などを用いて JavaScript bundler を使った開発が主だと思いますが、この記事では Import maps とは何なのか? そして Rails ではどう扱かっているのか? についてまとめてみます。

Import maps について

Import mapsは JavaScript (ES6) の import 文や import() 式で取得するモジュール(ESModules)の URL を制御することができるWeb標準です。 現状では Chrome系ブラウザでのみサポートされているのみですが、他のブラウザでも ES Module Shims という Polyfill を使うことで利用できます。

Can I use Import maps?

具体例

どういうことか、具体的な例を示します。

HTML

"パッケージ名":"取得先のURL" を列挙し、ブラウザに伝えます

<script type="importmap">{
  "imports": {
    "application": "/assets/application-12345.js",
    "components": "/assets/components/index-12345.js",
    "components/hello": "/assets/components/hello-12345.js",
    "react": "https://ga.jspm.io/npm:react@17.0.2/index.js",
    "react-dom": "https://ga.jspm.io/npm:react-dom@17.0.2/index.js",
    "object-assign": "https://ga.jspm.io/npm:object-assign@4.1.1/index.js",
    "scheduler": "https://ga.jspm.io/npm:scheduler@0.20.2/index.js",
  }
}</script>
<script type="module">
  // "application" から "/assets/application-12345.js" を解決、取得して実行
  import "application";
</script>

JavaScript

JavaScript コード中の import で指定されたパッケージ名に対応するパッケージ名を ブラウザが解決してロードする ことで、実行できます。

// in assets/application.js
// ブラウザによって `"components"` に対応する `/assets/modules/index-12345.js` よりダウンロードされ、 export された Hello オブジェクトをインポート
// Import maps を使わないなら次のように書いていた
// import { Hello } from "./components";
import { Hello } from "components";
// 同様にnpmモジュールをダウンロードしてインポート。モジュール中の依存先もimportmapに書いてあるならOK
import React from "react-dom";
import ReactDOM from "react-dom";

const container = document.querySelector("#hello");
if (container) {
  ReactDOM.render(
    React.createElement(Hello, { toWhat: container.dataset.toWhat }, null),
    container
  );
}

何が嬉しいか?

JavaScript の Bundling が不要

ブラウザが import を解決、必要なファイルを取得してくれるため、依存モジュールや複数のファイルをまとめる必要がありません

これまでJavaScriptの変更の度に(変更を検知して自動で行うことができるとはいえ)Webpackなどでファイルを1つにまとめていましたが、これが不要になります。

HTTP/2で並列処理

HTTP/1.1 では、1つのファイルを取得するごとに1つのTCP接続が必要です。取得するファイル数が増えると読み込みにかかる時間が増大し、ユーザー体験を損なうために、複数のファイルを1つにまとめて、接続数を抑える工夫が必要でした。

そこで HTTP/2 では1つの接続で複数のファイルを処理できるようになりました。

普及が進む「HTTP/2」の仕組みとメリットとは

現代においてはIE以外の主要ブラウザではHTTP/2に対応しており、特にIEに対応する必要が無ければ積極的に利用できそうです。

HTTP/2 protocol | Can I use...

小さなキャッシュ

複数のJavaScriptファイルを1つの大きなJavaScriptにまとめるということは、複数のファイルのうち1つでも変更があれば、できあがる大きなJavaScript全体が変更されるということです。

Bundling が不要であれば変更は変更のあったファイルだけということになるので、変更のないファイルはキャッシュを利用できるため、新たにダウンロードするファイルは最小限で済みます。

Rails での活用

Ruby on Rails 7.0 では、 rails new すると標準で importmap-rails が使用されます。

importmap-rails では config/importmap.rb にDSLを使ってマッピングの設定を記載 します。

そしてビューで javascript_importmap_tags ヘルパーを使い

  • importmap (<script type="importmap">{...}</script>)
  • プリロードさせるモジュールのためのscriptタグ
  • ES Module Shims 用の Content Security Policy nonce (CSPが設定されているとき)
  • ES Module Shims を読み込むためのscriptタグ
  • エントリーポイントのためのscriptタグ (<script type="module">import "application";</script>)

を生成します。

importmap_tags_helper.rb

サンプルコード

// config/iportmap.rb
pin "application", preload: true
pin_all_from "app/javascript/components", under: "components"
pin "react", to: "https://ga.jspm.io/npm:react@17.0.2/index.js"
pin "react-dom", to: "https://ga.jspm.io/npm:react-dom@17.0.2/index.js"
pin "object-assign", to: "https://ga.jspm.io/npm:object-assign@4.1.1/index.js"
pin "scheduler", to: "https://ga.jspm.io/npm:scheduler@0.20.2/index.js"
<%= javascript_importmap_tags %>

生成されるHTML

<script type="importmap">{
  "imports": {
    "application": "/assets/application-182299b37495cf432aefe1013a04757ed7685c297b3b67c0d45ca82f27cb9878.js",
    "components": "/assets/components/index-8d6b58843dade72f9dd9a40e43bf392c8f6e4a60b1ef34158c71dd4e580527a0.js",
    "components/hello": "/assets/components/hello-8749abf3cde30aa00f0fd38f4c9408d069368585972e163191c54eec14e09d78.js",
    "react": "https://ga.jspm.io/npm:react@17.0.2/index.js",
    "react-dom": "https://ga.jspm.io/npm:react-dom@17.0.2/index.js",
    "object-assign": "https://ga.jspm.io/npm:object-assign@4.1.1/index.js",
    "scheduler": "https://ga.jspm.io/npm:scheduler@0.20.2/index.js"
  }
}</script>
<link rel="modulepreload" href="/assets/application-182299b37495cf432aefe1013a04757ed7685c297b3b67c0d45ca82f27cb9878.js">
<script src="/assets/es-module-shims.min-6982885c6ce151b17d1d2841985042ce58e1b94af5dc14ab8268b3d02e7de3d6.js" async="async"></script>
<script type="module">import "application"</script>

サンプルと使い方

takeyuweb/rails-importmap-demo

config/importmap.rb の書き方

pin(name, to: nil, preload: false)

特定のファイルをマッピングに追加する

  • 第一引数: パッケージ名。規約では app/javascript 内のファイル名と同じに。
  • to: オプション: パッケージ名とファイル名が一致しないときに指定する。リモートサーバー上のモジュールを読み込みたい場合ここでURLを指定する。
  • preload: オプション: モジュールとその依存関係を先取りして取得させたいとき true
# https://github.com/rails/importmap-rails/blob/459f1080f8dd72b22929ac18da47153b03975497/test/importmap_test.rb より
pin "application", preload: true // application.js を読み込みたい
pin "editor", to: "rich_text.js"  // import "editor"; で rich_text.js を読み込みたい
pin "not_there", to: "nowhere.js"  // ローカルに存在しない
pin "md5", to: "https://cdn.skypack.dev/md5", preload: true // リモート
<script type="importmap">{
  "imports": {
    "application": "/assets/application-<digest>.js",
    "editor": "/assets/rich_text-<digest>.js",
    "md5": "https://cdn.skypack.dev/md5"
  }
}</script>
<link rel="modulepreload" href="/assets/application-<digest>.js">
<link rel="modulepreload" href="https://cdn.skypack.dev/md5">

pin_all_from(dir, under: nil, to: nil, preload: false)

指定したディレクトリ以下のファイル一式をマッピングに追加する

  • 第一引数: ローカル上の特定のディレクトリ
  • under: オプション: サブディレクトリ名を指定
    • この指定がない場合、パッケージ名は第一引数のディレクトリからの相対パスから生成したものになる。
    • たとえば第一引数で指定するディレクトリが app/javascript/controllers のとき app/javascript/controllers/hoge_controller.js のパッケージ名は
      • 指定が無い場合: hoge_controller
      • under: "controllers"指定の場合: controllers/hoge_controller
  • to: オプション: オプション: カスタマイズされたアセットパスを使いたいとき指定する。
    • この指定がない場合、アセットパスは第一引数のディレクトリからの相対パスから生成したものになる。
    • AssetsPipelineの出力パスがルート(/assets/)直下でない場合、 to: オプションで指定する必要がある
    • たとえば第一引数で指定するディレクトリが app/javascript/controllers のとき app/javascript/controllers/hoge_controller.js のアセットパス名
      • 指定が無い場合: /assets/hoge_controller-<digest>.js
      • under: "controllers"指定の場合: /assets/controllers/hoge_controller-<digest>.js
  • preload: オプション: モジュールとその依存関係を先取りして取得させたいとき true
# https://github.com/rails/importmap-rails/blob/459f1080f8dd72b22929ac18da47153b03975497/test/importmap_test.rb より
# app/javascript/controllers 以下のモジュールを import "controllers/hoge" で読み込みたい
pin_all_from "app/javascript/controllers", under: "controllers", preload: true
# app/javascript/spina/controllers 以下のモジュールを import "controllers/spina" で読み込みたい(実際のファイルパスと使いたいパッケージ名が違う)
pin_all_from "app/javascript/spina/controllers", under: "controllers/spina", to: "spina/controllers", preload: true
pin_all_from "app/javascript/helpers", under: "helpers", preload: true
# app/javascript の以外のパスもOK(app/assets/config/manifest.js への記載が必要)
pin_all_from "lib/assets/javascripts", preload: true
<script type="importmap" data-turbo-track="reload">{
  "imports": {
    // pin_all_from "app/javascript/controllers", under: "controllers", preload: true
    "controllers/application": "/assets/controllers/application-<digest>.js",
    "controllers/goodbye_controller": "/assets/controllers/goodbye_controller-<digest>.js",
    "controllers/hello_controller": "/assets/controllers/hello_controller-<digest>.js",
    "controllers": "/assets/controllers/index-<digest>.js",
    "controllers/utilities/md5_controller": "/assets/controllers/utilities/md5_controller-<digest>.js",
    // pin_all_from "app/javascript/spina/controllers", under: "controllers/spina", preload: true
    "controllers/spina/another_controller": "/assets/spina/controllers/another_controller-<digest>.js",
    "controllers/spina/deeper/again_controller": "/assets/spina/controllers/deeper/again_controller-<digest>.js",
    // pin_all_from "app/javascript/helpers", under: "helpers", preload: true
    "helpers/requests": "/assets/helpers/requests/index-<digest>.js",
    // pin_all_from "lib/assets/javascripts", preload: true
    "my_lib": "/assets/my_lib-<digest>.js"
  }
}</script>
<link rel="modulepreload" href="/assets/controllers/application-<digest>.js">
<link rel="modulepreload" href="/assets/controllers/goodbye_controller-<digest>.js">
<link rel="modulepreload" href="/assets/controllers/hello_controller-<digest>.js">
<link rel="modulepreload" href="/assets/controllers/index-<digest>.js">
<link rel="modulepreload" href="/assets/controllers/utilities/md5_controller-<digest>.js">
<link rel="modulepreload" href="/assets/spina/controllers/another_controller-<digest>.js">
<link rel="modulepreload" href="/assets/spina/controllers/deeper/again_controller-<digest>.js">
<link rel="modulepreload" href="/assets/helpers/requests/index-<digest>.js">
<link rel="modulepreload" href="/assets/my_lib-<digest>.js">

npmパッケージを使う

bin/importmap コマンドを使うことで、npmパッケージの依存関係を解決して必要なパッケージを config/importmap.rb に追加したり削除したりすることができます。

bin/importmap pin [*PACKAGES]

$ bin/importmap pin react react-dom
Pinning "react" to https://ga.jspm.io/npm:react@17.0.2/index.js
Pinning "react-dom" to https://ga.jspm.io/npm:react-dom@17.0.2/index.js
Pinning "object-assign" to https://ga.jspm.io/npm:object-assign@4.1.1/index.js
Pinning "scheduler" to https://ga.jspm.io/npm:scheduler@0.20.2/index.js
# config/importmap.rb
pin "react", to: "https://ga.jspm.io/npm:react@17.0.2/index.js"
pin "react-dom", to: "https://ga.jspm.io/npm:react-dom@17.0.2/index.js"
pin "object-assign", to: "https://ga.jspm.io/npm:object-assign@4.1.1/index.js"
pin "scheduler", to: "https://ga.jspm.io/npm:scheduler@0.20.2/index.js"

bin/importmap unpin [*PACKAGES]

$ bin/importmap unpin react react-dom
Unpinning "react"
Unpinning "react-dom"
Unpinning "object-assign"
Unpinning "scheduler"

プリロード rel="modulepreload"

リソースを先行して読み込む仕様をプリロードと呼びます。
ESModulesのプリロードには rel="preload" ではなく rel="modulepreload" を使う必要があります。

modulepreload でモジュールを指定すると、そこから static import されているモジュールも全てプリロードします。

<link rel="modulepreload" href="/assets/application-<digest>.js">
<link rel="modulepreload" href="https://cdn.skypack.dev/md5">

リンク種別: modulepreload

リソースの読み込みを助けるウェブブラウザ API の世界

importmap-rails では pin pin_all_from のオプションとして preload: true を渡しておく と、javascript_importmap_tags ヘルパーが link rel="modulepreload" タグを生成します。

importmap-rails にできないこと

JSXの処理

プリコンパイルしないので、JSXを処理できず、たとえばReactコンポーネントはクラスでしか書けません。(一応、JSX的な記法を素のJavaScriptで書けるライブラリを使う方法もあるようですが・・・

import React from "react";

class Hello extends React.Component {
  render() {
    return React.createElement('div', null, `Hello, ${this.props.toWhat}`);
  }
}

app/javascript/hello_react/hello.js

npmモジュールのアップデート

bin/importmap pin react react-dom のようにしてパッケージの追加はできますが、 bin/importmap update react react-dom のようにはできません。

まとめ

主要ブラウザがHTTP/2やES6サポートしたこと、Import mapのWeb標準とその実装ができたことで、JavaScriptツールチェーンを利用せず、RailsサーバーとWebブラウザのみで、モダンなJavaScript開発が可能になりました。

まだまだ発展途上ではあるものの、Rails以外のツールチェインに悩ませられずに開発できる可能性が産まれたのは、個人や小規模のRailsエンジニアチームなど一部の開発者にとって救いとなるかもしれません。

rails/importmap-rails のREADME にRails Engineなどさらに詳しい使い方が記載されています。

Discussion

ログインするとコメントできます