18 жовтня 2024 р.

Mutation Observer (спостерігач за мутаціями)

MutationObserver – це вбудований об’єкт, який спостерігає за елементом DOM і викликає функцію зворотного виклику кожного разу, коли він помічає зміну.

Спочатку ми поглянемо на синтаксис, а потім дослідимо реальний приклад, щоб побачити, де така річ може знадобитися.

Синтаксис

MutationObserver – простий у використанні.

Спершу ми створюємо спостерігача із функцією зворотного виклику:

let observer = new MutationObserver(callback);

А потім прикріплюємо його до вузла DOM:

observer.observe(node, config);

config – це об’єкт із булевими опціями, які вказують “на якого роду зміни слід реагувати”:

  • childList – зміни в безпосередніх нащадках вузла node,
  • subtree – у всіх нащадках вузла node,
  • attributes – атрибути вузла node,
  • attributeFilter – масив назв атрибутів, щоб спостерігати лише за певними з них.
  • characterData – чи спостерігати за змінами в node.data (текстовий вміст),

Декілька інших опцій:

  • attributeOldValue – якщо вказана як true – до функції зворотного виклику буде передано і нове, і старе значення атрибута (дивіться нижче), а інакше передаватиме лише нове значення (потребує вказаної опції attributes),
  • characterDataOldValue – якщо дорівнює true – до функції зворотного виклику буде передано і нове, і старе значення node.data (дивіться нижче), а інакше передаватиме лише нове значення (потребує вказаної опції characterData).

Далі після будь-яких змін виконується callback: зміни передаються першим аргументом у вигляді списку об’єктів типу MutationRecord, а другим аргументом передається сам спостерігач.

Об’єкти MutationRecord містять такі властивості:

  • type – тип мутації, одне з:
    • "attributes": змінився атрибут
    • "characterData": змінилися дані, використовуються для текстових вузлів,
    • "childList": додані/прибрані дочірні елементи,
  • target – де саме відбулася зміна: для "attributes" це елемент, або текстовий вузол у випадку "characterData", або елемент для мутації типу "childList",
  • addedNodes/removedNodes – вузли, які було додано/прибрано,
  • previousSibling/nextSibling – відповідно попередній та наступний елемент відносно доданих/прибраних вузлів,
  • attributeName/attributeNamespace – ім’я/простір імен (для XML) зміненого атрибута,
  • oldValue – попереднє значення, лише для змін в атрибуті або тексті, за умови, що встановлено відповідний параметр attributeOldValue/characterDataOldValue.

Наприклад, ось елемент <div> із атрибутом contentEditable. Цей атрибут дає нам змогу переміщувати фокус на нього і редагувати.

<div contentEditable id="elem">Натисни і <b>редагуй</b>, будь ласка</div>

<script>
let observer = new MutationObserver(mutationRecords => {
  console.log(mutationRecords); // console.log(зміни)
});

// спостерігати за всім окрім атрибутів
observer.observe(elem, {
  childList: true, // спостерігати за безпосередніми нащадками
  subtree: true, // і також за глибшими нащадками
  characterDataOldValue: true // передавати старі дані до функції зворотного виклику
});
</script>

Якщо ми запустимо цей код у браузері, потім помістимо фокус на цей <div> і змінимо текст всередині <b>редагуй</b>, console.log покаже одну мутацію:

mutationRecords = [{
  type: "characterData",
  oldValue: "edit",
  target: <text node>,
  // інші властивості порожні
}];

Якщо ми виконаємо складніші операції редагування, наприклад видалимо <b>редагуй</b>, подія мутації можливо міститиме декілька записів змін:

mutationRecords = [{
  type: "childList",
  target: <div#elem>,
  removedNodes: [<b>],
  nextSibling: <text node>,
  previousSibling: <text node>
  // інші властивості порожні
}, {
  type: "characterData"
  target: <text node>
  // ...деталі мутації залежать від того, як браузер обробляє таке видалення
  // він може злити два сусідні текстові вузли "редагуй " і ", будь ласка" в один
  // або залишити їх окремими вузлами
}];

Отже, MutationObserver дає змогу реагувати на будь-які зміни всередині піддерева DOM.

Застосування для інтеграції

В яких випадках така річ може стати корисною?

Уявіть ситуацію, коли вам потрібно додати скрипт від третіх осіб, що містить корисну функціональність, проте також робить щось небажане, наприклад показує рекламу <div class="ads">Небажана реклама</div>.

Природньо, цей скрипт від третіх осіб не надає жодних механізмів для її видалення.

Застосувавши MutationObserver ми можемо помітити, коли небажаний елемент з’являється всереднині нашої DOM, і видалити його.

Трапляються інші ситуації, коли скрипт від третіх осіб додає щось до нашого документа, і нам би хотілося помітити, коли це відбувається, щоб адаптувати нашу сторінку, динамічно змінити розмір чогось тощо.

MutationObserver дає можливість це реалізувати.

Застосування в архітектурі

Також трапляються ситуації, коли MutationObserver є корисним з точки зору архітектури.

Припустимо, ми робимо вебсайт про програмування. Природньо, статті та інші матеріали можуть містити фрагменти програмного коду.

Такий фрагмент всередині HTML-розмітки виглядає так:

...
<pre class="language-javascript"><code>
  // Тут знаходиться код
  let hello = "world";
</code></pre>
...

Для кращої прочитності і, в той же час для естетичності, ми будемо використовувати JavaScript-бібліотеку для підсвітки синтаксису на нашому вебсайті, на кшталт Prism.js. Для отримання синтаксичної підсвітки за допомогою Prism для наведеного вище фрагмента, викликається Prism.highlightElem(pre), який перевіряє вміст таких елементів pre, та додає в них особливі теги і стилі для кольорової підсвітки синтаксису, подібно до того, що ви можете побачити в прикладах тут, на цій сторінці.

Коли саме нам слід запускати такий метод для додавання підсвітки? Що ж, ми можемо це робити на подію DOMContentLoaded, або поставити скрипт внизу сторінки. Як тільки наша DOM готова, ми можемо виконати пошук елементів pre[class*="language"] та викликати на них Prism.highlightElem:

// підсвітити всі фрагменти коду на сторінці
document.querySelectorAll('pre[class*="language"]').forEach(Prism.highlightElem);

Досі все просто, правда? Ми шукаємо фрагменти коду всередині HTML і розфарбовуємо їх.

Тепер продовжимо. Скажімо, ми збираємося динамічно забирати матеріали зі сервера. Ми вивчимо способи це зробити далі в посібнику. Що важливо наразі – це те, що ми забираємо статтю з HTML із сервера, і показуємо її на вимогу:

let article = /* отримаємо новий вміст із сервера */
articleElem.innerHTML = article;

HTML-вміст нової статті article може містити фрагменти коду. Нам потрібно викликати на них Prism.highlightElem, інакше підсвітки на них не буде.

Коли і де нам слід викликати Prism.highlightElem для динамічно завантаженої статті ?

Ми могли б прикріпити цей виклик до коду, який завантажує статтю, ось так:

let article = /* отримаємо новий вміст із сервера */
articleElem.innerHTML = article;

let snippets = articleElem.querySelectorAll('pre[class*="language-"]');
snippets.forEach(Prism.highlightElem);

…Але, уявімо, що у нас є багато місць в коді, де ми завантажуємо наш вміст: статті, опитники, форумні дописи тощо. Чи повинні ми всюди вставляти виклик підсвітки, аби виконати підсвітку синтаксису коду всередині вмісту після завантаження? Це не надто зручно.

І що, якщо вміст було завантажено стороннім модулем? Наприклад, якщо ми маємо форум, написаний кимось іншим, який динамічно завантажує вміст, і нам би хотілося додати до нього підсвітку синтаксису. Ніхто не любить правити сторонні скрипти.

На щастя, є інший спосіб.

Ми можемо використати MutationObserver для автоматичного виявлення моментів, коли фрагменти коду вставляються у сторінку, і виконання підсвітки на них.

Отже, ми виконаємо функціональність підсвітки в одному місці, позбавивши нас клопоту з її інтеграцією.

Демонстрація динамічної підсвітки

Ось робочий приклад.

Якщо ви запустите цей код, він почне спостерігати за наведеним нижче елементом, і підсвічувати будь який фрагмент коду, який з’являтиметься там:

let observer = new MutationObserver(mutations => {

  for(let mutation of mutations) {
    // перевіряємо вузли, чи є тут щось для розфарбування?

    for(let node of mutation.addedNodes) {
      // ми стежимо лише за елементами, пропустимо інші вузли (наприклад текстові вузли)
      if (!(node instanceof HTMLElement)) continue;

      // перевіримо, чи вставлений елемент є фрагментом коду
      if (node.matches('pre[class*="language-"]')) {
        Prism.highlightElement(node);
      }

      // чи можливо фрагмент коду десь в глибині його піддерева?
      for(let elem of node.querySelectorAll('pre[class*="language-"]')) {
        Prism.highlightElement(elem);
      }
    }
  }

});

let demoElem = document.getElementById('highlight-demo');

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

Тут, нижче наведено HTML-елемент і JavaScript, що динамічно заповнюють його за допомогою innerHTML.

Будь ласка, запускайте попередній код (наведено вище, для спостереження за елементом), а потім код, наведений нижче. Ви помітите, як MutationObserver виявляє і виконує підсвітку у фрагменті.

Демо-елемент із id="highlight-demo", запустіть наведений вище код для спостереження за ним.

Наступний код заповнює свій innerHTML, що змушує MutationObserver реагувати і підсвічувати його вміст:

let demoElem = document.getElementById('highlight-demo');

// динамічно вставляє вміст із фрагментами коду
demoElem.innerHTML = `Фрагмент коду -- нижче:
  <pre class="language-javascript"><code> let hello = "world!"; </code></pre>
  <div>І ще один:</div>
  <div>
    <pre class="language-css"><code>.class { margin: 5px; } </code></pre>
  </div>
`;

Тепер у нас є MutationObserver, який відстежує всю підсвітку у елементах, за якими спостерігає, або в цілому document. Ми можемо додавати/прибирати фрагменти коду з HTML не думаючи про це.

Додаткові методи

Існує метод для зупинки спостереження за вузлом:

  • observer.disconnect() – зупиняє спостереження.

Коли ми припиняємо спостереження, може трапитися ситуація, що деякі зміни ще не були оброблені спостерігачем. В таких випадках, ми вживаємо

  • observer.takeRecords() – отримує перелік необроблених записів мутацій – тих, що трапилися, проте функція зворотного виклику їх ще не опрацювала.

Ці методи можна застосовувати разом, як це показано далі:

// отримання переліку необроблених мутацій
// повинно викликатись перед роз'єднанням,
// якщо для вас важливі потенційно необроблені недавні мутації
let mutationRecords = observer.takeRecords();

// зупинка відстежування змін
observer.disconnect();
...
Записи, повернені методом observer.takeRecords() видаляються із черги обробки

Функцію зворотного виклику не буде викликано для записів, повернених методом observer.takeRecords().

Взаємодія зі збиранням сміття

Всередині спостерігачів використовуються слабкі посилання на вузли. Це означає, що якщо вузол видаляється із DOM, і стає недоступним – збирач сміття зможе його прибрати.

Сам факт спостереження за вузлом DOM не перешкоджає процесу збирання сміття.

Підсумки

MutationObserver може реагувати на зміни в DOM, зокрема на зміни в атрибутах, текстовому вмісті, та додавання/прибирання елементів.

Ми можемо використовувати його для відстеження змін, внесених іншими частинами нашого коду, так само як і інтегруватися зі сторонніми скриптами.

MutationObserver може відстежувати будь які зміни. Поля конфігурації для вказівки “за чим саме спостерігати” використовуються для оптимізації, аби не витрачати ресурсів на непотрібні виклики функцій зворотного виклику.

Навчальна карта