Эксперимент: Автоматическое включение View Transitions с MutationObserver

Источник: «Experiment: Automatically triggered View Transitions with MutationObserver»
Вместо того чтобы добавлять document.startViewTransition в различные места JS, используйте MutationObserver, следящий за мутациями DOM. В обратном вызове Observer'а отмените исходную мутацию и примените её снова, но на этот раз обернув во View Transition.

Потребность в автоматических View Transitions

Недавно на BlueSky Cory LaViska задал вопрос о View Transitions в рамках одного документа:

I wish I could opt certain elements in to View Transitions so all DOM modifications would just work without having to wrap with document.startViewTransition() 🤔

— Cory LaViska (@cory.laviska.com) November 25, 2024 at 9:11 AM

Это очень правильный запрос (feature request), а также то, что нам с коллегой Адамом уже было нужно. Посмотрите следующую демонстрацию, запускающую View Transition в ответ на отметку радиокнопки.

See the Pen

Чтобы это работало, необходимо перехватить выбор радио, отменить его, а затем снова применить, но на этот раз обернув во View Transition.

Код, реализующий это, выглядит следующим образом:

document.querySelectorAll('.item input').forEach(($input) => {
// @note: мы слушаем click, потому что это происходит *перед* событием change.
// Таким образом, можно предотвратить проверку input, а затем повторно применить
// выделение, обернув его вызовом `document.startViewTransition()`.
$input.addEventListener('click', async (e) => {
if (!document.startViewTransition) return;

e.preventDefault();

document.startViewTransition(() => {
e.target.checked = true;
});
});
});

Как сказал Cory, было бы здорово, если бы это работало без дополнительного кода. Что, если бы не нужно было перехватывать событие click или загромождать JS-логику вызовами document.startViewTransition, а было бы что-то, что позволило бы сказать: Когда это изменится, сделай это с помощью View Transition в том же документе? Это было бы очень хорошо, просто и надёжно.

Автотриггер View Transition с MutationObserver

По просьбе Cory я создал PoC, призванный дать ответ на эту проблему. В качестве отправной точки используется следующая демонстрация, позволяющая добавлять и удалять карты в списке.

See the Pen

Без View Transitions суть этой демонстрации заключается в следующем:

document.querySelector('.cards').addEventListener('click', e => {
if (e.target.classList.contains('delete-btn')) {
e.target.parentElement.remove();
}
})

document.querySelector('.add-btn').addEventListener('click', async (e) => {
const template = document.getElementById('card');
const $newCard = template.content.cloneNode(true);
$newCard.firstElementChild.style.backgroundColor = `#${ Math.floor(Math.random()*16777215).toString(16)}`;
document.querySelector('.cards').appendChild($newCard);
});

Вместо того чтобы изменить приведённый выше код для включения View Transitions, как это было сделано в предыдущей вставке, я использовал добавление в код MutationObserver. MutationObserver используется для выполнения обратного вызова, когда он наблюдает изменение DOM. В обратном вызове я настроил его на автоматическую отмену и повторное применение проведённой мутации. Например, когда в список добавляется карточка, из него удаляется только что добавленный элемент, а затем он снова добавляется, обёрнутый в document.startViewTransition. Это работает потому, что обратные вызовы MutationObserver ставятся в очередь как микрозадачи, способные заблокировать рендеринг.

const observer = new MutationObserver(async (mutations) => {
for (const mutation of mutations) {
// Был добавлен узел
if (mutation.addedNodes.length) {
const $li = Array.from(mutation.addedNodes).find(n => n.nodeName == 'LI');

// …

// Отмена добавления, а затем повторное добавление в VT
$li.remove();
const t = document.startViewTransition(() => {
mutation.target.insertBefore($li, mutation.nextSibling);
});

// …
}
}
});
observer.observe(document.querySelector('.cards'), {
childList: true,
characterData: false,
});

Благодаря этому коду процесс создания снапшотов для View Transitions может правильно фиксировать старое и новое состояние при добавлении карточки: одно без нового добавленного элемента, а другое с ним.

Аналогично происходит и при удалении карточки: она сразу добавляется заново, и только потом удаляется через View Transition. Также имеется логика, предотвращающая блокировку рендеринга обратным вызовом на неопределённое время, поскольку вызов $li.remove();, сделанный в обратном вызове, вызовет новую мутацию.

В совокупности это даёт следующий результат:

See the Pen

Не решается с MutationObserver

В этом POC не рассматриваются такие изменения, как установка флажка для радиокнопок. Это происходит потому, что такие изменения не являются изменениями, наблюдаемыми MutationObserver: изменяется свойство DOM элемента, а не атрибут. Чтобы решить эту проблему, можно использовать что-то вроде моего StyleObserver для активации наблюдаемого изменения, когда чекбокс/радио изменяется на :checked. Проблема, однако, в том, что изменения StyleObserver срабатывают слишком поздно: поскольку изменения срабатывают после рендеринга, вы получаете глюк в 1 кадр при повторном применении изменений. Смотрите следующую вставку, в которой я подправил демонстрацию Adam'а Bento, чтобы использовать @bramus/style-observer для запуска View Transition:

See the Pen

В идеале нужен либо StyleObserver, срабатывающий перед рендерингом, либо что-то вроде расширения MutationObserver, позволяющее также отслеживать изменения свойств.

Также не рассматриваются пакетные обновления — например, когда элементы удаляются в одном месте и добавляются в другом. В демонстрации выше я обошёл эту проблему, вручную сгруппировав мутации в пары, прежде чем обрабатывать их как один набор изменений.

Комментарии


Дополнительные материалы

Предыдущая Статья

Использовать двойные кавычки или нет

Следующая Статья

Web Performance API: Измерьте важное