Разработка

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

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

В скриптовых языках, подобных 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

Зарегистрироваться

или войти в аккаунт

Курсы программирования для новичков и опытных разработчиков. Начните обучение бесплатно.

  • 115 курсов, 2000+ часов теории
  • 800 практических заданий в браузере
  • 250 000 студентов

Нажимая кнопку «Зарегистрироваться», вы даёте своё согласие на обработку персональных данных в соответствии с «Политикой конфиденциальности» и соглашаетесь с «Условиями оказания услуг».

Наши выпускники работают в компаниях:

Логотип компании Альфа Банк
Логотип компании Rambler
Логотип компании Bookmate
Логотип компании Botmother