Добавить в корзинуПозвонить
Найти в Дзене

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

В интерпретируемых языках, подобных JavaScript, внутри файлов можно писать любой код: определения функций, вызовы функций, определения и изменения переменных. Такая свобода очень упрощает всю разработку.
С другой стороны, при неаккуратной разработке появляются ошибки, значительно усложняющие код и его поддержку. CEO Хекслета Кирилл Мокевнин подробно рассказывает, почему эти ошибки часто встречаются в продакшен коде и как не допустить их появление. Эти проблемы не специфичны для JavaScript, то же самое встречается и во многих других интерпретируемых языках, таких как Python, Ruby или PHP Подробнее о разнице между модулями и скриптами можно прочитать в статье. Здесь же мы сосредоточимся на неверно спроектированных модулях. Предположим, что у нас есть модуль index.js с таким содержимым: export const pi = '3.14';
Где-то в других местах программы он импортируется и используется. Как правило, импорт подобных модулей происходит не в одном месте, а в разных местах программы. // Где-то в одн

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

С другой стороны, при неаккуратной разработке появляются ошибки, значительно усложняющие код и его поддержку. CEO Хекслета Кирилл Мокевнин подробно рассказывает, почему эти ошибки часто встречаются в продакшен коде и как не допустить их появление.

Эти проблемы не специфичны для 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;

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

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

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

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

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

// Библиотека для работы с текстами в приложении
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}',
},
});

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