↗️

【Vanilla JS - MutationObserver】動的に追加されたDOMにeventを仕込みたい

2023/06/03に公開

背景

  • Vueで作られたフロントアプリケーションに、特定のDOMにeventを仕込むVanilla JS製の独自プラグインを入れたい
  • 考えなくaddEventListenrを使用した場合、pluginが読み込まれるタイミングでは狙いたいdomがまだ描画されていないケースがある

結論

Point

  • observe関数のoptionにsubtree: trueを指定する
    • これにより孫要素以降も監視対象となる

実装

  • ここではdata属性を使用してDOMの特定を行なっています
  • 変更が分かりやすいように意図的に処理を遅延させている箇所があります
plugin.js
document.addEventListener("DOMContentLoaded", () => {
  const targetContainer = document.querySelector(
    '[data-dynamic-elements-continer="true"]'
  );

  const observer = new MutationObserver((records) => {
    records.forEach((record) => {
      // 追加されたnodeに対しての処理
      record.addedNodes.forEach((addedNode, i) => {
        if (addedNode instanceof Element) {
          const targets = addedNode.querySelectorAll(
            '[data-dynamic-element="true"]'
          );
          targets.forEach((target) => {
            target.addEventListener("click", (e) => {
              alert(`click!!: ${e.target.dataset.index}`);
            });
          });
        }
      });
    });
  });

  observer.observe(targetContainer, { childList: true, subtree: true });
});

Usage

index.html
<!DOCTYPE html>
<html lang="en">
  <head>
    <!-- ... -->
  </head>
  <body>
    <!-- ... -->

    <!-- new!! -->
    <script src="./plugins/plugin.js" defer></script>
  </body>
</html>
App.vue
<template>
  <div className="App">
    <h1>Mutation Observer Playground</h1>

    <div data-dynamic-elements-continer="true">
      <DynamicChild
        v-for="(data, i) in data"
        :key="data"
        :label="data"
        :index="i"
      />
    </div>
  </div>
</template>

<script>
import DynamicChild from "./components/DynamicChild.vue";
export default {
  name: "App",
  components: {
    DynamicChild,
  },
  data() {
    return {
      data: [],
    };
  },
  async mounted() {
    // 数秒止める
    await this.sleep(1000);

    this.data = ["child1", "child2", "child3"];
  },
  methods: {
    sleep(waitTime) {
      return new Promise((resolve) => setTimeout(resolve, waitTime));
    },
  },
};
</script>

<style>
#app {
  font-family: Avenir, Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-align: center;
  color: #2c3e50;
  margin-top: 60px;
}
</style>
DynamicChild.vue
<template>
  <div>
    <p v-if="loading">loading...</p>

    <div v-else>
      <p class="button" data-dynamic-element="true" :data-index="index + 1">
        {{ label }}
      </p>
    </div>
  </div>
</template>

<script>
export default {
  props: ["label", "index"],
  data() {
    return {
      loading: true,
    };
  },
  async mounted() {
    // 数秒止める
    await this.sleep(2000 * this.index || 0);

    this.loading = false;
  },
  methods: {
    sleep(waitTime) {
      return new Promise((resolve) => setTimeout(resolve, waitTime));
    },
  },
};
</script>

<style scoped>
.button {
  cursor: pointer;
}
</style>

後書き

  • 背景としてはレアケースな気はする
  • 昨今のフロントFWとは別にモジュール作りたいケースがあれば知っておいても損は無いかも?

おまけ

今回載せたソースのDEMO

Reactで書いてみたDEMO

Discussion