Ленивая загрузка модулей JavaScript с помощью ConditionerJS

Привязка функциональности JavaScript к элементам DOM может быть нудной и трудоёмкой задачей. Плагин Conditioner призван не только освободить вас от этой работы, но и сделать многое другое!

Плагин Conditioner и прогрессивное улучшение

Conditioner - это не фреймворк для создания веб-приложений. Он нацелен на сайты.

Сайты нужны для представления контента пользователям. HTML семантически описывает контент, а CSS оформляет его. JavaScript добавляет крутизны пользовательскому функционалу. Представьте себе элемент выбора даты, навигацию, анимацию при прокрутке или карусели.

Примерами контентно-ориентированных сайтов являются Wikipedia, Smashing Magazine, сайт муниципалитета, газеты. Веб-приложения зачастую нацелены на инструментарий: почтовик, сервис онлайн-карт. При этом они также предоставляют контент.

Существует огромная серая зона между этими двумя понятиями. Но это разделение поможет определиться, когда плагин Conditioner может быть эффективен, а когда стоит обойтись без него.

Проблемный третий акт

Функциональность JavaScript зачастую добавляется на сайты следующим образом:

  1. К элементу HTML добавляется класс.
  2. Метод querySelectorAll используется для получения всех элементов, связанных с этим классом.
  3. Цикл перебирает список NodeList, возвращённый на шаге 2.
  4. Функция JavaScript вызывается для каждого элемента в списке.

Быстро представим этот процесс в виде кода, добавив автозаполнения к полю для ввода. Мы создадим файл с именем autocomplete.js и добавим его на веб-страницу при помощи тега <script>.

function createAutocomplete(element) {
  // наша логика автозаполнения
  // ...
}
<input type="text" class="autocomplete"/>
<script src="autocomplete.js"></script>
<script>
var inputs = document.querySelectorAll('.autocomplete');
for (var i = 0; i < inputs.length; i++) {
  createAutocomplete(inputs[i]);
}
</script>

Посмотреть демо →

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

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

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

if (window.innerWidth <= 480) {
  // циклы для маленьких экранов
}

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

Копания в душе

Я горячий сторонник разделения трёх слоёв веб-разработки: HTML, CSS и JavaScript. HTML не должен быть жёстко связан с JavaScript, поэтому никаких атрибутов onclick. То же самое относится и к CSS, поэтому никаких атрибутов style. Добавление классов к элементам HTML и их поиск в циклах прекрасно следует этой философии.

Помню, как мучился над статьёй про использование атрибутов данных вместо классов, и как они могли бы быть использованы для связывания функциональности JavaScript. Мне казалось, что это просто прикрытие для атрибута onclick, смешение HTML и JavaScript.

Несколько недель спустя я понял, что связь функциональности JavaScript через атрибуты данных соответствует концепции разделения слоёв HTML и JavaScript.

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

<input type="text" data-module="autocomplete">
<script src="autocomplete.js"></script>
<script>
var inputs = document.querySelectorAll('[data-module=autocomplete]');
for (var i = 0; i < inputs.length; i++) {
  createAutocomplete(inputs[i]);
}
</script>

Посмотреть демо →

Мы только заменили .autocomplete на [data-module=autocomplete]. Чем это лучше? Но не расстраивайтесь, поскольку это краеугольный камень к мегациклу.

Добавим пару штрихов:

<input type="text" data-module="createAutocomplete">
<script src="autocomplete.js"></script>
<script>
var elements = document.querySelectorAll('[data-module]');
for (var i = 0; i < elements.length; i++) {
    var name = elements[i].getAttribute('data-module');
    var factory = window[name];
    factory(elements[i]);
}
</script>

Посмотреть демо →

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

  1. Ищем элементы на странице, имеющие атрибут data-module;
  2. Проходим в цикле по элементам;
  3. Получаем имя модуля из атрибута data-module;
  4. Сохраняем ссылку на функцию впеременной factory;
  5. Вызываем JavaScript-функцию factory и передаём ей элемент.

Мы сделали имя модуля динамическим, поэтому больше не потребуются дополнительные циклы.

У этого подхода есть и другие преимущества:

  • Скрипту инициализации не нужно знать, что он загружает;
  • Благодаря привязке функциональности к элементам DOM легко понять, какие элементы HTML будут дополнены кодом JavaScript.
  • Скрипт инициализации не ищет модули, которых не существует. Нет больше лишних поисков по дереву элементов.
  • Скрипт инициализации завершён. Больше не нужны никакие дополнения. Когда мы добавим функционал на веб-страницу, он будет обнаружена автоматически и начнёт работать.

А как же Conditioner?

При помощи единого цикла мы автоматически загружаем функционал:

  1. Добавляем атрибут data-module к элементу.
  2. Добавляем тэг <script>, ведущий к функционалу на веб-страницу.
  3. Цикл связывает нужный функционал с каждым элементом.

Чтобы сделать цикле более гибким и универсальным, нужно:

  • Переместить глобальные функции в изолированные модули. Это предотвратит загрязнение кода. Также это сделает наши модули более портативными. Благодаря этому нам больше не придётся добавлять тэги <script> вручную.
  • При использовании модулей в нескольких, придётся добавлять дополнительные опции конфигурации. Например, ключи API, названия меток, скорость анимации.
  • При постоянно растущем разнообразии пользовательских устройств мы рано или поздно столкнёмся с ситуацией, когда потребуется загружать модуль только в определённом контексте. Например, меню, которое нужно сворачивать на маленьких экранах.

Вот в этом плагин Conditioner и может помочь. Заменим наш цикл инициализации Conditioner.

Переход на Conditioner

Загрузить библиотеку Conditioner можно из репозитория GitHub, npm или с unpkg. Добавьте скрипт Conditioner на веб-страницу. Самый быстрый путь – подключить его с unpkg.

<script src="https://unpkg.com/conditioner-core/conditioner-core.js"></script>

Поведение плагина Conditioner по умолчанию в точности такое же, как и у нашего цикла. Он будет искать элементы с атрибутом data-module и глобально связывать их с функциями JavaScript.

Начнем этот процесс, вызвав метод hydrate плагина Conditioner.

<input type="text" data-module="createAutocomplete"/>
<script src="autocomplete.js"></script>
<script>
conditioner.hydrate(document.documentElement);
</script>

Посмотреть демо →

Мы передаём documentElement в метод hydrate. Это заставляет Conditioner искать элементы с атрибутом data-module в дереве элемента <html>:

document.documentElement.querySelectorAll('[data-module]');

Теперь заменим функции модулями. Модули – это повторно используемые куски кода JavaScript, которые предоставляют нужную функциональность.

Переход от глобальных функций к модулям

Первым шагом будет преобразование функции createAutocomplete в модуль. Создадим файл autocomplete.js. Мы добавим в этот файл единственную функцию и сделаем её экспортируемой по умолчанию.

export default function(element) {
  // логика автозаполнения
  // ...
}

Преобразуем классические функции в стрелочные.

export default element => {
  // autocomplete logic
  // ...
}

Теперь можно импортировать модуль autocomplete.js и использовать экспортируемые функции:

import('./autocomplete.js').then(module => {
  // функция autocomplete расположена в module.default
});

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

Изменим атрибут data-module на ./autocomplete.js, так чтобы он совпадал с файлом модуля и относительным путём.

Запомните: Метод import() использует относительный путь к текущему модулю. Если не поставить ./ перед autocomplete.js, то браузеру не удастся найти модуль.

Чтобы динамически загружать модули ES Modules, перепишем метод moduleImport. А также укажем плагину, где искать конструктор module.default.

<input type="text" data-module="./autocomplete.js"/>
<script>
conditioner.addPlugin({
  // получаем модуль при помощи динамического импорта
  moduleImport: (name) => import(name),
  // получаем конструктор модуля
  moduleGetConstructor: (module) => module.default
});

conditioner.hydrate(document.documentElement);
</script>

Посмотреть демо →

Теперь Conditioner автоматически будет загружать ./autocomplete.js, а затем вызовет функцию module.default и передаст ей элемент в качестве параметра.

Определение скрипта громоздко. Это можно исправить переопределением метода moduleSetName. Conditioner смотрит на значение в data-module как на псевдоним и использует только значение, возвращаемое moduleSetName в качестве настоящего имени модуля. Будем автоматически добавлять расширение js и относительный путь.

<input type="text" data-module="autocomplete"/>
…
conditioner.addPlugin({
  // преобразует псевдоним модуля в путь
  moduleSetName: (name) => `./${ name }.js`
});

Посмотреть демо →

Теперь мы можем указывать в data-module просто autocomplete вместо ./autocomplete.js. Так лучше.

Мы настроили Conditioner на загрузку ES Modules.

Передача параметров конфигурации в наши модули

У «коробочной версии» Conditioner нет возможности передавать настройки в модули. Мы изучим другие опции передачи переменных в модули и затем используем API плагина, чтобы настроить автоматическую работу.

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

window.autocompleteSource = './api/query';
export default (element) => {
  console.log(window.autocompleteSource);
  // напишет в лог './api/query'
  // логика автозаполнения
  // ...
}

Не делайте этого.

Лучше просто добавить дополнительные атрибуты данных.

<input type="text"
       data-module="autocomplete"
       data-source="./api/query"/>

Они могут быть доступны внутри модуля при обращении к свойству dataset, которое возвращает DOMStringMap со всеми атрибутами данных.

export default (element) => {
  console.log(element.dataset.source);
  // напишет в лог './api/query'
  // логика автозаполнения
  // ...
}

Чтобы не обращаться к element.dataset в каждом модуле, выделим dataset и внедрим его как параметр настройки при сборке модуля. Давайте переопределим moduleSetConstructorArguments.

conditioner.addPlugin({
  // имя модуля и элемент, к которому он привязан
  moduleSetConstructorArguments: (name, element) => ([
    element,
    element.dataset
  ])
});

Метод действия moduleSetConstructorArguments возвращает массив параметров, который будет передан в конструктор модуля.

export default (element, options) => {
  console.log(options.source);
  // напишет в лог './api/query'
  // логика автозаполнения
  // ...
}

Было бы хорошо, если бы ключ API предоставлялся «автоматически» без его добавления в качестве атрибута к каждому элементу.

Добавим объект конфигурации на уровне веб-страницы.

const pageOptions = {
  // псевдоним модуля
  autocomplete: {
    key: 'abc123' // ключ api
  }
}
conditioner.addPlugin({
  //имя модуля и элемент, к которому он привязан
  moduleSetConstructorArguments: (name, element) => ([
    element,
    // объединяем настройки страницы по умолчанию с настройками элемента
    Object.assign({},
      pageOptions[element.dataset.module],
      element.dataset
    )
  ])
});

Посмотреть демо →

Переменная pageOptions объявлена как константа, поэтому на не загрязнит собой глобальную область видимости

Используя Object.assign, мы объединяем pageOptions и DOMStringMap свойства dataset элемента. В результате объект настроек будет содержать как свойство source, так и свойство key. Если какой-то из элементов автозаполнения на веб-странице будет иметь атрибут data-key, он перезапишет для этого элемента ключ, определённый в pageOptions.

const ourOptions = Object.assign(
  {},
  { key: 'abc123' },
  { source: './api/query' }
);
console.log(ourOptions);
// вывод: {  key: 'abc123', source: './api/query' }

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

Условная загрузка модулей, основываясь на контексте пользователя

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

[IMG=https://cloud.netlifyusercontent.com/assets/344dbf88-fdf9-42bb-adb4-46f01eedd629/6932c42b-58cf-4adf-9048-85d3cb32d004/lazyloading-with-conditionerjs-image2.png]

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

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

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

Наш новый модуль section-toggle будет использоваться для скрытия главного меню под кнопкой-переключателем в случае просмотра сайта на маленьких экранах.

Поскольку section-toggle должен быть выгружаемым, то функция по умолчанию должна возвращать другую функцию. Плагин Conditioner будет вызывать эту функцию, когда он выгружает модуль.

export default (element) => {
  // логика sectionToggle
  // ...
  return () => {
    // логика выгрузки sectionToggle
    // ...
  }
}

Нам не нужно это поведение на больших экранах, поскольку на них достаточно места для полноценного меню. Нужно свернуть его на экранах шириной меньше 30 em (что эквивалентно 480px).

HTML-код меню:

<nav>
  <h1 data-module="sectionToggle"
      data-context="@media (max-width:30em)">
      Navigation
  </h1>
  <ul>
    <li><a href="/home">home</a></li>
    <li><a href="/about">about</a></li>
    <li><a href="/contact">contact</a></li>
  </ul>
</nav>

Посмотреть демо →

Атрибут data-context вызовет плагин Conditioner, чтобы автоматически загрузить мониторинг контекста, отслеживаю значение медиа-запроса max-width. Когда пользовательский контекст совпадет с этим медиа-запросом, плагин загрузит модуль.

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

Можно рассматривать мониторинг как обнаружение функций: определение значения «включено/выключено», поддерживает ли браузер WebGL, или нет.

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

Постоянный мониторинг позволяет немедленно адаптироваться к изменениям контекста (без перезагрузки страницы) и оптимизирует слой JavaScript

Мониторинг медиа-запросов – это единственный, доступный по умолчанию. Добавление собственных мониторингов возможно через API плагина. Добавим мониторинг visible, который будем использовать для определения того, видим ли элемент пользователю (находится ли элемент в границах экрана при прокрутке). Чтобы сделать это, используем новый API IntersectionObserver.

conditioner.addPlugin({
  // привязка мониторинга ожидает объект конфигурации
  monitor: {
    // имя нашего монитора с '@'
    name: 'visible',
    // метод create возвратит API мониторинга
    create: (context, element) => ({
      // текущее состояние совпадения
      matches: false,
      // вызывается плагином conditioner для старта отслеживания изменений
      addListener (change) {
        new IntersectionObserver(entries => {
          // обновляем состояние совпадения
          this.matches = entries.pop().isIntersecting == context;
          // ставим плагин Conditioner в известность об изменении
          change();
        }).observe(element);
      }
    })
  }
});

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

Базовой разметкой HTML будет ссылка на изображение. Если JavaScript не загружен, ссылки всё равно будут работать и описывать изображение:

<a href="cat-nom.jpg"
   data-module="lazyImage"
   data-context="@visible">
   A red cat eating a yellow bird
</a>

Посмотреть демо →

Модуль lazyImage выделит текст ссылки, создаст элемент изображения и вставит текст ссылки в качестве альтернативного текста изображения.

export default (element) => {
  // сохраняем оригинальный текст ссылки
  const text = element.textContent;
  // заменяем текстовый элемент изображением
  const image = new Image();
  image.src = element.href;
  image.setAttribute('alt', text);
  element.replaceChild(image, element.firstChild);
  return () => {
    // возвращаем оригинальное состояние элемента
    element.innerHTML = text
  }
}

Когда ссылка появляется в видимой области, её текст заменяется тэгом img.

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

Изменим это поведение, добавив оператор was. Он заставит Conditioner сохранять текущее состояние страницы.

<a href="cat-nom.jpg"
   data-module="lazyImage"
   data-context="was @visible">
   A red cat eating a yellow bird
</a>

В нашем распоряжении есть три других оператора. Оператор not позволяет обратить результат мониторинга. Вместо @visible false можно написать not @visible, что выглядит более натурально.

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

<a href="cat-nom.jpg"
   data-module="lazyImage"
   data-context="was @visible and @media (max-width:30em) or @media (min-width:30em)">
   A red cat eating a yellow bird
</a>

Существует множество других вариантов оценки контекста:

  • Используйте Geolocation API для мониторинга местоположения пользователя: @location (near: 51.4, 5.4), чтобы загружать разные скрипты, когда пользователь находится вблизи определённого места.
  • @time динамически изменяет веб-страницу в зависимости от времени суток:@time (after 20:00).
  • Используйте Device Light API, чтобы определить уровень освещённости @lightlevel (max-lumen: 50) в текущем местоположении пользователя.

Переместив мониторинг контекста за пределы модулей, мы сделали их ещё более портативными. Если нужно добавить свёртываемые секции на одну из веб-страниц, можно повторно использовать section toggle. Он не зависит от контекста, в котором используется.

Использование плагина Conditioner в JavaScript

Плагин Conditioner состоит всего из трёх методов. Мы уже сталкивались с методами hydrate и addPlugin. Теперь рассмотрим метод monitor. Он позволяет отслеживать контекст вручную.

const monitor = conditioner.monitor('@media (min-width:30em)');
monitor.onchange = (matches) => {
  // вызывается, когда замечено изменение в контексте
};
monitor.start();

Этот метод упрощает совместное использование Conditioner с такими фреймворками, как React, Angular или Vue для мониторинга контекста.

В качестве быстрого примера я создал компонент React <ContextRouter>, который использует плагин Conditioner для отслеживания пользовательского контекста и переключения между представлениями. По большей части он основан на React Router, поэтому может показаться знакомым.

<ContextRouter>
    <Context query="@media (min-width:30em)"
             component={ FancyInfoGraphic }/>
    <Context>
        // переход на меньшие экраны
        <table/>
    </Context>
</ContextRouter>

Заключение

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

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

Используя это в совокупности, можно увеличить скорость загрузки веб-страниц и улучшить функционал для каждого конкретного пользователя. Это приведёт к улучшению пользовательского опыта и повысит ваши навыки веб-программирования.