Состояние приложения

Манипулирование DOM — задача простая только в самых примитивных ситуациях. Как только понадобится реализовать полноценное, пусть и небольшое приложение, код моментально превращается в тыкву. Десятка обработчиков достаточно для того, чтобы потеряться. С каждым новым событием сложность кода растёт ещё быстрее, а ведь в реальных приложениях событий сотни. Почему так происходит?

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

Подойдём к правильной архитектуре со стороны бэкенда. Как вы уже знаете или догадываетесь, в бэкенде приложения состоят минимум из двух частей — базы данных и собственно самого кода. Формы отправляемые на сервер, изменяют состояние (то есть его данные) приложения, которое хранится в базе, далее, на основе этого состояния формируется ответ в виде HTML-страницы.

Из браузера в базу данных через приложение

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

  • Возникает событие
  • Обработчик события меняет состояние
  • DOM обновляется на основе новых данных

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

See the Pen js_dom_state_simple by Hexlet (@hexlet) on CodePen.

Главная особенность кода выше, в том, как идёт работа с состоянием. Здесь нет никаких обращений к DOM для извлечения текущего значения, оно хранится в переменной и доступно для всех обработчиков.

Обратите внимание на структуру кода. Определение состояния, обращение к DOM-элементам, все это находится внутри определения функции app(), а не на уровне модуля:

В коде выше функция app() вызывается сразу же в этом модуле. Это сделано только для удобства демонстрации, в реальности же вызов должен быть в другом модуле

// Модуль с кодом

// Не правильно
// Прямо на уровне модуля все функций
let counterValue = 0;
const result = document.getElementById('result');

const app = () => {
  // Правильно
  // Внутри функции, которая стартует (инициализирует) приложение

  let counterValue = 0
  const result = document.getElementById('result');

  // остальной код
};

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

// Где-нибудь в тестах
// Если бы внутри app.js был код вне функций, он бы начал сразу исполняться
// Если в тесте нет document, то этот импорт упал бы с ошибкой
import app from './app.js';

test('app', () => {
  // Инициализируем приложение в тесте
  app();
});

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

// Демонстрация плохого примера
// Предположим что состояние определено прямо на уровне модуля app.js
import './app.js';

test('app1', () => {
  // инкрементируем счетчик
  // выполняем проверки
  // количество нажатий = 1
});

test('app', () => {
  // уменьшаем счетчик
  // ожидаем что он будет равен -1
  // но в реальности он равен 0
  // потому что состояние общее для обоих тестов (и всех остальных)
});

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

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

See the Pen js_dom_state_object by Hexlet (@hexlet) on CodePen.

Как видно из примера, состояние описывается обычным JS-объектом, который создаётся при старте приложения:

const state = {
  registrationProcess: {
    valid: true,
    submitDisabled: true,
  }
};

Состояние приложения не ограничивается примерами выше. Возьмем для примера вопросы/ответы Хекслета (можно посмотреть нажав на кнопку в правом меню). Ниже неполный список данных находящихся в состоянии этой части фронтенда:

  • Список топиков
  • Список авторов топиков
  • Список лайков
  • Список комментариев
  • Отслеживание процесса создания новых топиков и комментариев
  • Открытость и раскрытость комментариев
// Гипотетический пример
const state = {
  topics: [/* ... */],
  topicLikes: [/* ... */],
  topicComments: [/* ... */],
  /* остальные данные */
}

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

const state = {
  centralBlock: {
    valid: true,
    submitDisabled: true,
  },
  sideBar: {
    formValue: 'value'
  },
};

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

Далее обработчики событий. Они должны иметь доступ к состоянию, так как оно меняется именно в обработчиках. Поэтому обработчики определяются в той же функции где и создаётся состояние (главное не делать это на уровне модуля, состояние должно быть локально относительно приложения). Кроме того, обработчики это то место, где выполняются AJAX-запросы. В нашем примере их нет, но на будущее не забывайте.

input.addEventListener('keyup', () => {
  if (input.value === '') {
    state.registrationProcess.valid = true;
    state.registrationProcess.submitDisabled = true;
  } else if (!input.value.match(/^\d+$/)) {
    state.registrationProcess.valid = false;
    state.registrationProcess.submitDisabled = true;
  } else {
    state.registrationProcess.valid = true;
    state.registrationProcess.submitDisabled = false;
  }

  render(state);
});

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

Теперь наше приложение разделено на три независимых части: состояние (данные приложения), обработчики и рендеринг. Эта модель работы на тривиальных приложениях (в пару тройку обработчиков) смотрится избыточной, но если обработчиков станет хотя бы 10, то вы увидите, что с приложением достаточно удобно работать. Виден поток данных, то есть движение данных в приложении от одних частей к другим, от обработчика до отрисовки в DOM. Всегда можно отследить, что изменилось и как одни части приложения зависят от других. К тому же сокращается дублирование. Например, изменение состояния может идти из разных частей приложения, но логика отрисовки при этом остаётся неизменной. В такой ситуации достаточно описать новый способ изменения уже существующего состояния, а рендеринг сделает всё остальное.

const render = (state) => {
  const input = document.querySelector('.phone');
  const submit = document.querySelector('.submit');
  submit.disabled = state.registrationProcess.submitDisabled;
  if (state.registrationProcess.valid) {
    input.style.border = null;
  } else {
    input.style.border = 'thick solid red';
  }
}

Кроме наличия разделения на три части, не менее важно то, как они друг с другом взаимодействуют, более того, это основа модульности:

  • Состояние не знает ничего про остальные части системы — оно ядро.
  • Рендеринг пользуется состоянием для отрисовки и добавляет новые обработчики в DOM
  • Обработчики знают про состояние, так как обновляют его и инициируют рендеринг

Этот способ разделения по-прежнему обладает одним важным недостатком, который мы устраним в уроке, посвящённом MVC.


Дополнительные материалы

  1. Скрипты, модули и библиотеки
Мы учим программированию с нуля до стажировки и работы. Попробуйте наш бесплатный курс «Введение в программирование» или полные программы обучения по Javascript, PHP, Python и Java.

Хекслет

Подробнее о том, почему наше обучение работает →