12
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Stimulusの実装の勘所 - primitive controller -

Last updated at Posted at 2021-03-31

Stimulusについてはこの記事で触れました。
本稿はStimulus実装におけるやりがちな設計と、よりよい設計について説明していきます。

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

題材について

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

<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>

See the Pen QIITA_STIMULUS_TIPS_DAIZAI by nazomikan (@nazomikan) on CodePen.

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

機能要件

  • 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関数内でも同様にこのメソッドを呼び出せば要件は全てみたさることになります。

<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>
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('/')
  }
})

See the Pen QIITA_STIMULUS_TIPS_DAIZAI_STIMULUS_BAD by nazomikan (@nazomikan) on CodePen.

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

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

よくなかった点

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

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

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

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

どうすればよかったか

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

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

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などあっても面白いかもしれません。

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

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

<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>
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));
  }
})

See the Pen QIITA_STIMULUS_TIPS_DAIZAI_STIMULUS_BETTER1 by nazomikan (@nazomikan) on CodePen.

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

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

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

<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で監視するコードを書いてみましょう

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

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

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

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

<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>
// 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));
  }
})

See the Pen QIITA_STIMULUS_TIPS_DAIZAI_STIMULUS_BETTER2 by nazomikan (@nazomikan) on CodePen.

最後に

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

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

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

12
9
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
12
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?