Все статьи | Разработка

Совершенный код: состояние в модулях

Совершенный код: состояние в модулях главное изображение

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

Эти проблемы не специфичны для JavaScript, то же самое встречается и во многих других интерпретируемых языках, таких как Python, Ruby или PHP.

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

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

export const pi = '3.14';

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

// Где-то в одном месте
import { pi } from '../index.js';

// Где-то в другом месте
import { pi } from '../index.js';

Возникает вопрос, сколько раз реально вызывается содержимое файла index.js? Проверить очень легко — достаточно внутри модуля вызвать печать на экран:

console.log('!!!');
export const pi = '3.14';

Запустив программу, можно увидеть, что вызов произошел ровно один раз. То же самое относится и к константе. Она была определена ровно один раз. В этом смысле export сильно отличается от return внутри функций. return вызывается на каждый вызов, а export срабатывает только при первом импорте, и затем всегда переиспользуется.

Из этой особенности есть важное следствие — модуль легко превратить в хранилище глобального состояния.

// state.js

export default {
  users: [],
};

Где-то в других частях системы:

// Один файл
import state from '../state.js';

// Какая-то функция-обработчик
const addUser = (data) => {
  // тут логика
  const user = /* ... */;
  // и сохранение
  state.users.push(user);
};

// Другой файл
import state from '../state.js';

// Какая-то функция-обработчик
const getUsers = () => {
  // Если до этого где-то вызывался addUser, то внутри окажутся данные
  return state.users;
};

Обратите внимание. Хотя это и разные файлы, объект, который импортируется из state.js, всегда тот же самый.

Что же здесь произошло? Хотя код кажется удобным для использования, он основывается на практиках, которые в программировании всегда считались плохими. Фактически здесь была создана глобальная переменная, к которой может получить доступ любая часть системы (через импорт) и изменить ее любым способом. Это довольно опасно, и может приводить к серьезным ошибкам. Именно поэтому глобальные переменные рекомендуется избегать всегда, когда это можно сделать.

Частично проблему можно решить методами доступа, добавленными к состоянию:

// state.js

const state = {
  users: [],
};

export const addUser = (user) => state.users.push(user);
export const getUsers = () => state.users;

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

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

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

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

Локальное состояние

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

// На вход принимает селектор указывающий на элемент с автокомплитом
export default (selector) => {
  const state = {
    // Начальное состояние конкретного автокомплита
  };

  const el = document.querySelector(selector);
  // Логика автокомплита
};

И использование:

const autocomplete1 = addAutocomplete('.input1');
const autocomplete2 = addAutocomplete('.input2');

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

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

Допустимое глобальное состояние

Есть ли примеры, когда глобальное состояние допустимо? Как минимум, им пользуются многие библиотеки для конфигурации или своих внутренних нужд. Это может рождать определенные сложности, как описано выше, но довольно часто мешает не сильно:

// Библиотека для работы с текстами в приложении
import i18next from 'i18next';

// Вызовы методов без использования результата — это гарантированные мутации внутри
i18next.init({
  lang: 'ru',
});


// Валидация
import * as yup from 'yup';

// Вызов меняющий состояние yup
yup.setLocale({
  mixed: {
    default: 'Não é válido',
  },
  number: {
    min: 'Deve ser maior que ${min}',
  },
});

Итого. Никогда не используйте глобальное состояние для данных. Если нужно хранить данные – используйте объекты, создаваемые внутри приложения.

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

Аватар пользователя Kirill Mokevnin
Kirill Mokevnin 26 июня 2020
Рекомендуемые программы

С нуля до разработчика. Возвращаем деньги, если не удалось найти работу.

Иконка программы Фронтенд-разработчик
Профессия
Разработка фронтенд-компонентов веб-приложений
25 мая 10 месяцев
Иконка программы Python-разработчик
Профессия
Разработка веб-приложений на Django
25 мая 10 месяцев
Иконка программы PHP-разработчик
Профессия
Разработка веб-приложений на Laravel
25 мая 10 месяцев
Иконка программы Node.js-разработчик
Профессия
Разработка бэкенд-компонентов веб-приложений
25 мая 10 месяцев
Иконка программы Fullstack-разработчик
Профессия
Новый
Разработка фронтенд и бэкенд компонентов веб-приложений
25 мая 16 месяцев
Иконка программы Верстальщик
Профессия
Вёрстка с использованием последних стандартов CSS
в любое время 5 месяцев
Иконка программы Java-разработчик
Профессия
Разработка приложений на языке Java
25 мая 10 месяцев
Иконка программы Разработчик на Ruby on Rails
Профессия
Создает веб-приложения со скоростью света
25 мая 5 месяцев