CSS-live.ru

Полный контроль над контрольными точками. В CSS и в JS

Всем привет! Недавно меня поругали за то, что для гарантии адаптивности вёрстки я применяю дополнительный класс .js-adaptive, который я вешаю на элемент body с помощью JavaScript. К примеру, при ширине экрана 600px и ниже класс .js-adaptive вешается на body, а при ширине 601px и выше этот класс с body убирается. В самих стилях при ширине экрана 600px и ниже я делаю адаптивность вот так:

.block-container {
	display: block // при ширире больше 600px включаем display: block для .block-container 
}
.js-adaptive .block-container {
	display: flex // при ширине меньше 600px включаем display: flex для .block-container 
}

Здесь вырисовывается явный минус. В адаптивном режиме у нас появляется дополнительный класс .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(), в котором можно уже писать любой текст.

@media screen and (min-width: 1px) and (max-width:600px) {
  html::before {
    content: 'Маленький экран';
    display: none;
  }
...

В общем, вот что у нас вышло. Поясню некоторые моменты в коде.

  var MQ_indicator = window.getComputedStyle(document.querySelector('html'), ':before').getPropertyValue('content').replace(/["']/g,'');

Этой строкой мы вычисляем значение свойства content, а далее просто проверяем его при помощи условия…

if(MQ_indicator == 'Маленький экран') {

… и как только оно сработает, ловушка захлопнута :)

Решение с помощью кастомных свойств

Обновление от 21.05.2017: ещё один способ подсказал Сергей Артёмов (@firefoxic_arts). Он решил пойти ещё дальше и вместо всяких хаков задействовал кастомные свойства. А суть решения следующая:

Для начала мы объявляем значение переменной --media в селекторе :root

:root {
  --media: mobile-up;
}

Далее в медиавыражении для мобильных экранов переопределяем значение нашей переменной:

@media (max-width: 600px) {
  :root {
    --media: mobile-only;
  }
  ...

Ну а далее в JavaScript делаем вот что:

/* Сохраняем в переменной exportMediaToJs ссылку на селектор с классом '.export-to-js' (тестовый div, который отвечает за JavaScript */
const exportMediaToJs = document.querySelector('.export-to-js');

/* Определяем функцию, в которой будут происходить все проверки */
const repaint = function () {

  /* Получаем значение свойства --media  для селектора :root,
     а после с помощью метода .trim() удаляем ненужные проблемы,
     которые мы ставим после двоеточия в --media: */
  let mediaQuery = getComputedStyle(document.querySelector(':root')).getPropertyValue('--media').trim();

  /* Задаем переменной  exportMediaToJsColor значение по умолчанию "orange"
 */
  let exportMediaToJsColor = 'orange';
  
  /* Проверяем, если значением свойства --media является  'mobile-only',
     то это и есть граница нашей контрольной точки*/
  if (mediaQuery === 'mobile-only') {
    exportMediaToJsColor = 'lightblue';
  }
  /* Устанавливаем тестовому элементу div (отвечающему за JavaScript) цвет, 
     который зависит от того, сработало ли медиавыражение или нет. */
  exportMediaToJs.style.setProperty('--color', exportMediaToJsColor);
};
/* Вешаем на события 'resize' и 'load' нашу функцию выше */
window.addEventListener('resize', repaint);
window.addEventListener('load', repaint);

Вот собственно и всё. Нам с SelenIT-ом очень нравится решение Сергея, поскольку оно использует CSS-переменные, которые по сути и предназначены для таких целей, и ещё вдобавок с ними код становится чище и избавляет нас от ненужных хаков. Браво Сергей! Единственный минус (а минус ли это?) здесь в том, что кастомные свойства поддерживаются только в современных браузерах, включая Edge 15+. Поэтому смело используйте его, если вам не требуется поддержка IE10-11.

2. Хак с определением display у блока-хелпера

Это решение принадлежит Виталию Емельянцеву (@gambala_rus). Оно немного напоминает предыдущий вариант, но вместо font-family у <html> здесь используется блок-хелпер:

<div class="device-helper_visible-below-600"></div>

Поначалу мы скрываем его в CSS с помощью display: none, а после в медиавыражении меняем его display на block:

.device-helper_visible-below-600 {
    display: none; // скрываем элемент-хелпер
}

@media screen and (min-width: 1px) and (max-width:600px) {
.device-helper_visible-below-600 {
    display: block; // показываем элемент-хелпер
}

Далее уже в JavaScript мы создаём функцию, которая возвращает true/false в зависимости от значения свойства display у элемента-хелпера:

var deviceHelper = $(".device-helper_visible-below-600"); // Получаем ссылку на элемент-хелпер

var isBreakpoint = function() {
   return deviceHelper.is(':visible'); // возвращаем true/false в зависимости от значения display у элемента-хелпера
};

Ну и последним действием мы просто проверяем результат, который вернула функция, и уже пляшем от него:

if (isBreakpoint()) {

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

Кстати, у Виталия есть свой чат в 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 в зависимости от того, совпал ли запрос с размерами экрана или нет. Выглядит это очень просто:

if (window.matchMedia("screen and (min-width: 1px) and (max-width:600px)").matches) {
    // свойство matches вернуло true, поскольку ширина экрана от 1 до 600px. 
}

Поскольку в объекте MediaQueryList мы проверяем то же самое медиавыражение, что в CSS, и по той же самой логике, мы можем быть уверены, что срабатывать оно будет в той же самой точке.

Насчёт поддержки браузами matchMedia можете вообще не париться, она прекрасна. IE10+, Safari и другие современные браузеры поддерживают эту штуку на ура!

Кстати, я настоятельно рекомендую познакомиться с этим window.matchMedia() поближе, поскольку его возможности не ограничиваются нашей задачей. Советую начать вот с этой статьи на русском, а после обратиться к MDN (раз, два) и самой спецификации.

4. Хак с определением ширины скроллбара + window.matchMedia()

А это решение принадлежит Владимиру Кузнецову (@mista_k). Уверен, что вы уже читали его популярный блог. Его идея состоит из нескольких этапов. Давайте разберём их.

Первым этапом будет вычисление ширины скроллбара. Для этого мы создаём следующую функцию:

function getScrollbarWidth() {

   /* Создаём элемент div с position: absolute (и закидываем его в body). 
   Прячем его за экран с помощью ствойств top и left. А уже внутрь этого div кладём ещё один div. 
   var div будет служить ссылкой на внешний div. */
   var div = $('<div style="width:50px; height:50px; overflow:hidden; position:absolute; top:-200px; left:-200px;"><div style="height:100px;"> </div></div>').appendTo('body');
 
   /* Измеряем внутреннюю ширину внутреннего div, и кладём результат в переменную */
   var w1 = $('div', div).innerWidth(); 

   /* Принудительно врубаем у внешнего div скролл */
   var w1 div.css('overflow-y', 'scroll'); 

  /* Заново измеряем внутреннюю ширину div, но уже со скроллом (если, конечно, скролл 
  есть), и кладём результат в переменную var w2 */
  var w2 = $('div', div).innerWidth();  

  /* Удаляем div */
  $(div).remove(); 
 
  /* Возвращаем результат ширины скролла. Если его нет, то вернётся 0 */
  return (w1 - w2); 
}

Далее нам нужно выяснить, учитывает ли браузер ширину скроллбара в медиавыражениях или нет, чтобы потом вычесть эту ширину из общих расчётов. К примеру, Sarari может не учитывать, а остальные браузеры — наоборот. Для этого мы создаём такую вот функцию:

function getDifference() {

 /* Получаем ширину странцы */
 var windowWidth = $(window).width();

 /* Получаем ширину скроллбара из предыдущей функции 
 (если скроллбара нет, то вернётся 0) */
 var scrollbarWidth = getScrollbarWidth(); 

 /* Создаём медиавыражение с учётом полученных результатов ранее */
 var query = 'screen and (min-width: 1px) and (max-width:' + (windowWidth + scrollbarWidth) + 'px)'; 

 /* С помощью уже знакомого нам метода window.matchMedia и свойства matches проверяем, 
    учитывает ли браузер ширину скроллбара при срабатывании медиавыражений или нет. 
    Если да, то возвращает эту ширину. */
 if (window.matchMedia(query).matches) {
   return scrollbarWidth; 
 }

  /* Если браузер не учитывает ширину скроллбара, то возвращаем 0 */
  return 0; 
}

Ну а дальше всё просто. Присваиваем результат предыдущих проверок переменной var widthDiff = getDifference();, а после уже при ресайзе в условии отнимаем результат этой переменной от контрольной точки:

$(window).on("resize", function() {
 var windowsWidht1 = $(window).width();

 /* изменяем контрольную точку, если знаем, что скролбар влияет на медиавыражения */
 if (windowsWidht1 <= 600 - widthDiff) {

По идее это исключает любые риски, связанные с поведением браузера в отношении скроллбара при медиавыражениях. Сам способ немного замороченный, но зато он гарантирует, что медиавыражения и условия в JavaScript будут срабатывать одновременно. А чтобы совсем было понятно, как он работает, Владимир подробно описал это прямо в примере на codepen.

5. Решение с определением браузера Safari

Это решение родилось благодаря Инне Сукновальник (@isuknovalnik). Оказывается Инна уже сама давно задавалась этим вопросом, и накопала по нему много интересных деталей. Как она выяснила, во всех браузерах, кроме Safari медиавыражения срабатывают по window.innerWidth, т.е. скроллбар включается в ширину окна. И так должно быть по спецификации. А вот в Safari как раз всё наоборот — он противоречит спецификации, и медиавыражения в нём срабатывают по document.documentElement.clientWidth, а ширина окна не включает скроллбар. Поэтому Инна пошла следующим путём: поскольку плохо себя ведёт только лишь Safari, то с помощью скрипта мы можем определить этот браузер и подсовывать ему document.documentElement.clientWidth, а остальным window.innerWidth.

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

function getdocWidth(){
  /* Получаем строку из юзерагента браузера */
  var ua = navigator.userAgent.toLowerCase();
  
  /* Проверяем, если в строке есть "safari",
     то скорее всего это webkit, поэтому заходим в этот if 
  */
  if (ua.indexOf('safari') != -1) {
     
    /* Если это браузер на основе Chrome, то записываем в 
       переменную docWidth значение window.innerWidth */
    if (ua.indexOf('chrome') > -1) {
      docWidth = window.innerWidth;

    /* Если это не Chrome, то значит это Safari,
       поэтому в переменной docWidth уже сохраняем значение document.documentElement.clientWidth*/
    } else {
      docWidth = document.documentElement.clientWidth;
    }
  /* Если в строке юзерагента нет "Safari", значит это какой-то иной браузер,
     поэтому отдаём ему window.innerWidth
     */
  }else{
   docWidth = window.innerWidth;
  }
  /* Ну и возвращаем переменную */
  return docWidth;
}

А далее при ресайзе окна нам лишь нужно вернуть результат этой функции и дальше действовать по уже знакомому нам сценарию:

$(window).on('resize', function () {
    var docWidth = getdocWidth()
    if(docWidth <= 600) {
    ...

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

Заключение

Вам, наверное, не терпится задать вопрос, мол, зачем нужны 1-, 2-, 4-, 5-е решения, если для этого есть специально придуманный нативный метод matchMedia (3-е решение), который ещё и имеет отличную поддержку браузерами? Да, возможно вы правы,  мне и самому, если честно, этот вариант импонирует больше всего. Но мы рассмотрели все эти способы как минимум для того, чтобы почерпнуть какие-то идеи, которые могут пригодиться в будущем и в других задачах, а так же чтобы узнать что-то новое. Поэтому, я очень надеюсь, что в комментариях вы сможете предложить и другие идеи, которые я с радостью включу в статью. Да и просто делитесь своими мыслями на этот счёт, тоже будет интересно послушать.

Кстати, Владимир (@mista_k) настоятельно рекомендовал перестраховываться, и совмещать метод с matchMedia, к примеру, с хаком с ::before для элемента <html> (Решение с matchMedia и хаком с ::before у элемента <html>). Это исключит любые риски, связанные с задержкой загрузки стилей, когда JS уже загрузился. Подробнее об этом можете почитать в комментарии Владимира и в его статье по теме.

Все решения воедино:

  1. Хак со свойством font-family элемента <html> (от @_sssats)
  2. Хак с определением display у блока-хелпера (от @gambala_rus)
  3. Решение с помощью window.matchMedia()
  4. Хак с определением ширины скроллбара + window.matchMedia() (от @mista_k)
  5. Решение с определением браузера Safari (от @isuknovalnik)

P.S. Это тоже может быть интересно:

28 комментариев

  1. По правде сказать, для решения исходной задачи нужно только matchMedia().

    А вот проверка на то, применяются конкретные стили с учётом медиа-запроса или нет, которая описана в пунктах 1 и 2 может быть хорошей страховкой от случайных расхождений в логике.

    Я аналогичный случай описывал в статье Динамическая загрузка «отзывчивых» ресурсов. Технически, может оказаться, что скрипты загрузились и matchMedia() срабатывает, а вот стилей, соответствующих этой логике на странице нет.

    1. Владимир, благодарю за коммент. Но есть вопрос. У вас хоть и надёжный, но сложноватый скрипт. Нельзя ли сделать попроще: к примеру, взять вариант с matchMedia() и просто внутри условия делать дополнительную проверку на тот же z-index у элемента:

      if (window.matchMedia("screen and (min-width: 1px) and (max-width:600px)").matches) {
      if($('html').css('z-index') == '1') {
      // то мы точно знаем, что медиавыражение сработало

      1. Максим, конечно можно. Я исходил из кода, который вы запостили. А потом в комментарии написал, что нужно использовать только matchMedia() для синхронизации CSS и JS кода.

        Вот мой «чистый» пример: https://codepen.io/mistakster/pen/GmwBpQ

        В зависимости от ширины экрана будет меняться вид меню: строка или селектор. А вся важная логика скрыта в последних тридцати строках кода. Там нет никаких проверок на ширину скролбара, нет обработчиков window.on('resize') и т.п.

        Дайте знать, если и после этого остались какие-то вопросы. Можно обсудить в скайпе, например.

  2. Перестаньте писать костыли на JS, обходитесь стабильным css (медиа запросы) !

  3. А почему нельзя вычислять ширину скролла у окна браузера (window.innerWidth — document.body.clientWidth)? У Дивов он такой же будет.

      1. Я в данном случае про функцию getScrollbarWidth() писал из промежуточного варината (4). Иногда ширину скролбара все-равно полезно знать (Например, когда есть фиксированная шапка + основная часть со скроллом)

            1. Видимо я сам до конца не разобрался в чем проблема. У меня в Safari блоки не перекрашиваются одновременно только при загрузке. При расайзе все происходит нормально. Если код вынести в отдельную функцию и ее вызывать при загрузке и при ресайзе, либо не изменяя код добавить $(window).trigger(‘resize’), то все работает.

                1. В ветке был приведен пример с matchMedia, и я подумал что проблема в Safari применима и к нему. Если говорить про первоначальный вариант, то можно можно также определять safari css hackoм через медиа выражения.

                  https://codepen.io/anon/pen/qmgeGK?editors=0010

                  В любом случае вариант с matchMedia самый правильный.

  4. Максим, вы проблему из пальца высосали и раздули до целой статьи. Никаких расхождений нет. Почему, я вам ответил на форуме, продублирую и сюда тоже.

    Ну во первых вы изначально сами себя запутали отождествляя window.innerWidth с $(window).width() который на самом деле является аналогом document.documentElement.offsetWidth . Эти методы ( window.innerWidth и document.documentElement.offsetWidth ) позволяют получить ширину вьюпорта со скролом и соответственно без. C Сафари проблем быть не должно, не под виндой случаем запускаем )?

    И ещё для медиазапросов в JS есть свои методы

    var smallScreen = window.matchMedia(«(max-width: 600px)»).matches;

    if ( smallScreen ) {

    }

    1. Андрей, к сожалению не могу с вами согласиться, поскольку проблема всё же есть, и не только я это понял. Про window.matchMedia теперь знаю, но и оно тоже может подвести (см. «Заключение»). И если вам не сложно, покажите, пожалуйста, свой рабочий вариант не на window.matchMedia, а именно с document.documentElement.offsetWidth и иже с ним.

      1. Максим, я прочитал заключение и не совсем понял при каких обстоятельствах может подвести matchMedia.

        PS Ваша статья называется — «Полный контроль над контрольными точками. В CSS и в JS» а не «Как отказаться от matchMedia и усложнить себе жизнь», но если сильно хочется можно использовать
        window.innerWidth. Насколько я понимаю это решение у вас работало везде кроме сафари, но вы не уточнили на какой платформе запускали последний. Сафари под windows давно мертв и поддерживать его не имеет смысла.

    2. расхождения есть и это проблема, позвоните по скайпу мне, я вам покажу как это работает, скайп vejevich

  5. Так и не понял зачем вообще js для этого? Всю адаптивность легко можно делать на чистом css

  6. также проблема в использовании единиц измерения vh, vw. Есть разница в сафари и хром

  7. Ребята, привет!

    Спасибо за публикации.

    Классная статья, хотел распечатать — да не получается хорошо распечатать, не видно кода, который прячется за горизонтальной полосой прокрутки (конечно, допишу ручками, благо — его не так много).  :))

    У меня есть для вас предложение: Давайте примеры кода в статьях не размещать больше, чем ширина самой статьи, чтобы не появлялись эти горизонтальные полосы прокрутки и можно было  бы статью не читать с компа, а распечатывать. Ну, и горизонтальная полоса прокрутки — не лучшее удобство для комфортного чтения статей.

    P.S. В принципе уже существует выверенное количество символов в одной строке, которые удобны для чтения. Упоминание об этом в статье тут: https://css-tricks.com/bookmarklet-colorize-text-45-75-characters-line-length-testing/

     

Оставить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

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