お礼の品の組み合わせをMutationObserverで監視する

この記事は、[トラストバンク Advent Calendar 2021](https://qiita.com/advent-calendar/2021/trustbank)の3日目です。

この度、ふるさとチョイスの[トップページ](https://www.furusato-tax.jp?qiita)や[控除金額シミュレーション](https://www.furusato-tax.jp/about/simulation?qiita)に**控除上限額の中でもらえるお礼の品の組み合わせをおすすめする機能**（以降、**お礼の品の組み合わせ表示**と省略）がリリースされました。
忙しい年末のふるさと納税に、ぜひご活用ください！

お礼の品の組み合わせ表示を制作するにあたり、JavaScriptの監視インスタンスである`MutationObserver`を利用しました。
複雑なDOM操作をシンプルに実装することができる`MutationObserver`の活用方法を、お礼の品組み合わせ表示の紹介も交えつつ説明します。

# お礼の品の組み合わせ表示の複雑な状態管理

![キャプチャ.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/930157/18fa81a4-141b-8cd5-016f-a77e246c7e87.png)

お礼の品の組み合わせ表示とは、控除上限額に合わせてお礼の品を組み合わせ、合計金額と共に表示する機能です。
表示された組み合わせは、**入れ替えるボタン**や**削除ボタン**などを用いて自分好みにカスタマイズ。
**寄付するリストに追加**をクリックすることで、一括でお礼の品を寄付するリストに追加することができます。

制作時に悩まされたのは、お礼の品が入れ替わる条件や動作の多さです。
全ての動作に対して個別に寄付金総額や品数の変更を実装すると、更新漏れやソースの難読化に繋がります。

そこで活用するのが`MutationObserver`です。

```HTML
<div id="mutation-wrap"></div>
```

```JavaScript
const mutationWrapElement = document.getElementById('mutation-wrap');
const observer = new MutationObserver(() => {
    // ここに寄付金総額と品数の再計算を記述する
});

observer.observe(mutationWrapElement, {
    childList: true
});
```

`#mutation-wrap`にDOMが追加されるたび、`MutationObserver.observe`のcallbackとして指定した関数が実行され、寄付金総額と品数が再計算されます。
再計算を実行する条件が、**`#mutation-wrap`にDOMを追加・削除する**である限り、新たにJavaScriptを書く必要はありません。

今回は、親となるDOMの直下に発生する操作のみを監視したいため、オプションには`childList`を追加しました。
他にも指定できるオプションは様々です。

|オプション名|内容|
|---|---|
|subtree|孫を監視対象に含める|
|attributes|属性値の変更を監視|
|attributeOldValue|属性値が変更された際、変更前の値を取得する|
|attributeFilter|監視したい属性値をフィルタリングする|
|characterData|テキストの変更を監視|
|characterDataOldValue|テキストが変更された際、変更前の値を取得する|

参考：[DOM Living Standard - Interface MutationObserver](https://dom.spec.whatwg.org/#interface-mutationobserver)

# `MutationObserver`を用いる際の注意事項
工夫次第で何にでも活用できる`MutationObserver`ですが、利用にあたって注意すべき点がいくつか存在します。

## DOMの追加・削除に伴うパフォーマンスの低下
監視したいDOMを単体で指定することができないため、DOMが操作された分だけcallback関数が実行されます。
`Document.createDocumentFragment`や`Element.innerHTML`などを用い、なるべく一括で操作を実行するようにします。

```JavaScript
// NG
children.forEach(child => {
    // childを追加した分実行される
    obseverWrapper.appendChild(child);
});
children.forEach(child => {
    // childを削除した分実行される
    obseverWrapper.removeChild(child);
});

// OK
const fragment = document.createDocumentFragment();

children.forEach(child => {
    fragment.appendChild(child);
});
// 追加を1回にまとめたので、observeも1回だけ実行される
obseverWrapper.appendChild(fragment);
// 削除を1回にまとめたので、observeも1回だけ実行される
obseverWrapper.innerHTML = '';
```

参考：[DOM Living Standard - Interface DocumentFragment](https://dom.spec.whatwg.org/#documentfragment)

## 無限ループに陥る場合がある
callback関数の中に`MutationObserver.observe`で監視しているDOMに対する処理を記述すると、場合によっては無限ループに陥ります。
`MutationObserver.disconnect`を用いて、一時的に監視を止めてから処理を実行しましょう。

```JavaScript
// NG
const mutationWrapElement = document.getElementById('mutation-wrap');
const observer = new MutationObserver(() => {
    // 属性値の変更が監視されているので、無限ループに陥る
    mutationWrapElement.children[0].classList.add('view');
});

observer.observe(mutationWrapElement, {
    childList: true,
    attributes: true
});

// OK
const mutationWrapElement = document.getElementById('mutation-wrap');
const observer = new MutationObserver(() => {
    // 監視を停止
    observer.disconnect();
    // 監視が停止しているので、無限ループを回避できる
    mutationWrapElement.children[0].classList.add('view');
    // 監視を再開
    observer.observe(mutationWrapElement, {
        childList: true,
        attributes: true
    });
});

observer.observe(mutationWrapElement, {
    childList: true,
    attributes: true
});
```

# まとめ
JavaScriptには他にも、交差監視を実現する`IntersectionObserver`やサイズの変化を監視する`ResizeObserver`など、多くの監視インスタンスが実装されています。
それぞれをうまく取り入れることで、いつかはフレームワークを利用しなくても、直感的で見やすいソースコードが書ける時代が来るかもしれません。

明日の記事は、@a_wis1056さんです。お楽しみに。