Эксперимент: Автоматическое включение View Transitions с 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 в ответ на отметку радиокнопки.
Чтобы это работало, необходимо перехватить выбор радио, отменить его, а затем снова применить, но на этот раз обернув во 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, призванный дать ответ на эту проблему. В качестве отправной точки используется следующая демонстрация, позволяющая добавлять и удалять карты в списке.
Без 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();
, сделанный в обратном вызове, вызовет новую мутацию.
В совокупности это даёт следующий результат:
Не решается с MutationObserver
В этом POC не рассматриваются такие изменения, как установка флажка для радиокнопок. Это происходит потому, что такие изменения не являются изменениями, наблюдаемыми MutationObserver
: изменяется свойство DOM элемента, а не атрибут. Чтобы решить эту проблему, можно использовать что-то вроде моего StyleObserver
для активации наблюдаемого изменения, когда чекбокс/радио изменяется на :checked
. Проблема, однако, в том, что изменения StyleObserver
срабатывают слишком поздно: поскольку изменения срабатывают после рендеринга, вы получаете глюк в 1 кадр при повторном применении изменений. Смотрите следующую вставку, в которой я подправил демонстрацию Adam'а Bento, чтобы использовать @bramus/style-observer
для запуска View Transition:
В идеале нужен либо StyleObserver
, срабатывающий перед рендерингом, либо что-то вроде расширения MutationObserver
, позволяющее также отслеживать изменения свойств.
Также не рассматриваются пакетные обновления — например, когда элементы удаляются в одном месте и добавляются в другом. В демонстрации выше я обошёл эту проблему, вручную сгруппировав мутации в пары, прежде чем обрабатывать их как один набор изменений.