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

CSS только доходит до вас

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

<Article>
  <ArticleAuthorInformationDesktop />
  <ArticleImage />
  <ArticleHeading />
  <ArticlePreviewText />
  <ArticleAuthorInformationMobile />
</Article>

Теперь, если разметка, используемая для верхнего рабочего стола и нижнего мобильного компонента информации об авторе, была бы точно такой же, вы могли бы вместо этого использовать сетку CCS или порядок CSS flexbox, чтобы просто иметь компонент один раз в разметку и используйте медиа-запросы CSS, чтобы установить соответствующее правило CSS для позиционирования компонента по желанию для различных окон просмотра.

Однако здесь есть оговорки:

Использование свойства order имеет точно такие же последствия для доступности, как и изменение направления с помощью flex-direction. Использование order изменяет порядок, в котором элементы рисуются, и порядок, в котором они отображаются визуально. Он не меняет порядок последовательной навигации по элементам. Поэтому, если пользователь переключается между элементами, он может обнаружить, что прыгает по макету очень запутанным образом. — https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_flexible_box_layout/Ordering_flex_items#the_order_property_and_accessibility

Если бы мы были здесь умнее и попытались добиться желаемых различий в макете полностью с помощью CSS, мы бы столкнулись с потенциальными проблемами доступности (a11y), поскольку опыт для визуальных и невизуальных пользователей будет отличаться.

Теоретически это можно обойти с помощью разумного использования tabindex и/или aria-owns, где в любом случае вы можете определить порядок, в котором ожидается обход элементов — однако оба случая почти всегда настоятельно не рекомендуются. Попытка взломать порядок табуляции по умолчанию на странице рискованно, а aria-owns не поддерживается VoiceOver, что означает, что его использование решит проблему только для устройств, не связанных с фруктами. Оба варианта невероятно уязвимы для изменений в связанных компонентах, где существуют реальные накладные расходы на обслуживание, чтобы гарантировать, что это никогда не регрессирует.

Это также все разваливается, если настольная и мобильная версии компонента должны быть совершенно разными, например. слайдер против просто статического контента — вы не можете использовать CSS для изменения HTML-разметки! Это означает, что вам почти наверняка понадобятся разные компоненты, а затем вы либо не будете отображать, либо скроете тот, который не подходит для области просмотра.

Переосмысление серверной части

Это нормально, верно? Ну, это до тех пор, пока вы не запустите рендеринг на стороне сервера (SSR) этого компонента статьи. Сервер не имеет готовой концепции окна просмотра, поэтому нет способа узнать, какой из двух вариантов должен быть в ответе HTML, который вы отправляете в браузеры ваших пользователей.

При отсутствии подробностей вьюпорта на сервере при рендеринге это означает:

  1. Вы можете догадаться (гм, я имею в виду сначала перейти на мобильные устройства), но это неизбежно приведет к своего рода вспышке нестилизованного контента (FOUC) или кумулятивному смещению макета (CLS) всякий раз, когда вы ошибаетесь, что немного наивно!
  2. Вы можете отказаться от рендеринга на стороне сервера, но тогда вы почти наверняка получите CLS, когда компонент гидратируется на стороне клиента, или, в лучшем случае, он появится поздно, что раздражает.
  3. Вы можете выбрать рендеринг обоих, но тогда возникают те же проблемы, что и выше, когда один из вариантов присутствует при загрузке страницы, а затем удаляется вскоре после того, как он выглядит плохо визуально и потенциально может вызвать CLS.

Это не обязательно новая или нерешенная проблема. Помимо приведенных выше вариантов, одна из идей состоит в том, чтобы использовать Подсказки клиента, чтобы предоставить вашему серверу контекст того, где этот компонент будет отображаться, чтобы можно было сделать соответствующий выбор. В качестве альтернативы аналогичный процесс может быть достигнут с помощью обнюхивания пользовательского агента с помощью таких пакетов, как ua-parse-js, где вы обнаруживаете заголовок пользовательского агента и на его основе делаете вывод о вероятном типе и размере устройства.

Обязательно позаботьтесь о том, чтобы включить источник подсказки во все ключи кеша, которые у вас могут быть, иначе вы можете кешировать яд, если будете отображать по-разному для разных окон просмотра!

Наконец, возможно, мое предпочтительное решение: (1) использовать один из вышеперечисленных методов подсказок, если это возможно (и надежно), а затем (2) сделать следующее:

  1. Если у вас есть доверенная подсказка, визуализируйте только то, что необходимо в вашем HTML, и остановитесь здесь.
  2. В противном случае на стороне сервера отображаются оба варианта компонента в вашем HTML-ответе.
  3. В дополнение к этому двойному рендерингу добавьте небольшой фрагмент CSS и соответствующие стили/классы к компонентам с медиа-запросами CSS, гарантируя, что на стороне клиента они будут отображаться только в том случае, если область просмотра совпадает.
  4. После гидратации вы можете позволить JS вступить во владение, удалить компонент из DOM, который не требуется для области просмотра, и удалить атрибуты «SSR Smoothing over CSS», поскольку они больше не требуются.

Если вы хотите увидеть пример реализации этой идеи, посмотрите пакет @artsy/fresnel (и я уверен, что есть и другие).

Как я могу сделать это сам?

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

Недавно я был в такой ситуации — проект уже использовал react-responsive, оболочку для matchMedia API с некоторыми вариантами на стороне сервера для откатов, что полезно только в том случае, если есть разумное значение по умолчанию (необычное) или если вы используете подсказки. чтобы иметь возможность пройти к пакету. Этого недостаточно, поскольку у меня не было подсказок для работы и перехода к пакету, поэтому потребовалась дополнительная работа, чтобы включить SSR без CLS.

Вот решение первого прохода, которое я выбрал для своего варианта использования (возможны изменения, и пробег может варьироваться):

// index.tsx

import React, { useEffect, useState } from 'react';
import { useMediaQuery } from 'react-responsive';
import { BREAKPOINTS, BreakpointWidth } from './breakpoints';
import styles from './index.css';

interface SSRMediaQueryProps {
  maxWidth?: BreakpointWidth;
  minWidth?: BreakpointWidth;
}

interface DisplayValueProps extends SSRMediaQueryProps {
  breakpoint: BreakpointWidth;
}

const getDisplayValue = ({ breakpoint, maxWidth, minWidth }: DisplayValueProps) => {
  if (typeof maxWidth === 'undefined' && typeof minWidth === 'undefined') {
    return undefined;
  } else if (typeof minWidth === 'undefined') {
    return breakpoint >= maxWidth! ? 'none' : undefined;
  } else if (typeof maxWidth === 'undefined') {
    return breakpoint < minWidth ? 'none' : undefined;
  }

  return breakpoint < minWidth || breakpoint >= maxWidth ? 'none' : undefined;
};

/**
 * This component serves to allow viewport specific components be server-side
 * rendered without causing a CLS.
 */
export const SSRMediaQuery: React.FC<SSRMediaQueryProps> = ({ children, maxWidth, minWidth }) => {
  const [isClientSide, setIsClientSide] = useState(false);
  const isMatch = useMediaQuery({ maxWidth, minWidth });

  useEffect(() => {
    setIsClientSide(true);

    return () => {
      setIsClientSide(false);
    };
  }, []);

  if (!isClientSide) {
    const style = {
      '--xs': getDisplayValue({ breakpoint: BREAKPOINTS.xs, maxWidth, minWidth }),
      '--sm': getDisplayValue({ breakpoint: BREAKPOINTS.sm, maxWidth, minWidth }),
      '--md': getDisplayValue({ breakpoint: BREAKPOINTS.md, maxWidth, minWidth }),
      '--lg': getDisplayValue({ breakpoint: BREAKPOINTS.lg, maxWidth, minWidth }),
      '--xl': getDisplayValue({ breakpoint: BREAKPOINTS.xl, maxWidth, minWidth }),
    } as React.CSSProperties;

    return (
      <div className={styles.mediaQueryContainer} style={style}>
        {children}
      </div>
    );
  }

  if (isMatch) {
    return <>{children}</>;
  }

  return null;
};

Где примерно находится импортированный файл CSS (я использую миксины SCSS, чтобы не писать от руки!):

/* index.css */

@media (max-width: 543px) {
  .mediaQueryContainer {
    display: var(--xs, unset);
  }
}
@media (min-width: 544px) and (max-width: 767px) {
  .mediaQueryContainer {
    display: var(--sm, unset);
  }
}
@media (min-width: 768px) and (max-width: 991px) {
  .mediaQueryContainer {
    display: var(--md, unset);
  }
}
@media (min-width: 992px) and (max-width: 1199px) {
  .mediaQueryContainer {
    display: var(--lg, unset);
  }
}
@media (min-width: 1200px) {
  .mediaQueryContainer {
    display: var(--xl, unset);
  }
}

И использование что-то вроде:

<Article>
  <SSRMediaQuery minWidth={BREAKPOINTS.sm}>
    <ArticleAuthorInformationDesktop />
  </SSRMediaQuery>
  <ArticleImage />
  <ArticleHeading />
  <ArticlePreviewText />
  <SSRMediaQuery maxWidth={BREAKPOINTS.sm}>
    <ArticleAuthorInformationMobile />
  </SSRMediaQuery>
</Article>

Проходим реализацию:

  1. Сначала мы предполагаем, что мы всегда находимся на стороне сервера или в мире до гидратации, пока эффект (который работает только на стороне клиента) не говорит нам об обратном.
  2. В этом серверном мире мы всегда визуализируем предоставленные дочерние элементы и, более того, делаем это с ними, завернутыми в <div>, с которыми связаны соответствующие стили, чтобы гарантировать, что они отображаются пользователям только в нужных областях просмотра. Здесь я чувствовал себя причудливым, поэтому использовал переменные CSS, чтобы определить, как значение display устанавливается для оболочки <div>, по умолчанию unset, если значение не передается.
  3. Как только мы попадаем в клиент, мы можем быть в безопасности, зная, что медиа-запросы CSS в оболочке <div> сработают, что означает, что, хотя мы немного снизили производительность при доставке обоих вариантов компонента, мы не страдаем от визуальных причуд. или ЦЛС.
  4. После гидратации мы по-прежнему будем находиться в состоянии, когда оба варианта существуют — действительно, оба варианта будут отображаться при первом проходе, поэтому необходимо позаботиться о том, чтобы ваши компоненты не имели побочных эффектов загрузка / подключение, которые могут двойное срабатывание. . (Для дальнейшего чтения о том, почему мы делаем этот двойной проход, см. Эта проблема GitHub. Существует лучшая альтернатива, которая более сложна, но я не буду здесь ее освещать — это использует границы ожидания и ручное сокращение DOM для удаления нежелательного дерева. перед увлажнением)
  5. После первого рендеринга эффект сработает, это обновит состояние, чтобы переключиться в режим на стороне клиента. Это запускает повторный рендеринг, при котором мы либо продолжаем рендерить дочерние элементы, поскольку есть совпадение области просмотра, либо ничего другого.

И точно так же мы свободны от CLS и можем изящно справляться с такой разницей в разметке между разными окнами просмотра 🎉

Прощальные мысли

К сожалению, здесь нет идеального ответа, только компромиссы:

  • Получайте именно то, что хотите, но приходится доверять подсказкам клиента, которые могут быть не всегда… так что, может быть, только иногда получая то, что хотите?
  • Иметь более быстрое время до первого байта (TTFB) и другие аналогичные показатели загрузки страницы за счет раздражающего CLS по сравнению с почти наверняка наличием CLS за счет раздувания веса страницы медленнее, чем первое впечатление.
  • Потенциальные проблемы с поисковой оптимизацией (SEO) в результате двойного рендеринга контента (хотя считается общепринятым, что вы не должны быть наказаны за такое поведение) в сочетании с указанными выше проблемами производительности для основных жизненно важных веб-сайтов, оказывающих прямое влияние. по SEO.
  • По-прежнему необходимо позаботиться о побочных эффектах в компонентах с двойной визуализацией SSR (если вы не проделаете больше работы).

Всегда оценивайте, что работает для вашего варианта использования!

  • Если вы живете в мире SPA, где вы работаете только с клиент-рендерингом, эта статья не для вас!
  • Если вы находитесь в мире SSR, но компонент, который может отображаться по-разному, толькоотображается при взаимодействии с клиентом или каком-либо другом ленивом или отложенном триггере, вам не о чем беспокоиться — просто используйте клиентскую часть. методы.
  • Если вы находитесь в мире SSR, возможно, рассмотрите некоторые из обсуждаемых методов.

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

Как то, что я должен сказать? Подпишитесь здесь или в Твиттере.