Пишем фронтенд-компоненты на ванильном JS

В наши дни вокруг фронтенд-фреймворков (React, Angular, Vue) много шумихи. Поразмышляем, какую проблему они решают и для чего могут быть полезны.

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

Созданная функция будет представлять собой раскрывающийся список элементов. Она должна принимать параметр, указывающий на количество элементов, отображаемых при начальной загрузке. При нажатии на “Показать больше” (Show more) будут показаны все элементы в списке. “Показать меньше” (Show less) скроет все элементы, кроме ранее выбранного числа. Фича должна быть расширяемой, чтобы добавлять на одну страницу несколько таких списков.

Конечный результат

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

Планирование

Прежде чем приступать, нужно рассмотреть несколько моментов.

  1. Дизайн кода + модульность: как мы собираемся выстроить фичу, чтобы ее можно было применять сразу к нескольким сворачиваемым спискам на одной странице и не загрязнить глобальное пространство имен?
  2. Рендеринг HTML: как мы собираемся рендерить HTML для компонента?
  3. Управление состояниями: как мы будем сохранять и контролировать внутреннее состояние компонента? Какое именно состояние будем сохранять?
  4. Уничтожение элемента: как мы реализуем удаление компонента и обработчиков событий из документа, когда они больше не нужны?

Затем, наконец, можно приступить к созданию JS-фичи, добавляя функции в код.

1. Дизайн кода + модульность

Требования гласят, что компонент должен быть доступен для переиспользования во многих экземплярах списка. Как добиться этого в JS?

Способ заключается в создании того, что называется “модулем”. Это набор переменных и функций, которые инкапсулированы в единый контекст выполнения, поэтому переменные внутри будут частными и ограниченными. Другими словами, когда вы создаете объект или функцию в JavaScript, то назначаете границу вокруг функций и переменных внутри них, делая эти значения уникальными.

Узнать больше о шаблоне модуля JavaScript можно из главы о модулях в книге Эдди Османи “Изучение шаблонов проектирования Javascript”. В очень упрощенном виде это означает, что мы пишем код со следующей структурой:

// Литерал объекта
var myFeature = {
    myProperty: "hello",
 
    myMethod: function() {
        console.log( myFeature.myProperty );
    },
 
    init: function( settings ) {
        myFeature.settings = settings;
    },
 
    readSettings: function() {
        console.log( myFeature.settings );
    }
};
 
myFeature.myProperty === "hello"; // истинно
 
myFeature.myMethod(); // "hello"
 
myFeature.init({
    foo: "bar"
});
 
myFeature.readSettings(); // { foo: "bar" }

Мы определяем компонент в области действия функции (либо внутри объекта, либо внутри функции, возвращающей объект). Такая структура позволяет написать компонент, все переменные которого ограничены только конкретным экземпляром списка. Так нам не придется больше беспокоиться о расширяемости модуля в будущем. Нет проблемы создать на одной странице один, два или пять списков. Вот модуль, который мы написали:

var collapsible = ( elem, numberItems = 2) => {
    
    let state = { closed : true };
    
    let $elem = document.querySelector(elem);
    let $listItems = $elem.querySelectorAll("li");
    let $showMoreLink = $elem.querySelector(".show-more-link");

    let render = () => {
      // ...
    }
    
    // 
    $elem.addEventListener('click', function (event) {
      // ...
    })
    
    return $elem;
    
  };

В приведенном выше примере важно отметить не специфику выполнения функций, а то, что мы начинаем с извлечения корневого элемента elem и создания переменной state. Обе эти переменные сохраняются при выполнении функции. Если вызвать console.log(state) вне этой функции, переменная будет не определена. Состояние и другие ссылки на элементы DOM  —  частные для этой функции.

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

return $elem

Здесь можно было бы раскрыть больше внутренних процессов:

return {
    $elem: elem, 
    render: render, 
    setState: setState
}

// Произведет тот же самый объект, но немного чище

return {$elem, render, setState}

Затем пользователь компонента вызывал бы эти действия извне функции следующим образом:

var collapsibleList1 = collapsible("#mycollapsibleelement")

// Позже в коде
collapsibleList1.setState("closed") // Смена состояния
collapsibleList1.render() // Ре-рендер компонента

Здесь следует отметить еще одно: для обозначения элемента DOM используется $. Это соглашение Jquery, популярной библиотеки методов расширения Javascript, но оно распространилось повсюду для определения переменных в JS, указывающих на один или несколько узлов DOM.

2. Рендеринг HTML-кода

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

<div class="show-hide-module">
    <h3>Fruits</h3>
    <ul class="collapse-list">
      <li>Tomato🍅</li>
      <li>Grapes 🍇</li>
      <li>Watermelon 🍉</li>
      <li>Mango 🥭</li>
      <li>Peach 🍑</li>
    </ul>
    <a class="collapse-link"> show more </a>
  </div>

На первый взгляд, на основе пользовательского ввода придется ре-рендерить не так уж и много. Единственное, что могло бы часто меняться,  —  это текст “показать больше”. Что нам нужно сделать, так это отделить логику рендеринга HTML от остальной части компонента.

// Проверка "состояния" компонента (открыто/закрыто)
// и рендер на основе этого
let render = () => {
  state.closed ?
    $showMoreLink.textContent = "show more" : 
    $showMoreLink.textContent = "show less";
}

3. Сохранение состояния компонента

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

Определить состояние просто: let state = {closed:true}. Затем события могут вызвать изменение состояния, что поменяет значение этой переменной в области действия функции. А это, в свою очередь, вызовет повторный рендеринг, влияющий на отображение. Таков проверенный временем паттерн, который более причудливыми способами реализуется во всех современных фронтенд-фреймворках.

var collapsible = ( elem, numberItems = 2) => {
    
    let state = {
      closed : true
    };
    
    let $elem = document.querySelector(elem);
    // ... 

    let render = () => {
      // Эффекты теперь могут быть основаны на изменении состояния
    }
    
    $showMoreLink.addEventListener('click', function (event) {
        state.closed === true ? 
          state.closed = false :
          state.closed = true;
        }
        // Передаем новое состояние в функцию рендера
        render(); 
    })
    
    return $elem;
    
  };

  collapsible("#list1");

4. Уничтожение элемента

Все JS-разработчики знают: когда для запуска кода больше не нужен браузер, то нужно “размонтировать” его из DOM и удалить все несработавшие обработчики событий, которые прикреплены к этому экземпляру. Для этого сделаем заготовку функции destroy, которая удалит обработчики кликов и уберет элемент из DOM.

function destroy () {
  // Убирает элемент и его дочерние элементы
  $elem.parentNode.removeChild($elem);
}

Создание фичи

Наконец-то мы готовы создать необходимую фичу. Сначала нужно определить JS-функцию, которая будет модулем для компонента (опять же, термин “модуль” здесь означает часть JS-кода, который будет определять область действия и применяться только к целевому элементу). В приведенном ниже коде строка collapsible($module1, 2) говорит: “Создайте новый модуль JS-кода, который применяет функциональность свертывания только к элементу под названием module1”.

Внутри этого модуля мы определяем пару инициализирующих переменных, таких как высота списка на основе элементов внутри, а также несколько элементов DOM, которые нужны изнутри этого модуля (первичный элемент $elem, а также внутренний список).

Затем мы определяем обработчик init, который присоединен к данному экземпляру. Внутри него мы применим обработчик кликов к элементу button, связанному с экземпляром. Конечно, есть несколько вариантов относительно того, какую функциональность размещать в init и в теле функции модуля collapsible(), поскольку обе эти функции в некотором роде инициализируют модуль. Здесь мы оставим вычисляемые свойства экземпляра в теле функции модуля, а методы обработки кликов поместить в функцию init.

<style>
body {
  font-family: sans-serif;
}
li {
  display:list;
  margin-bottom: 5px;  
  &:not(.default) {
    display:none;
  }
}
.show-more-link {
  position: relative;
  color: blue;
  &::after {
    content: "\25BC";
    position: absolute;
    bottom: 3px;
    right: -15px;
    font-size: 10px;
  }
}

.collapsible-list.open {
    a::after {
      content: "\25B2";
      bottom: 3px;
    }
    li:not(.default) {
      display: list-item;
    }
}
</style>

  <div class="collapsible-list closed" id="list1">
    <h3>Fruits</h3>
    <ul class="collapse-list">
      <li>Tomato🍅</li>
      <li>Grapes 🍇</li>
      <li>Watermelon 🍉</li>
      <li>Mango 🥭</li>
      <li>Peach 🍑</li>
    </ul>
    <a class="show-more-link"> show more </a>
  </div>
  
  <div class="collapsible-list closed" id="list2">
    <h3>Vegetables</h3>
    <ul class="collapse-list">
      <li>Broccoli 🥦</li>
      <li>Corn 🌽</li>
      <li>Carrot 🥕</li>
      <li>Eggplant 🍆</li>
    </ul>
    <a class="show-more-link"> show more </a>
  </div>

<script>
  var collapsible = ( elem, numberItems = 2) => {
    
    let state = { closed : true };
    
    let $elem = document.querySelector(elem);
    let $listItems = $elem.querySelectorAll("li");
    let $showMoreLink = $elem.querySelector(".show-more-link");

    let render = () => {
      state.closed ?
        $showMoreLink.textContent = "show more" : 
        $showMoreLink.textContent = "show less";
    }
    
    // Добавляем классы для элементов "по умолчанию", видимых даже при сворачивании
    $listItems.forEach((item, index) => {
      if(index < numberItems) item.classList.add('default');
    });
    
    // Обрабатываем ссылку
    $elem.addEventListener('click', function (event) {
        if (!event.target.matches('.show-more-link')) return;
        state.closed ? 
          state.closed = false : 
          state.closed = true;
        $elem.classList.toggle("open");
        $elem.classList.toggle("closed");
        render();
    })
    
    return $elem;
    
  };

  collapsible("#list1");
  collapsible("#list2");
  </script>

Здесь хорошо видны преимущества применения фреймворков даже для таких небольших компонентов.

  1. Модульность по умолчанию. Парадигма программирования “соглашение о конфигурации” особенно хорошо применима в области фронтенд-разработки. Фреймворки смешивают JS и HTML таким образом, что вся функциональность, написанная на JS, легко присоединяется к элементу DOM, с которым вы работаете. В ванильном JS этого не происходит.
  2. Методы жизненного цикла. Необходимость каждый раз заново думать о монтировании и размонтировании компонентов отнимает много сил. Эта функциональность включена “из коробки” во все основные JS-фреймворки. Фреймворк, скорее всего, обработает монтирование/размонтирование за вас и позволит добавлять “крючки” или функции, которые будут выполняться на определенном этапе жизненного цикла компонента.
  3. Рендер. Для этого JS-компонента мы написали очень мало логики рендеринга. Будь требования сложнее, это добавило бы трудностей даже с таким простым компонентом. Тем не менее решение с применением CSS для свертывания/развертывания все еще немного сбивает с толку и его сложнее обосновать, чем простой метод рендеринга в React.js или Vue.js.

Разработка без фронтенд-фреймворков  — полезное упражнение, которое позволяет увидеть потрясающую функциональность, к которой эти инструменты дают доступ.

Весь код из примера можно посмотреть здесь.

Спасибо за чтение!

Читайте также:

Читайте нас в Telegram, VK и Яндекс.Дзен


Перевод статьи Alex Zito-Wolf: Writing a Front End Component With Vanilla JS

Предыдущая статьяПознай прокси-объект JavaScript как самого себя
Следующая статья21 идея для автоматизации в 2021 году