ResizeObserver — новый мощный инструмент для работы с адаптивностью

Автор оригинала: Khrystyna Skvarok
  • Перевод
Доброго времени суток, друзья!

«Отзывчивый» является одним из стандартов веб-разработки. Существует большое количество разрешений экрана, и это количество все время увеличивается. Мы стремимся поддерживать все возможные размеры экранов с сохранением дружелюбного пользовательского интерфейса. Отличным решением данной задачи являются медиа-запросы (media-queries). Но что насчет веб-компонентов? Современная веб-разработка основана на компонентах, и нам нужен способ делать их отзывчивыми. Сегодня я хочу рассказать о ResizeObserver API, позволяющим следить (наблюдать) за изменениями размеров конкретного элемента, а не всей области просмотра (viewport), как в случае с медиа-запросами.

Немного истории


Раньше в нашем распоряжении имелись лишь медиа-запросы — решение на CSS, основанное на размере, типе и разрешении экрана медиа устройства (под медиа устройством я подразумеваю компьютер, телефон или планшет). Медиа-запросы являются достаточно гибкими и простыми в использовании. Долгое время медиа-запросы были доступны только в CSS, сейчас они также доступны в JS через window.matchMedia(mediaQueryString). Теперь мы можем проверять, с какого устройства просматривается страница, а также следить за изменением размера области просмотра (речь идет о методе MediaQueryList.addListener() — прим. пер.).

Запросы размеров элемента (Element Queries)


Чего нам не хватало, так это возможности следить за размерами отдельного элемента DOM, а не всего «вьюпорта». Разработчики сетуют на это на протяжении многих лет. Это одна из самых ожидаемых особенностей. В 2015 году даже выдвигалось предложение — запросы размеров контейнера (Container Queries):
Разработчики часто нуждаются в возможности стилизовать элементы при изменении размера их родительского контейнера, независимо от области просмотра. Запросы размеров контейнера предоставляют им такую возможность. Пример использования в CSS:
.element:media(min-width: 30em) screen {***}
Звучит здорово, но у производителей браузеров была серьезная причина отклонить это предложение — круговая зависимость (circular dependency) (когда один размер определяет другой, это приводит к бесконечной петле (infinite loop); подробнее об этом можно почитать здесь). Какие еще варианты существуют? Мы можем использовать window.resize(callback), но это «дорогое удовольствие» — callback будет вызываться каждый раз при возникновении события, и нам потребуется много вычислений для определения того, что размер нашего компонента действительно изменился…

Наблюдение за изменениями размеров элемента с помощью ResizeObserver API


Встречайте, ResizeObserver API от Chrome:
ResizeObserver API — это интерфейс слежения за изменениями размеров элемента. Это своего рода аналог события window.resize для элемента.

ResizeObserver API — это «живой черновик». Он уже реализован в Chrome, Firefox и Safari для ПК. Поддержка мобильных менее впечатляющая — только Chrome на Android и Samsung Internet. К сожалению, полноценного полифила не существует. Имеющиеся полифилы содержат некоторые ограничения (например, медленная реакция на изменение размера или отсутствие поддержки плавного перехода). Однако это не должно останавливать нас от тестирования данного API. Так давайте же сделаем это!

Пример: изменение текста при изменении размера элемента


Представим следующую ситуацию — текст внутри элемента должен меняться в зависимости от размера элемента. ResizeObserver API предоставляет два инструмента — ResizeObserver и ResizeObserverEntry. ResizeObserver используется для слежения за изменением размера элемента, а ResizeObserverEntry содержит сведения об элементе, размер которого изменился.
Код очень простой:

<h1> size </h1>
<h2> boring text </h2>

const ro = new ResizeObserver(entries => {
    for(let entry of entries){
        const width = entry.contentBoxSize
        ? entry.contentBoxSize.inlineSize
        : entry.contentRect.width

        if(entry.target.tagName === 'H1'){
            entry.target.textContent = width < 1000 'small' : 'big'
        }

        if(entry.target.tagName === 'H2' && width < 500){
            entry.target.textContent = `I won't change anymore`
            ro.unobserve(entry.target) // прекращаем наблюдение, когда ширина элемента достигла 500px
        }
    }
})

// мы можем следить за любым количеством элементов
ro.observe(document.querySelector('h1'))
ro.observe(document.querySelector('h2'))

В начале создаем объект ResizeObserver и передаем ему в качестве параметра функцию обратного вызова:

const resizeObserver = new ResizeObserver((entries, observer) => {
    for(let entry of entries){
        // логика
    }
})

Функция вызывается каждый раз, когда происходит изменение размеров одного из элементов, содержащихся в ResizeObserverEntries. Второй параметр функции обратного вызова это сам observer. Мы можем использовать его, например, для остановки слежения при выполнении определенного условия.

Callback получает массив ResizeObserverEntry. Каждая запись (entry) содержит размеры наблюдаемого элемента (target).

for(let entry of entries){
    const width = entry.contentBoxSize
    ? entry.contentBoxSize.inlineSize
    : entry.contentRect.width

    if(entry.target.tagName === 'H1'){
        entry.target.textContent = width < 1000 ? 'small' : 'big'
    }
    ...
}

У нас имеется три свойства, описывающих размеры элемента — borderBoxSize, contentBoxSize и contentRect. Они представляют блочную модель (box model) элемента, о которой мы поговорим позже. Сейчас несколько слов о поддержке. Лучше всего браузеры поддерживают contentRect, однако, судя по всему, это свойство будет признано устаревшим:
contentRect появился на предварительной стадии разработки ResizeObserver и был добавлен только в интересах текущей совместимости. Вероятно, в будущем он будет признан устаревшим.


Поэтому я бы настоятельно рекомендовала использовать contentRect совместно с bordeBoxSize и contentBoxSize. ResizeObserverSize включает в себя два свойства: inlineSize и blockSize, которые можно интерпретировать как ширину и высоту (при условии, что мы работаем в горизонтальной ориентации текста — writing-mode: horizontal).

Наблюдение за элементом


Последнее, что нужно сделать — это запустить слежение за элементом. Для этого мы вызываем ResizeObserver.observe(). Данный метод добавляет новую цель в список наблюдаемых элементов. Мы можем добавлять в этот список как один, так и сразу несколько элементов:

// resizeObserver(target, options)
ro.observe(document.querySelector('h1'))
ro.observe(document.querySelector('h2'))

Второй параметр является «опциональным». На сегодняшний день единственной доступной опцией является box, определяющей блочную модель. Возможными значениями являются content-box (по умолчанию), border-box и device-pixel-content-box (только Chrome). В одном ResizeObserver можно определить только одну блочную модель.

Для остановки наблюдения следует использовать ResizeObserver.unobserve(target). Для того, чтобы прекратить слежение за всеми элементами следует использовать ResizeObserver.disconnect().

Блочная модель


Content box — это содержимое блока без padding, border и margin. Border box включает padding и border (без margin).



Device pixels content box — это содержимое элемента в физических пикселях. Я не встречала примеров использования этой модели, но, похоже, это может пригодиться при работе с холстом (canvas). Вот интересное обсуждение данной темы на Github.

Когда «наблюдатель» (observer) узнает об изменениях?


Callback вызывается каждый раз при изменении размера целевого элемента. Вот что об этом говорится в спецификации:

  • Наблюдение (observation) срабатывает, когда наблюдаемый элемент добавляется/удаляется из DOM.
  • Наблюдение срабатывает, когда свойство display наблюдаемого элемента принимает значение none.
  • Наблюдение не работает для «незамещаемых» строчных элементов.
  • Наблюдение не работает в CSS трансформации.
  • Наблюдение работает только для элементов, рендеринг которых завершен, т.е. для элементов, чей размер не равен 0,0.

Согласно первому пункту мы можем определять изменение родительского контейнера при изменении его дочерних элементов. Отличный пример подобного использования ResizeObserver API — прокрутка окна чата вниз при добавлении нового сообщения. Пример можно посмотреть здесь.

Помните о запросах размера контейнера, о которых я упоминала ранее? О его проблеме круговой зависимости? Так вот, ResizeObserver API имеет встроенное решения для предотвращения бесконечной петли «ресайзов». Почитать об этом можно здесь.

Благодарю за внимание.

Полезные ссылки:

Спецификация
MDN
CanIUse
Первая статья от команды разработчиков
Самый популярный полифил
AdBlock похитил этот баннер, но баннеры не зубы — отрастут

Подробнее
Реклама

Комментарии 19

    0

    Интересно, почему не добавили просто новое событие, зачем целый новый класс для этого понадобилось вводить?

      +2

      Чтобы можно было агрегировать обновления и вызывать обработчик один раз на несколько элементов. В статье пример есть


      const ro = new ResizeObserver(entries => {
           // вызовется один раз сразу для всех обозреваемых элементов
      })
      
      // мы можем следить за любым количеством элементов
      ro.observe(document.querySelector('h1'))
      ro.observe(document.querySelector('h2'))

      с событиями были бы отдельные коллбеки на каждый элемент

        0

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


        Даже ничего едкого и сарказмообразного в адрес подобных предложений не хочется добавлять. Просто стдлиб JS ещё на шаг ближе к помойке, не первый раз уже.

          0

          Не знаю как отредактировать в моб. версии и дополнить, так что дополнение в виде коммента:


          Помимо реализации медиатора есть более юзабельная фича под данную задачу и доступна с IE версий времён мамонтов, когда на любые атрибуты тегов (ака свойства DOM) можно подписываться.

            0

            Во-первых, не единственный, у нас уже есть MutationObserver, работающий точно так же.


            Во-вторых, а как бы выглядел код с использованием "обычного медиатора"?

              0
              Во-вторых, а как бы выглядел код с использованием "обычного медиатора"?

              Ну медиатор — это более общая реализация, позволяющая перейти к частной. Мб что-то вроде такого.


              const bus = new Bus(document); 
              
              bus.addEventListener('resize', e => { ... });

              Ну или в виде ещё более частной реализации:


              const bus = new Bus(dom1, dom2);
              
              bus.addEventListener('resize', e => { ... });

              Смысл в единой шине событий для нужных элементов. А "синхронизацию событий" можно добавить дополнительным аргументом или реализацией интерфейса шины событий:


              const bus = new SyncBus(...);

              P.S. Т.е. все мои претензии сводятся к тому, что:
              1) Закостылено под ресайз (нахрена?)
              2) Нарушен дизайн языка (события всегда делались через addEventListener).


              P.P.S. Но вообще, если так уж приспичило, то этот ResizeObserver реализуется, кажется, двумя тычками на нативном JS:


              class ResizeObserver {
                  _events = [];
              
                  constructor(callback) {
                      this.callback = callback;
                  }
              
                  _fire(e) {
                      this._events.push(e);
              
                      if (this.events.length > 0) {
                          requestAnimationFrame(() => {
                              this.callback(this.events);
                              this.events = [];
                          });
                      }
                  }
              
                  observe(el) {
                      el.addEventListener('resize', this._fire);
                  }
              }

              Если что, то я не являюсь JS разработчиком, так что могу ошибаться =)))

                0
                1) Закостылено под ресайз (нахрена?)

                ResizeObserver заточен под ресайз. Что тут не так?


                2) Нарушен дизайн языка (события всегда делались через addEventListener).

                MutationObserver существует со времен IE9, то есть уже лет 10 как.

                  +1
                  ResizeObserver заточен под ресайз. Что тут не так?

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


                  Если ответ будет "да, т.к. это оптимизация, которая нужна", то как часто вы в css прописываете will-change? Ну или как часто каждый addEventListener синхронизируете через requestAnimationFrame? Насколько часто делите стили на пререндер (минимальное отображение и гереация сетки) и рантайм (те, которые стартуются из Vue/CssInJS/etc)?


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


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


                  MutationObserver существует со времен IE9, то есть уже лет 10 как.

                  Если смотреть отдельно, то в IE было невероятно огромное количество уникального функционала для разработчиков, который потом переняли и другие. Но насколько сам браузер выполнял хорошо выполнял свою работу?


                  Ну или другой пример — Opera 12. До сих пор по юзабилити и плюшек для пользователей ему конкурентов особых-то и нет. Но как браузер насколько он качественно выполнял свою работу?


                  А вот в хромиуме (современная опера, хром и прочие) плюс ФФ работает ResizeObserver. Им уже можно пользоваться, пожалуйста. Вы считаете его действительно наиболее полезным, нежели добавление фичи а-ля Swift, когда на любую существующую переменную можно повесить обсверер, вроде такого:


                  let a = 23;
                  a.subscribe((oldValue, newValue) => ...);

                  Т.е. одна фича, которая и перекроет функционал этого MutationObserver (он не нужен будет) и решит все проблемы one\two-way биндингов всех существующих JS фреймворков, и кучу других "чего-то, что придумать можно"?


                  Но если добавят эту фичу (вдруг!), то что делать с функционалом, который теперь не нужен будет (например, упомянутый вами MutationObserver)? А им ведь кто-то пользуется… А ведь обратного пути нет. Лет через 10 разве что… Или 20...




                  Вот именно из-за этого мне и кажется такое решение очередным костылём, которое добавляет ещё больше проблем (в перспективе) и которое призвано закрывать какой-то очень редкий кейс и который можно и так реализовать кодом, похожим на приведённый мною пример выше.


                  Надеюсь я достаточно развёрнуто пояснил свою мысль? =)

                    0
                    Но как браузер насколько он качественно выполнял свою работу?

                    Вполне качественно, до тех пор, пока не устарел.


                    на любую существующую переменную можно повесить обсверер, вроде такого:

                    Не, погодите. Обсерверы для DOM и обсерверы для объектов JS — это совсем разное.

                      0
                      Не, погодите. Обсерверы для DOM и обсерверы для объектов JS — это совсем разное.

                      Только у нас DOM напрямую связан с их моделями. При изменении значений вьюхи меняются данные в JS модели, при изменнии модели — меняется вьюха.


                      let input = document.getElementById('input-el');
                      
                      input.value.subscribe(...);
                      input.value = 'new text';
                        0

                        Ну, связан, замечательно.
                        Я имел в виду, что работа с объектами JS и дёрганье за DOM API — это разного уровня сложности задачи. Подписка на переменную и подписка на свойство DOMElement — под капотом будут по разному работать.

                          0

                          Ну если изменение свойств ДОМа меняет свойства JS (в частности bound rect для ресайза), то почему это должно иметь какое-то значение?

            0
            function cb() {...}
            dom1.addEventListener('resize', cb);
            dom2.addEventListener('resize', cb);


            В чем разница?

            Можно подумать, что ResizeObserver будет вызываться один раз, если поменяются два элемента. Они же не могут измениться в один и тот же момент времени.
            Плюс, опять же, если вы слушаете два элемента, значит это зачем-то надо. Иначе бы следили только за одним.
              0

              Из документации на MDN


              Implementations should, if they follow the specification, invoke resize events before paint and after layout.

              То есть, если элементы поменялись за один проход, без лишних перерисовок, то вы получите один вызов на все подписанные элементы.

                0
                Это я понял. В реальности какая это должна быть ситуация?
                Кроме разве что вложенных элементов, но я не знаю зачем их одновременно слушать понадобится.

                Экономия на несуществующих спичках, которая при этом выворачивает наизнанку событийную модель JS. Не то что бы это плохо, но непривычно точно.
                  0

                  Несколько элементов в одном общем контейнере?

                    0
                    Если они ресайзятся в один фрейм, значит они как-то привязаны по размерам к родителю. Слушайте родитель.

                    Я понимаю, что можно придумать такую ситуацию и может быть она даже окажется нужной. Но к событиям все привыкли, а тут придумали что-то новое ради чего-то нового.
          0

          del

            0
            Лучше бы добавили новое событие для элемента. При его наличии всю функциональность описанную в статье можно было реализовать на rxjs, например

            Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

            Самое читаемое