Import mapsでJavaScriptを管理しているときのstimulus controller登録の仕組み

# はじめに

Railsでstimulusを使う場合は、[stimulus-rails](https://github.com/hotwired/stimulus-rails) gem が使われます。
このgemにより、stimulus controllerを生成できます。
生成されたstimulus controllerは、JavaScriptの管理方法に応じた方法で、登録する必要があります。

JavaScriptの管理方法としてJavaScript bundlerを使う場合は、`register`メソッドを使います。
```js
import HelloController from "./hello_controller"
application.register("hello", HelloController)
```

JavaScriptの管理方法としてimport mapsを使う場合は、`eagerLoadControllersFrom`メソッド or `lazyLoadControllersFrom`メソッドを使います。
```js
// Eager load all controllers defined in the import map under controllers/**/*_controller
import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading"
eagerLoadControllersFrom("controllers", application)

// Lazy load controllers as they appear in the DOM (remember not to preload controllers in import map!)
import { lazyLoadControllersFrom } from "@hotwired/stimulus-loading"
lazyLoadControllersFrom("controllers", application)
```

この記事では、JavaScriptの管理方法としてimport mapsを使う場合に用いる、これらメソッドの仕組みをまとめます。


# `eagerLoadControllersFrom`メソッド

`eagerLoadControllersFrom`メソッドは、指定したディレクトリ配下の全てのcontrollerを登録します。
第一引数で、登録するcontrollerがあるディレクトリを指定します。
第二引数は、stimulus controllerを登録する母体みたいなものを渡しています。
```js
export function eagerLoadControllersFrom(under, application) {
  const paths = Object.keys(parseImportmapJson()).filter(path => path.match(new RegExp(`^${under}/.*_controller$`)))
  paths.forEach(path => registerControllerFromPath(path, under, application))
}
```

このメソッドの１行目で、HTML上のimportmap定義をパースします。
そして、stimulus controllerに対して定義している部分だけ抽出しています。
例えば、以下のようなimportmap定義があったとします。
```html
<script type="importmap" data-turbo-track="reload">{
  "imports": {
    "application": "/assets/application-37f365cbecf1fa2810a8303f4b6571676fa1f9c56c248528bc14ddb857531b95.js",
    "@hotwired/turbo-rails": "/assets/turbo.min-dfd93b3092d1d0ff56557294538d069bdbb28977d3987cb39bc0dd892f32fc57.js",
    "@hotwired/stimulus": "/assets/stimulus.min-dd364f16ec9504dfb72672295637a1c8838773b01c0b441bd41008124c407894.js",
    "@hotwired/stimulus-loading": "/assets/stimulus-loading-3576ce92b149ad5d6959438c6f291e2426c86df3b874c525b30faad51b0d96b3.js",
    "controllers/application": "/assets/controllers/application-368d98631bccbf2349e0d4f8269afb3fe9625118341966de054759d96ea86c7e.js",
    "controllers/hello_controller": "/assets/controllers/hello_controller-549135e8e7c683a538c3d6d517339ba470fcfb79d62f738a0a089ba41851a554.js",
    "controllers": "/assets/controllers/index-2db729dddcc5b979110e98de4b6720f83f91a123172e87281d5a58410fc43806.js"
  }
}</script>
```
この場合、`eagerLoadControllersFrom`メソッドの１行目は`['controllers/hello_controller']`として評価されます。

`eagerLoadControllersFrom`メソッドの2行目では、１行目で抽出したstimulus controllerの登録を`registerControllerFromPath`メソッドで行なっています。
```js
function registerControllerFromPath(path, under, application) {
  const name = path
    .replace(new RegExp(`^${under}/`), "")
    .replace("_controller", "")
    .replace(/\//g, "--")
    .replace(/_/g, "-")

  if (canRegisterController(name, application)) {
    import(path)
      .then(module => registerController(name, module, application))
      .catch(error => console.error(`Failed to register controller: ${name} (${path})`, error))
  }
}
```

初めに、変数`name`に、stimulus controllerの識別子を定義しています。
`path`が`'controllers/hello_controller'`なら、`name`に`'hello'`が定義されます。

`import`メソッドのところで、stimulus controllerの登録をします。
実際に、登録を行なっているのは、`registerController`メソッドです。
```js
function registerController(name, module, application) {
  if (canRegisterController(name, application)) {
    application.register(name, module.default)
  }
}
```

このメソッドの`register`メソッドにより、stimulus controllerを登録します。
`register`メソッドで登録を行うのは、JavaScriptの管理方法としてJavaScript bundlerを使う時と同様です。


# `lazyLoadControllersFrom`メソッド

`lazyLoadControllersFrom`メソッドは、指定したディレクトリ配下のcontrollerを使っているDOMがあったら、そのcontrollerを登録します。
第一引数、第二引数で渡すもの`eagerLoadControllersFrom`メソッドと同様です。
```js
export function lazyLoadControllersFrom(under, application, element = document) {
  lazyLoadExistingControllers(under, application, element)
  lazyLoadNewControllers(under, application, element)
}
```

このメソッドの１行目の`lazyLoadExistingControllers`メソッドで、HTML上で既に使われているstimulus controllerを登録しています。
例えば、HTML上で`<p data-controller='hello'></p>`があれば、hello_controllerを登録します。

２行目の`lazyLoadNewControllers`メソッドで、動的に使われるようになったstimulus controllerを登録します。
例えば、HTML上に`<p></p>`があって、それの属性に`data-controller='hello'`をJavaScriptで追加した時に、hello_controllerを登録します。

これら２メソッドについてそれぞれまとめます。

## `lazyLoadExistingControllers`メソッド

`lazyLoadExistingControllers`メソッドは、HTML上の`data-controller`属性の値を抽出し、その属性の値のstimulus controllerを登録します。
```js
function lazyLoadExistingControllers(under, application, element) {
  queryControllerNamesWithin(element).forEach(controllerName => loadController(controllerName, under, application))
}
```
`queryControllerNamesWithin`メソッドで、HTML上の`data-controller`属性の値を抽出しています。つまり、stimulus controller名を抽出します。
例えば、HTML上に`<div data-controller='card'><p data-controller='hello'></p></div>`があれば、`'card'`と`'hello'`が抽出されます。

`loadController`メソッドで、`queryControllerNamesWithin`メソッドで抽出したstimulus controllerを登録します。このメソッドは、`eagerLoadControllersFrom`メソッドの節で説明した`registerControllerFromPath`メソッドとほぼ同じことをしています。

## `lazyLoadNewControllers`メソッド

`lazyLoadNewControllers`メソッドは、HTML上のDOMの動的な変更に応じて、stimulus controllerを登録します。
```js
function lazyLoadNewControllers(under, application, element) {
  new MutationObserver((mutationsList) => {
    for (const { attributeName, target, type } of mutationsList) {
      switch (type) {
        case "attributes": {
          if (attributeName == controllerAttribute && target.getAttribute(controllerAttribute)) {
            extractControllerNamesFrom(target).forEach(controllerName => loadController(controllerName, under, application))
          }
        }

        case "childList": {
          lazyLoadExistingControllers(under, application, target)
        }
      }
    }
  }).observe(element, { attributeFilter: [controllerAttribute], subtree: true, childList: true })
}
```

DOMの動的な変更は[MutationObserver](https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver)で監視しています。監視対象は以下です。
- `data-controller`属性
- DOMの子ノードの増減

`data-controller`属性を監視することで、既存DOMの`data-controller`属性の値が変わった時にもstimulus controllerが動的に登録されます。
例えば、HTML上に`<p data-controller='hello'></p>`があって、`data-controller`属性の値を`'goodbye'`に変えたらgoodbye_controllerが登録されます。

DOMの子ノードの増減を監視することで、JavaScriptで動的に増えたDOMに対するstimulus controllerが登録されます。
例えば、HTML上に`<div data-controller='card'</div>`があって、そのDOMの中にJavaScriptで
`<p data-controller='hello'></p>`を追加したら、hello_controllerが登録されます。

# おわりに

Import mapsでJavaScriptを管理しているときのstimulus controller登録の仕組みをまとめました。
Stimulus controllerを登録するメソッドは、`eagerLoadControllersFrom`、`lazyLoadControllersFrom`があります。
前者は１度に全てのcontrollerを登録し、後者は使われるcontrollerだけ登録します。
1つの画面でほぼ全てのcontrollerを使う場合は`eagerLoadControllersFrom`、そうでない場合は`lazyLoadControllersFrom`を使うのが良いかもしれません。