【JS】MutationObserverでDOMを監視[PhotoSwipe(v5)+Swiper(v8)連携]

【JS】MutationObserverでDOMを監視[PhotoSwipe(v5)+Swiper(v8)連携]

はじめに

JSでDOMを追加・変更・削除した時に合わせて何かしたいことがたまにあります。

VueやReactなんかのフレームワークを使う場合は処理できるのですが、フレームワークを使っていない普通のWebサイトの場合に「ここのDOMが消えた時にクラスを追加したい」「ここのDOMが変わった時に変更後のDOMを取得したい」とか、DOMの変更に合わせて何かしたい時が出てきます。

それが数十箇所とかある場合は、そもそもフレームワーク使った方が良いかもしれません。ただし、1カ所だけとかの場合、そのためにフレームワーク導入するのはちょっと重たいです。

そういう時に使えるAPIがMutationObserverです。

MutationObserverとは

MutationObserverはDOMの変更を監視するAPIです。使い方の例としてサンプルコードを書きました。

サンプルコード

HTML

<div id="js-target"></div>

JS

//監視する要素
const target = document.querySelector('#js-target');

// インスタンスの作成
const observer = new MutationObserver((mutations) => {
  mutations.forEach((mutation) => {
    // 要素内にテキストがある場合はクラス追加
    if(mutation.target.textContent.length){
      $elem.classList.add('is-text');
    }
    // テキストが空の場合はクラスを削除
    else{
      $elem.classList.remove('is-text');
    }
  });
});

// 監視の開始
observer.observe(target, {
  characterData: true, // テキストコンテンツの変更を監視
});

#js-target要素を監視して、テキストが1文字でもある場合は.is-textクラスを要素に追加し、テキストが空の場合は.is-textクラスを要素から削除するというものです。

監視した状態で、下記のようにJSでテキストを加えたとします。

target.textContent = 'テキスト追加';

するとMutationObserverの監視が効き、下記のようにHTMLにクラスが追加されます。

<div id="js-target" class="is-text">テキスト追加</div>

テキストを空にすると、追加した.is-textクラスは削除されます。

target.textContent = '';
<div id="js-target"></div>

このように、MutationObserverを使うことで任意の要素の変更を監視して何かしらの処理を追加できます。

また、サンプルコードではテキストコンテンツを監視対象にしていますが、オプションで他の内容の監視設定もできます。

// 監視を開始
observer.observe( target, {
  childList             : true, // 子ノードの変更を監視
  attributes            : true, // 属性の変更を監視
  attributeFilter       : ['class', 'style'] // 特定の属性の変更を監視
  attributeOldValue     : true, // 属性の変更時の古い値を記録しておく (attributes:true必須)
  characterData         : true, // テキストコンテンツの変更を監視
  characterDataOldValue : true, // テキストコンテンツの変更時の古い値を記録しておく (characterData:true必須)
  subtree               : true  // すべての子孫ノードの変更を監視
});

監視を開始する時は、childListattributescharacterData、の3つのうちいずれかをtrueに指定する必要があります。

対応ブラウザ

モダンブラウザはすべて使えます。もう必要ないけどIE11でも使えます。

詳しくは下記のCan I useで確認を。
https://caniuse.com/mutationobserver

MutationObserverを使ってPhotoSwipeとSwiperの連携

今回やりたかったのはこれ。

よく使うので、このブログでは過去に何度か紹介しているPhotoSwipeとSwiper。
PhotoSwipeは、ページ上に設置した画像をクリックすると拡大してポップアップ表示することができるJSプラグイン。Swiperは、スライダーを簡単に実装できるJSプラグイン。

スライド画像をクリックすると画像をポップアップして拡大表示させる実装をする時にこの組み合わせをよく使います。

PhotoSwipeとSwiperの連携は過去記事でも書きましたが、過去記事のものはバージョンが少し古くなってしまい、2022年8月現在は記述がかなり変わっています。前の記事ではPhotoSwipeはv4、Swiperはv7でしたが、PhotoSwipeは大きなアップデートがありv5に、Swiperはv8になっています。PhotoSwipeのイベントやメソッドもアップデートされて使い方がかなり変わったので、それに合わせてMutationObserverを使ったPhotoSwipeとSwiperの連携デモを作り直しました。

PhotoSwipe(v5)+ Swiper(v8)のデモ

githubリポジトリはこちら。
https://github.com/inos3910/photoswipe-swiper-demo

▼(2022.10.13追記)PhotoSwipe v5の基本的な使い方については下記。

サンプルコード

サンプルコードは過去記事でPhotoSwipeを紹介した時よりもかなり短くなりました。

HTML

<div class="swiper" id="js-slide">
  <ul class="swiper-wrapper" data-pswp>
    <li class="swiper-slide">
      <a href="assets/images/sample-1.jpg">
        <img src="assets/images/sample-1.jpg" width="1080" height="1080" alt="">
      </a>
    </li>
    <li class="swiper-slide">
      <a href="assets/images/sample-2.jpg">
        <img src="assets/images/sample-2.jpg" width="1080" height="1080" alt="">
      </a>
    </li>
    <li class="swiper-slide">
      <a href="assets/images/sample-3.jpg">
        <img src="assets/images/sample-3.jpg" width="1080" height="1080" alt="">
      </a>
    </li>
  </ul>
  <!-- /.swiper-wrapper -->
  <button type="button" class="swiper-button-next"></button>
  <button type="button" class="swiper-button-prev"></button>
  <div class="swiper-pagination"></div>
</div>

HTMLは、通常のSwiperの設置とほぼ同様です。

異なる点は、PhotoSwipeを実装するための部分。

  • data-pswp属性を.swiper-wrapperクラスの付いた要素に追加。
  • 画像はaタグでくくって、href属性にはポップアップ表示させたい画像のURLを追加。

HTMLは基本これだけでOKです。スライドの数(.swiper-slide要素)は任意で増減してください。

JS

ひとまず完成したコードが下記です。

import imagesLoaded from 'imagesloaded'
import Swiper, {Navigation, Pagination} from 'swiper';
import PhotoSwipeLightbox from 'photoswipe/lightbox';
import PhotoSwipe from 'photoswipe';

class Gallery {
  constructor() {
    this.imagesloaded();
  }

  imagesloaded(){
    const slide = document.getElementById('js-slide');
    if(!slide){
      return;
    }

    imagesLoaded(slide, () => {
      this.swiper = this.slide();
      this.imagePopUp(this.swiper);
    });
  }

  // スライド(Swiper)
  slide() {
    const slide = new Swiper('#js-slide', {
      modules                 : [Navigation, Pagination],
      pagination: {
        el       : '.swiper-pagination',
        type     : 'bullets',
        clickable: true
      },
      navigation: {
        nextEl: '.swiper-button-next',
        prevEl: '.swiper-button-prev',
      },
      loop                    : true,
      preventClicksPropagation: true,
      autoHeight              : true
    });
    return slide;
  }

  // スライド画像のポップアップ(PhotoSwipe)
  imagePopUp(swiper){
    const $target = document.querySelector('[data-pswp]');
    if(!$target){
      return;
    }

    const slides = $target.querySelectorAll('a');

    slides.forEach((el) => {
      const img = el.querySelector('img');
      el.setAttribute('data-pswp-width', img.naturalWidth);
      el.setAttribute('data-pswp-height', img.naturalHeight);
    });

    const lightbox = new PhotoSwipeLightbox({
      gallery              : $target,
      children             : 'a',
      showHideAnimationType: 'zoom',
      pswpModule           : PhotoSwipe
    });

    if(!swiper){
      lightbox.init();
      return;
    }

    // 監視ターゲットの取得
    let target = null;
    const reg = /\d+/;

    // オブザーバーの作成
    const observer = new MutationObserver((mutations) => {
      mutations.forEach((mutation) => {
        const matchCount = mutation.target.innerText.match(reg);
        const index = parseInt(matchCount) - 1;
        if(swiper.activeIndex !== index){
          swiper.slideTo(index);
        }
      });
    });

    lightbox.on('openingAnimationEnd', () => {
      target = document.querySelector('.pswp__counter');
        // 監視の開始
        observer.observe(target, {
          childList: true
        });
      });

    lightbox.on('closingAnimationEnd', () => {
      observer.disconnect();
    });

    lightbox.init();
  }
}

new Gallery();

簡単に内容を説明していきます。

まずSwiperは通常のv8の実装方法をそのまま書きます。
オプションで適当にページネーション・ナビゲーション・ループの設定を入れてます。

import Swiper, {Navigation, Pagination} from 'swiper';

slide() {
  const slide = new Swiper('#js-slide', {
    modules                 : [Navigation, Pagination],
    pagination: {
      el       : '.swiper-pagination',
      type     : 'bullets',
      clickable: true
    },
    navigation: {
      nextEl: '.swiper-button-next',
      prevEl: '.swiper-button-prev',
    },
    loop                    : true,
    preventClicksPropagation: true,
    autoHeight              : true
  });
  return slide;
}

window.on('load', () => {
  slide();
}):

CSSは別途読み込んでください。

<link rel="stylesheet" href="path/to/swiper-bundle.min.css">

デモサイトでは、Sassで@useを使ってnode_modulesから直接引っ張ってきてコンパイルするようにしてます。

@use "swiper/swiper-bundle.min.css";

次にPhotoSwipeの実装。こちらもCSSを読み込ませます。

<link rel="stylesheet" href="path/to/photoswipe.css">

デモサイトはSassで下記のように読み込んでいます。

@use 'photoswipe/dist/photoswipe.css';

JSは下記。v5の公式ドキュメントに合わせて実装します。

import PhotoSwipeLightbox from 'photoswipe/lightbox';
import PhotoSwipe from 'photoswipe';

imagePopUp(swiper){
  const $target = document.querySelector('[data-pswp]');
  if(!$target){
    return;
  }

  const slides = $target.querySelectorAll('a');

  slides.forEach((el) => {
    const img = el.querySelector('img');
    el.setAttribute('data-pswp-width', img.naturalWidth);
    el.setAttribute('data-pswp-height', img.naturalHeight);
  });

  const lightbox = new PhotoSwipeLightbox({
    gallery              : $target,
    children             : 'a',
    showHideAnimationType: 'zoom',
    pswpModule           : PhotoSwipe
  });
  
  lightbox.init();
}

window.on('load', () => {
  imagePopUp();
}):

v4に比べてかなりコード量減って使いやすくなってます。
ポイントはaタグにdata-pswp-width属性とdata-pswp-height属性の追加をJSで行う処理。

const slides = $target.querySelectorAll('a');
slides.forEach((el) => {
  const img = el.querySelector('img');
  el.setAttribute('data-pswp-width', img.naturalWidth);
  el.setAttribute('data-pswp-height', img.naturalHeight);
});

v5ではaタグにdata-pswp-width(横幅)とdata-pswp-height(高さ)でポップアップ時の画像サイズを指定することが必須になったのですが、いちいち手打ちするのも面倒ですし汎用性がないので、JS側で自動で属性追加するように仕込んでおきます。

ただ、こうする場合は画像が読み込み完了している状態でないとnaturalWidthnaturalHeightは取得できず0が返ってきてしまうので、読み込み完了してからPhotoSwipeを適用した方が良いです。そのためここでは関数を実行するタイミングを画像を含むすべてのリソースの読み込み完了後にしています。

window.on('load', () => {
  imagePopUp();
}):

完成コードではwindow.on('load')の代わりにimagesLoadedライブラリを使って画像の読み込み完了のみを検知して実行するようにしています。ただここではimagesLoadedについての説明は省きたいのでとりあえず。imagesLoadedについては下記の記事をご確認ください。

これでPhotoSwipeも動くようになりました。

あとは、PhotoSwipeとSwiperを連携させる処理。ここでようやくMutationObserverを使います。

具体的には、PhotoSwipeで表示させたポップアップ画面の状態でもPhotoSwipe側の機能でスライドできるようになっているのですが、そのスライドを動かした時にSwiperで実装している方のスライドも合わせて動かす処理です。

もし連携させずにこのまま使うとどうなるか、過去記事でデモサイトを作っていますのでこちらから確認を。

PhotoSwipeとSwiperを連携させずに使った場合のデモ

PhotoSwipeのスライドを動かした後にポップアップ画面を閉じると、閉じる時のアニメーションがずれて変な位置になり、ポップアップ画面上で見ていたスライドと違うものが表示されている状態になってしまいます。

これをMutationObserverで解決します。

const lightbox = new PhotoSwipeLightbox({
  gallery              : $target,
  children             : 'a',
  showHideAnimationType: 'zoom',
  pswpModule           : PhotoSwipe
});

// 監視ターゲットの取得
let target = null;
const reg = /\d+/;

// オブザーバーの作成
const observer = new MutationObserver((mutations) => {
  mutations.forEach((mutation) => {
  // PhotoSwipe表示画面左上のスライドのカウント表示を取得し、Swiperをカウントに合わせて動かす
  const matchCount = mutation.target.innerText.match(reg);
  const index = parseInt(matchCount) - 1;
  if(swiper.activeIndex !== index){
    swiper.slideTo(index);
  }
});
});
// ポップアップ画面を表示する時のアニメーションが終わったタイミングで実行
lightbox.on('openingAnimationEnd', () => {
  target = document.querySelector('.pswp__counter');
  // 監視の開始
  observer.observe(target, {
    childList: true
  });
});
// ポップアップ画面を閉じる時のアニメーションが終わったタイミングで実行
lightbox.on('closingAnimationEnd', () => {
  observer.disconnect();
});

lightbox.init();

PhotoSwipeとSwiperを連携させるには、現在表示されているスライドが何番目のスライドなのかが必要になります。PhotoSwipeのドキュメントを見ても現在表示されているスライドはライブラリ側で取得する方法が用意されていなさそうなので、ポップアップ画面左上のカウント表示から現在表示されているスライドのカウント表示を取得します。スライドが切り替わったタイミングでカウントのDOMが変更されるので、MutationObserverでカウントを監視して変化後の値を取得します。PhotoSwipeのイベントAPIを使って、ポップアップを表示したタイミングでカウントのDOM監視を開始、ポップアップを閉じるタイミングで監視を終了します。

これで連携は完了です!

完成デモ

PhotoSwipe(v5)+ Swiper(v8)のデモ

githubリポジトリはこちら。
https://github.com/inos3910/photoswipe-swiper-demo

デモ作成後に気づいたこと

別にMutationObserver使わなくてもPhotoSwipeは現在表示されているスライドの順番を取得できましたw

lightbox.on('change', () => {
  swiper.slideTo(lightbox.pswp.currIndex);
});

lightbox.pswp.currIndexがそれ。わざわざ変なところでDOM監視しなくても良かった・・・。

最終的には下記のようなコードに収まりました。

import imagesLoaded from 'imagesloaded'
import Swiper, {Navigation, Pagination} from 'swiper';
import PhotoSwipeLightbox from 'photoswipe/lightbox';
import PhotoSwipe from 'photoswipe';

class Gallery {
  constructor() {
    this.imagesloaded();
  }

  imagesloaded(){
    const slide = document.getElementById('js-slide');
    if(!slide){
      return;
    }

    imagesLoaded(slide, () => {
      this.swiper = this.slide();
      this.imagePopUp(this.swiper);
    });
  }

  // スライド(Swiper)
  slide() {
    const slide = new Swiper('#js-slide', {
      modules                 : [Navigation, Pagination],
      pagination: {
        el       : '.swiper-pagination',
        type     : 'bullets',
        clickable: true
      },
      navigation: {
        nextEl: '.swiper-button-next',
        prevEl: '.swiper-button-prev',
      },
      loop                    : true,
      preventClicksPropagation: true,
      autoHeight              : true
    });
    return slide;
  }

  // スライド画像のポップアップ(PhotoSwipe)
  imagePopUp(swiper){
    const $target = document.querySelector('[data-pswp]');
    if(!$target){
      return;
    }

    const slides = $target.querySelectorAll('a');

    slides.forEach((el) => {
      const img = el.querySelector('img');
      el.setAttribute('data-pswp-width', img.naturalWidth);
      el.setAttribute('data-pswp-height', img.naturalHeight);
    });

    const lightbox = new PhotoSwipeLightbox({
      gallery              : $target,
      children             : 'a',
      showHideAnimationType: 'zoom',
      pswpModule           : PhotoSwipe
    });

    if(!swiper){
      lightbox.init();
      return;
    }

    lightbox.on('change', () => {
      swiper.slideTo(lightbox.pswp.currIndex);
    });

    lightbox.init();
  }
}

new Gallery();

過去記事と比較するとかなりコード短くなってスッキリしました。
最終的にMutationObserver使ってないので記事内容から脱線してしまいましたが、試行錯誤したことで使い方と使い所はわかったので良かったです。

おわりに

MutationObserverについてメモしました。最終的にはPhotoSwipeメインの話になってしまいましたが・・・笑

今回はPhotoSwipe(v5)とSwiper(v8)の連携デモという特殊な使い方について書きましたが、MutationObserverは使い所がたくさんある便利なAPIなので他にもいろいろと使えて重宝しています。

今回のように「自分が書いたコード以外のところでDOMが変更される場合」にけっこう使えます。例えば、チャットボットのような外部スクリプトでAjax等でDOMに追加される要素・要素の属性の変化に合わせて何か処理したい場合などです。

外部スクリプト側で吐き出しているDOMに変更を加えたい場合に、JSファイルのどこにその処理が書かれているのか、どういう構造になっているのか、カスタマイズする必要があるのか、といったことを調べたり気にすることなく、とりあえず変更されているDOMさえわかればその部分だけ監視して何か実行する、といったことが可能になるので便利です。

また、ライブラリではなくWeb APIとして備わっている機能を使うだけなので重くもないです。

正直あまり登場回数は多くありませんが、混み入ったことをしたい時にあって良かった!と思える機能です。