Skeleton Mammoth logotype.
Skeleton Mammoth logotype.

Введение

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

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

Что такое Скелетон загрузчики?

Примечание: Вы можете пропустить этот раздел, если знаете, что такое скелетон.

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

Вот примеры скелетонов из LinkedIn и Youtube:

LinkedIn skeleton screen
LinkedIn skeleton screen
YouTube skeleton screen
YouTube skeleton screen

Почему нужно использовать скелетоны?

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

  • Уменьшение показателей отказов: Они могут препятствовать тому, что пользователь покинет страницу из-за задержек загрузки.

  • Плавные переходы: Создают более плавные переходы между различными состояниями страницы или приложения.

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

Проблемы большинства существующих скелетонов

Принимая во внимание существование множества примеров создания собственных скелетонов и наличие готовых библиотек, которые сделали это за вас, с ними все равно остается ряд недостатков.

  • Ограниченная настраиваемость: Многие существующие скелетоны имеют ограниченные возможности настройки. Это приводит к несоответствию стилей фактического дизайна и скелетона.

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

  • Универсальность и возможность повторного использования. Большинство подходов к созданию скелетонов предлагают либо создание поверхностных копий компонентов в виде заполнителей, в результате чего получается множество копий, либо существенное изменение структуры существующих компонентов. Оба подхода требуют достаточно много усилий для дополнительной разработки и ресурсов.

  • Сложность обслуживания: По мере развития веб-сайтов и изменения контента, поддержание скелетонов в актуальном состоянии может стать довольно сложным.

Альтернативы

Существуют некоторые «альтернативы» использованию скелетонов.
Забегая вперед и отвечая, действительно ли существуют альтернативы, мой ответ скорее нет, чем да. Если говорить о правильном использовании, то скелетон — одно из лучших решений. Ниже я все же приведу пару примеров вместе с их плюсами и минусами.

Спиннер

Спиннер - распространенная альтернатива скелетонам. Они состоят из анимированных иконок, которые постоянно вращаются, визуально показывая, что контент загружается.

Плюсы:

  • Простота: Простая реализация, часто требующая всего нескольких строк кода или использующая предварительно разработанные библиотеки.

  • Универсальное понимание: Спиннеры широко известны на разных платформах и приложениях, гарантируя, что пользователи понимают что контент загружается.

Минусы:

  • Ограниченная информация: Спиннеры не предоставляют никакой информации о загружаемом контенте.

  • Перекрывает всю страницу или ее большую часть, а не отдельные элементы. Что дает ощущение загрузки не отдельных элементов, а всего сайта в целом.

Спиннеры — неотъемлемая часть интерфейсов, но они не совсем подходят для замены скелетонов.

Прогресс Бар (Индикатор выполнения)

Прогресс Бар — это визуальный элемент, указывающий на статус завершения задачи или процесса. Он обеспечивает линейное представление, обычно с заполняемой частью, которая постепенно увеличивается.

Плюсы:

  • Точная обратная связь: Обеспечивает точную обратную связь о статусе завершения задачи.

  • Оценка времени: Прогресс бары могут дать пользователям оценку оставшегося времени, необходимого для завершения.

  • Многоцелевые: Можно использовать в различных контекстах и сценариях, что делает их универсальным компонентом в веб-разработке и разработке приложений.

Минусы:

  • Недостаток контекста: В некоторых случаях индикаторы выполнения могут не давать достаточного контекста о фактической задаче или процессе, который они представляют.

  • Сложность реализации: Создание индикаторов выполнения с точным представлением и плавной анимацией может быть сложным, особенно при работе с задачами разной продолжительности и временем отклика.

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

Отсутствие какого-либо визуала

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

Создание универсального и переиспользуемого скелетона

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

Универсальный и переиспользуемый

Существует много примеров с достаточно сложными подходами, где вам нужно создать отдельный скелетон для каждого компонента, в котором вы хотите его использовать.
В моем случае, я хотел чтобы это было что-то уникальное, что можно было бы повторно использовать в большинстве сценариев и не привязываться к какому-либо JavaScript фреймворку (например, React.js или Vue.js).

Гибкость конфигурации

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

Многофункциональный

Помимо стандартного набора, хотелось добавить в него поддержку дополнительных полезных и нужных функций.

Легковесный и без зависимостей

Легковесный и максимально свободный от других сторонних зависимостей.

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

Компонент базовой карточки

В качестве базового примера и в целях демонстрации я буду использовать React.js и возьму разметку компонента обычной базовой карточки, чтобы показать, как это работает. Но напомню, что библиотека не привязана ни к одному из фреймворков, а в конце статьи будут ссылки на исходный код библиотеки и демо.

Вот пример разметки карточки, которая имеет свои стили и еще не знает о существовании скелетона.

<div className='card'>
    <div className='card__img-wrapper'>
        <img className='card__img' src={require(`../../images/cards/${imgUrl}`)}/>
    </div>
    <div className='card__body'>
        <div className='card__details'>
            <p className='card__title'>{title}</p>
            <p className='card__subtitle'>{subtitle}</p>
        </div>
    </div>
</div>

Для того чтобы активировать скелетон, необходимо лишь применить родительский класс sm-loading к самой карточке, и дочерние классы sm-item-primary или sm-item-secondary к тем элементам, на которых мы хотим видеть скелетон. Таким образом, обновленный результат будет выглядеть так:

<div className={`card ${dataState.dataStatus === 'loading' ? "sm-loading" : ""}`}>
    <div className='card__img-wrapper sm-item-primary'>
        <img className='card__img' src={require(`../../images/cards/${imgUrl}`)}/>
    </div>
        <div className='card__body'>
            <div className='card__details'>
                <p className='card__title sm-item-secondary'>{title}</p>
                <p className='card__subtitle sm-item-secondary'>{subtitle}</p>
            </div>
    </div>
</div>

Давайте я объясню что тут происходит поэтапно. В данной строке:

<div className={`card ${dataState.dataStatus === 'loading' ? "sm-loading" : ""}`}>

Я применяю класс sm-loading в зависимости от условия. Если статус dataState.dataStatus имеет значение loading, то класс будет применен, иначе — нет. Класс sm-loading должен быть установлен/присутствовать только во время загрузки ваших данных. Это что-то вроде переключателя. Только при его наличии дочерние элементы с наличием соответствующих классов sm-item-primary или sm-item-secondary будут отображать скелетон. Таким образом, всего 3 класса приведут скелетон в действие.

Базовые стили скелетона

Корневые переменные

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

/* Root variables.
--------------------------------------------------------------------------------*/
:root {
    /* Light theme colors. */
    --sm-color-light-primary: 204, 204, 204, 1;
    --sm-color-light-secondary: 227, 227, 227, 1;
    --sm-color-light-animation-primary: color-mix(
            in srgb,
            #fff 15%,
            rgba(var(--sm-color-light-primary))
    );
    --sm-color-light-animation-secondary: color-mix(
            in srgb,
            #fff 15%,
            rgba(var(--sm-color-light-secondary))
    );

    /* Dark theme colors. */
    --sm-color-dark-primary: 37, 37, 37, 1;
    --sm-color-dark-secondary: 41, 41, 41, 1;
    --sm-color-dark-animation-primary: color-mix(
            in srgb,
            #fff 2%,
            rgba(var(--sm-color-dark-primary))
    );
    --sm-color-dark-animation-secondary: color-mix(
            in srgb,
            #fff 2%,
            rgba(var(--sm-color-dark-secondary))
    );

    /* Animations. */
    --sm-animation-duration: 1.5s;
    --sm-animation-timing-function: linear;
    --sm-animation-iteration-count: infinite;
}

Здесь задаются значения цветов для статического (без анимации) и анимированного скелетона, а также настройки анимации.

Базовые стили

Следующий раздел файла посвящен базовым стилям, не связанными ни с какой цветовой схемой или конфигурацией.

/* Base styles.
Applied by default and not related to any of the color scheme.
--------------------------------------------------------------------------------*/
.sm-loading .sm-item-primary,
.sm-loading .sm-item-secondary {
    border-color: transparent !important;
    color: transparent !important;
    cursor: wait;
    outline: none;
    position: relative;
    user-select: none;
}

.sm-loading .sm-item-primary:before,
.sm-loading .sm-item-secondary:before {
    clip: rect(1px, 1px, 1px, 1px);
    content: "Loading, please wait.";
    inset: 0;
    overflow: hidden;
    position: absolute;
    white-space: nowrap;
}

.sm-loading .sm-item-primary::placeholder,
.sm-loading .sm-item-secondary::placeholder {
    color: transparent !important;
}

.sm-loading .sm-item-primary *,
.sm-loading .sm-item-secondary * {
    visibility: hidden;
}

.sm-loading .sm-item-primary :empty:after,
.sm-loading .sm-item-primary:empty:after,
.sm-loading .sm-item-secondary :empty:after,
.sm-loading .sm-item-secondary:empty:after {
    content: "\00a0";
}

/* Animations related styles. */
@keyframes --sm--animation-wave {
    to {
        background-position-x: -200%;
    }
}

@keyframes --sm--animation-wave-reverse {
    to {
        background-position-x: 200%;
    }
}

@keyframes --sm--animation-pulse {
    0% {
        opacity: 1;
    }
    50% {
        opacity: 0.6;
    }
    100% {
        opacity: 1;
    }
}

.sm-loading .sm-item-primary,
.sm-loading .sm-item-secondary {
    animation: var(--sm-animation-duration) --sm--animation-wave
    var(--sm-animation-timing-function) var(--sm-animation-iteration-count);
}

Как указывалось ранее, sm-loading родительского класса используется для активации стилей скелетона. Классы sm-item-primary и sm-item-secondary переопределяют/дополняют стили элемента и отображают скелетон.

Skeleton Mammoth structure.
Skeleton Mammoth structure.

Таким образом, стили и размеры элементов (в нашем случае компонента карточки) сохраняются и наследуются скелетоном. Дополнительно хочу сказать, что при таком подходе мы гарантируем, что все дочерние элементы классов sm-item-primary или sm-item-secondary будут скрыты и как минимум имеют символ Неразрывного пробела. Если элемент совсем не имеет содержимого, этот символ обеспечивает отображение и визуализацию элемента. Также есть часть, которая отвечает за пользователей программ помогающий чтению с экрана (screen readers) и дает им знать, что контент находится в процессе загрузки.

Далее идет разделение на тематические разделы, такие как цветовая схема, анимация, доступность. Давайте посмотрим на стили для светлой темы.

/* Light theme.
The library's default color scheme.
Styles applied to the light color scheme.
--------------------------------------------------------------------------------*/
.sm-loading .sm-item-primary {
    background: rgba(var(--sm-color-light-primary));
}

.sm-loading .sm-item-secondary {
    background: rgba(var(--sm-color-light-secondary));
}

/* Animations related styles. */
.sm-loading .sm-item-primary {
    background: linear-gradient(
            90deg,
            transparent 40%,
            var(--sm-color-light-animation-primary) 50%,
            transparent 60%
    )
    rgba(var(--sm-color-light-primary));
    background-size: 200% 100%;
}

.sm-loading .sm-item-secondary {
    background: linear-gradient(
            90deg,
            transparent 40%,
            var(--sm-color-light-animation-secondary) 50%,
            transparent 60%
    )
    rgba(var(--sm-color-light-secondary));
    background-size: 200% 100%;
}

Цветовая схема

С помощью CSS медиа функции prefers-color-scheme, я реализовал автоматическую поддержку светлой и темной темы. В зависимости от настроек пользователей, она будет применяться автоматически. Конечно же есть возможность установить тему вручную, об этом я расскажу далее в статье.

/* Dark theme.
Styles to apply if a user's device settings are set to use dark color scheme.
https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-color-scheme
--------------------------------------------------------------------------------*/
@media (prefers-color-scheme: dark) {
    /*Omitted pieces of code.*/
}

Доступность

Анимации

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

/* Accessibility.
Disable animations if a user's device settings are set to reduced motion.
https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-reduced-motion
--------------------------------------------------------------------------------*/
@media (prefers-reduced-motion) {
    /*Omitted pieces of code.*/

    .sm-loading .sm-item-primary,
    .sm-loading .sm-item-secondary {
        animation: none;
    }

    /*Omitted pieces of code.*/
}

Конфигурация

На этом этапе основные стили закончены, и скелетон можно считать готовым. Но, меня преследовала мысль, что я должен иметь возможность настраивать все вышеперечисленное. Что делать, если я хочу отключить анимацию, или если я хочу всегда использовать темную тему? Поскольку CSS не может принимать какие-либо значения в качестве аргументов, как это делают функции JavaScript, а добавление JavaScript было исключено (по крайней мере, на данном этапе), потому что это полностью нарушило бы основную концепцию простоты и легкости.

Но все же, мы можем реализовать что-то похожее на аргументы, если заранее знаем их значения. И здесь нам на помощь приходят атрибуты data-*. С их помощью мы можем проверить наличие нужного нам значения в атрибуте, и применить нужные стили.

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

Например, если вы хотите явно использовать темную тему, вам нужно создать JSON объект:

const config = JSON.stringify({
  theme: "dark",
})

Примечание:
Атрибуты data-* могут работать только со строками, поэтому важно применить метод JSON.stringify() к объекту конфигурации.

Далее передаем этот объект в пользовательский атрибут data-sm-config:

<div class="card sm-loading" data-sm-config={config}>
    <!-- Omitted pieces of code. -->
</div>

Вот как это выглядит в CSS файле. Если в data-sm-config есть значение "theme":"dark", применяются соответствующие стили.

.sm-loading[data-sm-config*='"theme":"dark"'] .sm-item-primary,
.sm-loading[data-sm-config*='"theme":"dark"'] .sm-item-secondary {
    /* Omitted pieces of code. */
}

Продвинутое использование

Переопределение стилей с помощью глобальных переменных

Каждый проект и случай уникальны, и невозможно предугадать и сделать все универсальным. Особенно, когда дело касается цвета. Именно поэтому, как было сказано в начале статьи, большая часть значений помещена в глобальыне переменные. Если вы хотите настроить стили по умолчанию, просто переопределите соответствующие переменные в вашем собственном файле *.css внутри CSS псевдокласса :root.
Так, например, если вы хотите изменить цвет основного элемента (с классом sm-item-primary), вам нужно всего лишь перезаписать соответствующую переменную:

/* Your own custom.css file: */
:root {
  --sm-color-light-primary: 255, 0, 0, 0.5;
}

Демо

Skeleton Mammoth Live Demo.
Skeleton Mammoth Live Demo.

Вы можете опробовать готовый результат в действии по следующей ссылке: Live demo.

Давайте подытожим

После того, как я долго изучал тему скелетонов, их разновидностей, использования, подходов к разработке, мне удалось собрать суть полезной информации и превратить ее в конечный продукт. Собрав лучшие практики, улучшив их и объединив в единое целое, я создал библиотеку под названием Skeleton Mammoth. Я считаю, что мне удалось достичь поставленных целей и создать довольно неплохую библиотеку со всеми достоинствами, описанными в этой статье. Я надеюсь, что эта библиотека способна принести пользу людям при ее использовании или дать новые знания и опыт для создания чего-то своего.

Ваша поддержка

Если вы находите для себя полезной мою библиотеку и хотели бы выразить свою поддержку, есть простые способы сделать это:

Ставьте звездочку в GitHub репозитории: это помогает повысить его видимость и позволяет другим узнать, что библиотека имеет сильную пользовательскую базу.

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

Ниже я размещу список полезных ссылок, в том числе ссылку на библиотеку.

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

Комментарии (5)


  1. Spaceoddity
    03.08.2023 05:05

    Извиняюсь за оффтоп, но может стоит бороться с причиной, а не следствием?
    Вместо того чтобы максимально облегчать страницу, фронтенд сейчас готов запаковать в билд весь репозиторий npm...

    Что мне толку от вашей "обратной визуальной связи"? Мне контент нужен!))


    1. WOLFRIEND Автор
      03.08.2023 05:05

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

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


      1. Spaceoddity
        03.08.2023 05:05

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

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

        А что до "где нужно запрашивать огромные количества данных" - ну это тогда вопросы к архитектуре. Запросить один массив данных - не самая ресурсоёмкая задача. Поясню - "чистые данные" (условный json) - весят совсем не много, в отличие от медиаконтента. Допускаю, что в каких-то проектах надо крутить прям огромные массивы данных, но тогда возникает логичный вопрос - какого лешего вы собрались делать это на клиенте? Вы же представляете себе зоопарк нынешних девайсов? И что обрабатывать такие объёмы данных придётся на условном смартфоне 7-летней давности...

        Можно представить совсем уж специфическую ситуацию - у нас тяжеленное служебное приложение исключительно под десктопы. Но тогда я думаю пользователи этого приложения не облезнут и подождать десяток секунд (ну я же жду когда какой-нибудь raw-конвертер минуту стартует) без отрисовки этих скелетонов))

        В целом, это конечно полезная практика. Равно как и семантичный код, "юзабилити", "аксессабилити" и т.п. Вопрос лишь в целесообразности. Стоит ли этот "доступный интерфейс" потраченных ресурсов на его разработку?


        1. WOLFRIEND Автор
          03.08.2023 05:05

          Да, я понял посыл, и в принципе согласен, что зачастую нужно работать над архитектурой и качеством приложения, а не пытаться это скрыть за анимашками и крутилками))

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


          1. Spaceoddity
            03.08.2023 05:05

            Не, вопросов нет - статья ваша безусловно полезна. Может даже и мне когда пригодится))

            И даже без препроцессоров (уж очень "фреймворщики" их любят) - респект.

            Но вот моментик смутил:

            .sm-loading .sm-item-primary - слишком общий селектор, кмк. надо будет очень внимательно отсматривать код, чтобы в блок .sm-loading не пролезли не нужные в нём .sm-item-primary