JSでDOMを追加・変更・削除した時に合わせて何かしたいことがたまにあります。
VueやReactなんかのフレームワークを使う場合は処理できるのですが、フレームワークを使っていない普通のWebサイトの場合に「ここのDOMが消えた時にクラスを追加したい」「ここのDOMが変わった時に変更後のDOMを取得したい」とか、DOMの変更に合わせて何かしたい時が出てきます。
それが数十箇所とかある場合は、そもそもフレームワーク使った方が良いかもしれません。ただし、1カ所だけとかの場合、そのためにフレームワーク導入するのはちょっと重たいです。
そういう時に使えるAPIがMutationObserverです。
MutationObserverはDOMの変更を監視するAPIです。使い方の例としてサンプルコードを書きました。
<div id="js-target"></div>
//監視する要素
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 // すべての子孫ノードの変更を監視
});
監視を開始する時は、childList
、attributes
、characterData
、の3つのうちいずれかをtrue
に指定する必要があります。
モダンブラウザはすべて使えます。もう必要ないけどIE11でも使えます。
詳しくは下記のCan I useで確認を。
https://caniuse.com/mutationobserver
今回やりたかったのはこれ。
よく使うので、このブログでは過去に何度か紹介しているPhotoSwipeとSwiper。
PhotoSwipeは、ページ上に設置した画像をクリックすると拡大してポップアップ表示することができるJSプラグイン。Swiperは、スライダーを簡単に実装できるJSプラグイン。
スライド画像をクリックすると画像をポップアップして拡大表示させる実装をする時にこの組み合わせをよく使います。
PhotoSwipeとSwiperの連携は過去記事でも書きましたが、過去記事のものはバージョンが少し古くなってしまい、2022年8月現在は記述がかなり変わっています。前の記事ではPhotoSwipeはv4、Swiperはv7でしたが、PhotoSwipeは大きなアップデートがありv5に、Swiperはv8になっています。PhotoSwipeのイベントやメソッドもアップデートされて使い方がかなり変わったので、それに合わせてMutationObserverを使ったPhotoSwipeとSwiperの連携デモを作り直しました。
githubリポジトリはこちら。
https://github.com/inos3910/photoswipe-swiper-demo
▼(2022.10.13追記)PhotoSwipe v5の基本的な使い方については下記。
サンプルコードは過去記事でPhotoSwipeを紹介した時よりもかなり短くなりました。
<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
要素)は任意で増減してください。
ひとまず完成したコードが下記です。
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側で自動で属性追加するように仕込んでおきます。
ただ、こうする場合は画像が読み込み完了している状態でないとnaturalWidth
・naturalHeight
は取得できず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監視を開始、ポップアップを閉じるタイミングで監視を終了します。
これで連携は完了です!
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として備わっている機能を使うだけなので重くもないです。
正直あまり登場回数は多くありませんが、混み入ったことをしたい時にあって良かった!と思える機能です。