Полный контроль над контрольными точками. В CSS и в JS
Всем привет! Недавно меня поругали за то, что для гарантии адаптивности вёрстки я применяю дополнительный класс .js-adaptive , который я вешаю на элемент body с помощью JavaScript. К примеру, при ширине экрана 600px и ниже класс .js-adaptive вешается на body , а при ширине 601px и выше этот класс с body убирается. В самих стилях при ширине экрана 600px и ниже я делаю адаптивность вот так:
Здесь вырисовывается явный минус. В адаптивном режиме у нас появляется дополнительный класс .js-adaptive перед всеми селекторами, из-за чего повышается специфичность. Любителям БЭМа такое вряд ли понравится, да и вообще, не очень-то это и хороший подход на самом деле, тем более, что для таких целей у нас есть медиавыражения в CSS.
Сразу уточню, что решение данной проблемы у меня нашлось, и даже не одно. Для самых нетерпеливых сразу же дам ссылку на них.
Почему я обратился к дополнительному классу .js-adaptive ? Дело в том, что на практике оказалось, что медиавыражения в CSS расходятся во мнениях с такими штуками, как $(window).width() и window.innerWidth . Чуть позже я на примерах покажу, что я имею ввиду, а пока я поясню, зачем мне понадобились $(window).width() и window.innerWidth вместе с медиавыражениями.
Задача
Представьте себе такую задачу. Есть навигация, в которую мы для маленькой ширины (<=600px) из двух разных меню делаем одно гамбургерное (объединяя их пункты), а при ширине 601px и выше эти меню должны снова делиться на два. Понятно, что для этого мы используем JavaScript, чтобы отловить ширину и действовать в зависимости от неё. А теперь представьте, что вдобавок к этому при ширине <=600px должна включаться адаптивность, то есть все блоки/элементы должны менять свой внешний вид, готовясь к мобильным экранам. Согласитесь, что задача такого совмещения JavaScript (для своих целей) и медиавыражений (так же для своих целей) очень частая? Собственно, это и есть та причина, по которой нам нужно использовать JavaScript вместе с медиавыражениями.
Проблема
И вот тут как раз возникает проблема. Для наглядности я сделал примеры на codepen.io. Поскольку наша задача состоит в том, чтобы использовать в одну единицу времени сразу $(window).width() (или window.innerWidth) вместе с медиавыражениями, то очевидно, что контрольная точка (в нашем случае 600px ) у тех и у других должны совпадать. И проблема в том, что они не совпадают.
Покажу на примерах1) Пример без скролла — в этом примере верхний div отвечает за медиавыражения, нижний за $(window).width() (или window.innerWidth ), ну а span — это просто линейка в 600px , правый край которой и является нашей контрольной точкой ( 600px ).
Здесь всё работает отлично. Сузьте экран ровно до правого края span и увидите, как все два div станут синими. А если жмякнуть кнопкой мыши на span , то даже в консоли всё будет ровненько по 600px .
2) Но всё здорово ровно до того момента, пока не появляется вертикальный скролл. Сравним вариант со скроллом. Теперь во всех браузерах кроме Safari сужение экрана до 600px врубает $(window).width() , а вот медиавыражение срабатывает только на 585px . Понятно, что медиавыражение учитывает ширину скролла (у меня это 15px ). Но разночтение с $(window).width() уже очень расстраивает. А вот в Safari всё срабатывает так же, как без скролла, то есть ровненько на 600px . Следовательно, помимо разночтения при скролле мы ещё имеем разночтения в браузерах (Safari vs остальных).
3) Ок, попробуем поменять $(window).width() на window.innerWidth — Вариант со скроллом и window.innerWidth . Теперь во всех браузерах (в Edge не тестил, не суть) кроме Safari медиавыражения и window.innerWidth срабатывают в одной точке — 585px , а вот в Safari медиавыражения срабатывают на 600px , а вот window.innerWidth на 585px .
Промежуточный итог:Получается, что в данном случае нет единого мнения среди браузеров, и у нас нет возможности контролировать медиавыражения вместе с $(window).width() (или window.innerWidth )… или всё же есть? А давайте это и проверим в следующем разделе…
Решение проблемы:
Первым делом, для решения задачи, я как и положено верстальщику, отправился в гугл, и как оказалось, я не первый, кто задавался подобным вопросом, а толкового решения в итоге так и не нашлось. И даже с помощью статьи по теме на https://learn.javascript.ru я ничего не смог сделать. Поэтому мне пришлось кинуть клич во все инстанции чаты/слаки/телеграммы и т.д, о чём я ни капли не пожалел, поскольку добрые и более опытные коллеги поделились со мной аж пятью решениями! Поэтому давайте уже скорее рассмотрим каждое из них:)
1. Хак со свойством font-family элемента <html>
Первое решение подсказал Станислав Ивашкевич (@_sssats). Его идея заключается в том, чтобы вместо измерения скриптом ширины окна проверить, сработало ли медиавыражение, по его результату. И для этого в медиавыражении мы присваиваем свойству font-family элемента <html> значение mobile (можно присвоить любое на ваш вкус, главное, чтобы оно было без кавычек, иначе в в Safari, например, это не сработает), а уже в JavaScript при ресайзе окна просто ждём момента, когда медиавыражение сработает, и всё. Это гарантирует, что адаптивный режим при <=600px включится в одной контрольной точке, и в медиавыражении и в JavaScript.
Да, само собой, можно сделать сколько угодно медиавыражений для разных устройств и экранов. Вот так, к примеру, Станислав использует этот трюк в продакшне.
Развитие идеиМы с моим коллегой SelenIT-ом решили немного развить эту идею. Дело в том, что менять что-либо у элемента <html> немного рисковано, во-первых, потому, что от этого элемента всё наследуется, а во-вторых, может так получится, что у пользователя на компе случайно окажется шрифт mobile , и тогда проблем не миновать. Ведь этот трюк хорош тем, что позволяет использовать не только элемент <html> или свойство font-family , а вообще что угодно. Поэтому мы решили оставить элемент <html> , но вместо font-family использовать псевдоэлемент ::before и его значение content() , в котором можно уже писать любой текст.
В общем, вот что у нас вышло. Поясню некоторые моменты в коде.
Этой строкой мы вычисляем значение свойства content, а далее просто проверяем его при помощи условия…
… и как только оно сработает, ловушка захлопнута :)
Решение с помощью кастомных свойствОбновление от 21.05.2017: ещё один способ подсказал Сергей Артёмов (@firefoxic_arts). Он решил пойти ещё дальше и вместо всяких хаков задействовал кастомные свойства. А суть решения следующая:
Для начала мы объявляем значение переменной --media в селекторе :root
Далее в медиавыражении для мобильных экранов переопределяем значение нашей переменной:
Ну а далее в JavaScript делаем вот что:
Вот собственно и всё. Нам с SelenIT-ом очень нравится решение Сергея, поскольку оно использует CSS-переменные, которые по сути и предназначены для таких целей, и ещё вдобавок с ними код становится чище и избавляет нас от ненужных хаков. Браво Сергей! Единственный минус (а минус ли это?) здесь в том, что кастомные свойства поддерживаются только в современных браузерах, включая Edge 15+. Поэтому смело используйте его, если вам не требуется поддержка IE10-11.
2. Хак с определением display у блока-хелпера
Это решение принадлежит Виталию Емельянцеву (@gambala_rus). Оно немного напоминает предыдущий вариант, но вместо font-family у <html> здесь используется блок-хелпер:
Поначалу мы скрываем его в CSS с помощью display: none , а после в медиавыражении меняем его display на block :
Далее уже в JavaScript мы создаём функцию, которая возвращает true/false в зависимости от значения свойства display у элемента-хелпера:
Ну и последним действием мы просто проверяем результат, который вернула функция, и уже пляшем от него:
Вот собственно и всё, ничего сложного, поэтому не будем тут задерживаться и перейдём к следующему решению.
Кстати, у Виталия есть свой чат в Telegram под названием «Школа Веб 2.0», где он с радостью помогает новичкам (да и не только), отвечая на разные вопросы. Смело задавайте ему вопросы по веб-разработке, CSS, HTML, JS, Ruby on Rails, Дизайну, UI/UX, тайму и таск-менеджменту.
3. Решение с помощью window.matchMedia()
Добрые люди подсказали мне, что оказывается есть такая штука, как window.matchMedia() — метод, принимающий в качестве аргумента строку — наше медиавыражение ( "screen and (min-width: 1px) and (max-width:600px)" ). Он возвращает объект MediaQueryList, у которого есть свойство matches, возвращающее true/false в зависимости от того, совпал ли запрос с размерами экрана или нет. Выглядит это очень просто:
Поскольку в объекте MediaQueryList мы проверяем то же самое медиавыражение, что в CSS, и по той же самой логике, мы можем быть уверены, что срабатывать оно будет в той же самой точке.
Насчёт поддержки браузами matchMedia можете вообще не париться, она прекрасна. IE10+, Safari и другие современные браузеры поддерживают эту штуку на ура!
Кстати, я настоятельно рекомендую познакомиться с этим window.matchMedia() поближе, поскольку его возможности не ограничиваются нашей задачей. Советую начать вот с этой статьи на русском, а после обратиться к MDN (раз, два) и самой спецификации.
4. Хак с определением ширины скроллбара + window.matchMedia()
А это решение принадлежит Владимиру Кузнецову (@mista_k). Уверен, что вы уже читали его популярный блог. Его идея состоит из нескольких этапов. Давайте разберём их.
Первым этапом будет вычисление ширины скроллбара. Для этого мы создаём следующую функцию:
Далее нам нужно выяснить, учитывает ли браузер ширину скроллбара в медиавыражениях или нет, чтобы потом вычесть эту ширину из общих расчётов. К примеру, Sarari может не учитывать, а остальные браузеры — наоборот. Для этого мы создаём такую вот функцию:
Ну а дальше всё просто. Присваиваем результат предыдущих проверок переменной var widthDiff = getDifference(); , а после уже при ресайзе в условии отнимаем результат этой переменной от контрольной точки:
По идее это исключает любые риски, связанные с поведением браузера в отношении скроллбара при медиавыражениях. Сам способ немного замороченный, но зато он гарантирует, что медиавыражения и условия в JavaScript будут срабатывать одновременно. А чтобы совсем было понятно, как он работает, Владимир подробно описал это прямо в примере на codepen.
5. Решение с определением браузера Safari
Это решение родилось благодаря Инне Сукновальник (@isuknovalnik). Оказывается Инна уже сама давно задавалась этим вопросом, и накопала по нему много интересных деталей. Как она выяснила, во всех браузерах, кроме Safari медиавыражения срабатывают по window.innerWidth , т.е. скроллбар включается в ширину окна. И так должно быть по спецификации. А вот в Safari как раз всё наоборот — он противоречит спецификации, и медиавыражения в нём срабатывают по document.documentElement.clientWidth , а ширина окна не включает скроллбар. Поэтому Инна пошла следующим путём: поскольку плохо себя ведёт только лишь Safari, то с помощью скрипта мы можем определить этот браузер и подсовывать ему document.documentElement.clientWidth , а остальным window.innerWidth .
Сразу скажу, что я не стал сильно шерстить интернет в поисках подходящего скрипта для определения браузера, и взял почти первый попавшийся, поскольку мне важна была сама суть:
А далее при ресайзе окна нам лишь нужно вернуть результат этой функции и дальше действовать по уже знакомому нам сценарию:
Вот такой вот нехитрый способ. Как по мне, то я бы не стал особо ему доверять, поскольку может случится, что в будущем Safari захочет исправить это поведение, начав действовать по спецификации, и тогда этот метод может подпортить нам настроение.
Заключение
Вам, наверное, не терпится задать вопрос, мол, зачем нужны 1-, 2-, 4-, 5-е решения, если для этого есть специально придуманный нативный метод matchMedia (3-е решение), который ещё и имеет отличную поддержку браузерами? Да, возможно вы правы, мне и самому, если честно, этот вариант импонирует больше всего. Но мы рассмотрели все эти способы как минимум для того, чтобы почерпнуть какие-то идеи, которые могут пригодиться в будущем и в других задачах, а так же чтобы узнать что-то новое. Поэтому, я очень надеюсь, что в комментариях вы сможете предложить и другие идеи, которые я с радостью включу в статью. Да и просто делитесь своими мыслями на этот счёт, тоже будет интересно послушать.
Кстати, Владимир (@mista_k) настоятельно рекомендовал перестраховываться, и совмещать метод с matchMedia , к примеру, с хаком с ::before для элемента <html> (Решение с matchMedia и хаком с ::before у элемента <html> ). Это исключит любые риски, связанные с задержкой загрузки стилей, когда JS уже загрузился. Подробнее об этом можете почитать в комментарии Владимира и в его статье по теме.