Stimulusの実装の勘所 - primitive controller -

Stimulusについては[この記事](https://qiita.com/nazomikan/items/4dabc63b883aa71ce83c)で触れました。
本稿はStimulus実装におけるやりがちな設計と、よりよい設計について説明していきます。

今回はStimulusの生みの親である、Basecampのheyの実装を根拠によりよいコード改善を磨いていきたいと思います。

## 題材について

はずかしげもなくjQueryで再利用性の全くないコードを書いてみました。

```HTML
<div class="ConditionList">
  <p>Parent</p>
  <div class="ConditionList__conditions">
    <!-- .Condition -->
  </div>
  <button type="button" id="addBtn">
    add condition
  </button>
  <div class="ConditionList__formula">-</div>

  <template id="template">
    <div class="Condition">
      <p>Condition</p>
      <input type="text" class="Condition__input">
      <button type="button" class="Condition__delete">delete</button>
    </div>
  </template>
</div>
```

<p class="codepen" data-height="265" data-theme-id="light" data-default-tab="js,result" data-user="nazomikan" data-slug-hash="WNRRNgw" style="height: 265px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;" data-pen-title="QIITA_STIMULUS_TIPS_DAIZAI">
  <span>See the Pen <a href="https://codepen.io/nazomikan/pen/WNRRNgw">
  QIITA_STIMULUS_TIPS_DAIZAI</a> by nazomikan (<a href="https://codepen.io/nazomikan">@nazomikan</a>)
  on <a href="https://codepen.io">CodePen</a>.</span>
</p>
<script async src="https://cpwebassets.codepen.io/assets/embed/ei.js"></script>

今回はこのコードを題材とします。

### 機能要件

* `add condition`とかかれたボタンをクリックするとテンプレート要素の中身を`.ConditionList__conditions `に追加していく
* 各`.Condition`に存在している`delete`ボタンをクリックすると`.Condition`が削除される。
* 入力/削除があった場合、現在のCondition中のinputの値をすべて結合して`.ConditionList__formula`に展開する

というものです。

ツール系のUIにもしかしたらあるかもしれないようなインタフェースですね。 codepenを触ってもらえると動きがわかるかと思います。

## Stimulusでかいてみる

ざっくり、Stimulus初学者が書きがちなコードを書いてみます。

まず最初に、参照が必要となる要素にtargetを降っていきます。

* 結果を出力する`.ConditionList__formula`要素
* テンプレート要素
* 入力値をもっているinput要素
* 要素が追加される`.ConditionList__conditions`

次に`add button`ボタンにdata-actionを設定します。
クリックされたらaddメソッドを呼び出し、templateTargetを評価し、listTargetに追加していきます。
これで一つ目の要件が実現できます。

次に`delete`ボタンにdata-actionを設定します。
クリックされたらremoveメソッドを呼び出し、直近の`.Condition`を探索してremoveします。
これで二つ目の要件も実現できました。

最後にinput要素に入力イベントが発生した時にdata-action経由で結果を更新する関数(updateFromula)を呼び出し、条件の画面表示を加えます。 remove関数内でも同様にこのメソッドを呼び出せば要件は全てみたさることになります。


```HTML
<div class="ConditionList" data-controller="condition-list">
  <p>Parent</p>
  <div class="ConditionList__conditions" data-condition-list-target="list">
    <!-- .Condition -->
  </div>
  <button type="button" data-action="click->condition-list#add">
    add condition
  </button>
  <div class="ConditionList__formula" data-condition-list-target="formula">-</div>

  <template data-condition-list-target="template">
    <div class="Condition">
      <p>Condition</p>
      <input type="text" class="Condition__input"
        data-condition-list-target="input"
        data-action="input->condition-list#updateFormula"
      >
      <button type="button" data-action="click->condition-list#remove">delete</button>
    </div>
  </template>
</div>
```

```JavaScript
app.register('condition-list', class extends Controller {
  static targets = ['template', 'list', 'formula', 'input'];
  
  add() {
    this.listTarget.append(this.templateTarget.content.cloneNode(true))
  }
  
  remove(evt) {
    evt.currentTarget.closest('.Condition').remove();
    this.updateFormula();
  }
  
  updateFormula() {
    this.formulaTarget.textContent = this.inputTargets
      .map(target => target.value)
      .join('/')
  }
})
```

<p class="codepen" data-height="265" data-theme-id="light" data-default-tab="html,result" data-user="nazomikan" data-slug-hash="poRRvgW" style="height: 265px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;" data-pen-title="QIITA_STIMULUS_TIPS_DAIZAI_STIMULUS_BAD">
  <span>See the Pen <a href="https://codepen.io/nazomikan/pen/poRRvgW">
  QIITA_STIMULUS_TIPS_DAIZAI_STIMULUS_BAD</a> by nazomikan (<a href="https://codepen.io/nazomikan">@nazomikan</a>)
  on <a href="https://codepen.io">CodePen</a>.</span>
</p>
<script async src="https://cpwebassets.codepen.io/assets/embed/ei.js"></script>


これはイカしたコードなんでしょうか？

残念ながら、これはいつかの点で優れたコードではないと言えます。

### よくなかった点

1つはこのコードにはさほどの再利用性もないことです。 

もう1つはremove関数に着目するとわかります。　ここではDOM探索(.closest)が存在しています。

target apiを利用することでDOMとの概念的距離を縮め、コードを簡素化することを追い求めたStimulusにおいてDOM探索は基本的にすべきではあります。

こういった実装を続けていれば、やがてclosest関数は容易にdata-controllerを設定してる要素を突き抜け、Controllerの境界を曖昧にすることでしょう。

### どうすればよかったか

[hey.com](https://app.hey.com/sign_in)のソースコードや[betterstimulus.com](https://www.betterstimulus.com/)のサイトから学ぶに、controllerはUI単位ではなく振る舞い単位で記述するようになっています。
hey.comにおいてはelementを削除するという非常に細かい振る舞い単位でcontrollerが振られています(element-removal)。

中身はとても質素でこのようになっています

```JavaScript
app.register('element-removal', class extends Controller {
  remove() {
    this.element.remove();
  }
})
```

remove関数が実行されると自分自身を削除するというただそれだけを行うコントローラです。

最初のコードではcondition-listコントローラが全てを担おうとしてましたが、そうではなく、こういったプリミティブな振る舞い単位でコントローラを作ることがStimulusでの実装のコツです。

このControllerを`.Condition`に割り振りボタンに`data-action="element-removal#remove"`を付与すればそれだけで削除機能が実現します。

これで非常に再利用性の高いcontrollerが一つ生まれ、同時にDOM探索から解放されることに成功しました。

どうようにtemplateを評価して要素ついかする`template-addition`などあっても面白いかもしれません。

```JavaScript
app.register('template-additon', class extends Controller {
  static targets = ['container', 'template'];
  
  add() {
    this.containerTarget.append(this.templateTarget.cloneNode(true));
  }
})
```


これらを元にもとのコードをやや改善してみましょう

```HTML
<div class="ConditionList" data-controller="condition-list template-addition">
  <p>Condition List</p>
  <div class="ConditionList__conditions" data-template-addition-target="container">
    <!-- .Condition -->
  </div>
  <button type="button" data-action="click->template-addition#add">
    add condition
  </button>
  <div class="ConditionList__formula" data-condition-list-target="formula">-</div>

  <template data-template-addition-target="template">
    <div class="Condition" data-controller="element-removal">
      <p>Condition</p>
      <input type="text" class="Condition__input"
        data-condition-list-target="input"
        data-action="input->condition-list#updateFormula"
      >
      <button type="button"
        data-action="click->element-removal#remove click->condition-list#updateFormula">
        delete
      </button>
    </div>
  </template>
</div>
```

```JavaScript
app.register('condition-list', class extends Controller {
  static targets = ['formula', 'input'];
  
  updateFormula() {
    this.formulaTarget.textContent = this.inputTargets.map(target => target.value).join('/')
  }
})

app.register('element-removal', class extends Controller {
  remove() {
    this.element.remove();
  }
})

app.register('template-addition', class extends Controller {
  static targets = ['container', 'template'];
  
  add() {
    this.containerTarget.append(this.templateTarget.content.cloneNode(true));
  }
})
```

<p class="codepen" data-height="265" data-theme-id="light" data-default-tab="html,result" data-user="nazomikan" data-slug-hash="abppOwv" style="height: 265px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;" data-pen-title="QIITA_STIMULUS_TIPS_DAIZAI_STIMULUS_BETTER1">
  <span>See the Pen <a href="https://codepen.io/nazomikan/pen/abppOwv">
  QIITA_STIMULUS_TIPS_DAIZAI_STIMULUS_BETTER1</a> by nazomikan (<a href="https://codepen.io/nazomikan">@nazomikan</a>)
  on <a href="https://codepen.io">CodePen</a>.</span>
</p>
<script async src="https://cpwebassets.codepen.io/assets/embed/ei.js"></script>

DOM探索はなくなり、再利用性の高いコードが二つも生まれ、良さそうに見えます。

しかしながら一つだけごまかされた実装があります。

`delete`ボタンをクリックしたときのformulaを更新するための処理を以下のように記述しています。

```HTML
<button type="button"
  data-action="click->element-removal#remove click->condition-list#updateFormula">
  delete
</button>
```

data-actionに複数の振る舞いをつけることは許容されてますが、手前のremoveメソッドによって要素は削除されているため、後続のメソッドは実行されません。
故にこの実装は不具合を内包してるわけです。

削除したぞというイベントをdispatchしようにも、target要素が削除されてるので伝播のしようもないわけです。

hey.comのsorted_controllerにこれのヒントが隠されています。
そのコードはMutationObserverを利用し、DOMの変更を監視し、変更があればソートを実行するという実装になっています。

ここから学べば、`.ConditionList__conditions`の変更を監視し、変更があればupdateFormnulaを実行するという、やや大味に感じる実装でこれを解決することができます。

`.ConditionList__conditions`にtargetを付与し、MutationObserverで監視するコードを書いてみましょう

```JavaScript
  connect() {
    let observer = new MutationObserver(mutation => this.updateFormula());
    observer.observe(this.conditionsTarget, {childList: true, subtree: true})
  }
```

これでconditionsTargetに追加や削除が発生したタイミングで自動でviewの更新が入るようになります。

hey.comではこういった処理を基底Controllerに追加してどこでも便利に呼び出せるように実装していたりします。

heyのコードを是としてリファクタリングを完成させると最終的にこんな感じになるでしょうか

```HTML
<div class="ConditionList" data-controller="condition-list template-addition">
  <p>Condition List</p>
  <div class="ConditionList__conditions" data-template-addition-target="container" data-condition-list-target="conditions">
    <!-- .Condition -->
  </div>
  <button type="button" data-action="click->template-addition#add">
    add condition
  </button>
  <div class="ConditionList__formula" data-condition-list-target="formula">-</div>

  <template data-template-addition-target="template">
    <div class="Condition" data-controller="element-removal">
      <p>Condition</p>
      <input type="text" class="Condition__input"
        data-condition-list-target="input"
        data-action="input->condition-list#updateFormula"
      >
      <button type="button"
        data-action="click->element-removal#remove">
        delete
      </button>
    </div>
  </template>
</div>
```

```JavaScript
// from hey.coms application_controller.js
class ApplicationController extends Controller {
  observeMutations(callback, target = this.element, options = { childList: true, subtree: true }) {
    const observer = new MutationObserver(mutations => {
      observer.disconnect() // 次の瞬間監視要素がなくなってたら監視をやめるためにやや複雑な実装になっている
      Promise.resolve().then(start)
      callback.call(this, mutations)
    })
    function start() {
      if (target.isConnected) observer.observe(target, options)
    }
    start()
  }
}

app.register('condition-list', class extends ApplicationController {
  static targets = ['formula', 'input', 'conditions'];
  
  connect() {
    this.observeMutations(this.updateFormula, this.conditionsTarget)
  }
  
  updateFormula() {
    this.formulaTarget.textContent = this.inputTargets.map(target => target.value).join('/')
  }
})

app.register('element-removal', class extends ApplicationController {
  remove() {
    this.element.remove();
  }
})

app.register('template-addition', class extends ApplicationController {
  static targets = ['container', 'template'];
  
  add() {
    this.containerTarget.append(this.templateTarget.content.cloneNode(true));
  }
})
```

<p class="codepen" data-height="265" data-theme-id="light" data-default-tab="js,result" data-user="nazomikan" data-slug-hash="ExZZjBN" style="height: 265px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;" data-pen-title="QIITA_STIMULUS_TIPS_DAIZAI_STIMULUS_BETTER2">
  <span>See the Pen <a href="https://codepen.io/nazomikan/pen/ExZZjBN">
  QIITA_STIMULUS_TIPS_DAIZAI_STIMULUS_BETTER2</a> by nazomikan (<a href="https://codepen.io/nazomikan">@nazomikan</a>)
  on <a href="https://codepen.io">CodePen</a>.</span>
</p>
<script async src="https://cpwebassets.codepen.io/assets/embed/ei.js"></script>

## 最後に

いかがでしょう、heyの実装パターンを採用することで、primitiveなコントローラを二つ手に入れることができ、同時にDOM探索を排除することに成功しました。

Mutation Observerでの削除監視は賛否両論あるかもしれません、私もやや大味に感じる部分もあります。

次回からは開発の過程で発掘したプリミティブコントローラについて紹介していけたらいいかなと思います。