Веб. К черту фреймворки! Пишем свой starter-kit с роутером и сторами. Часть 2
- вторник, 21 октября 2025 г. в 00:00:04
Продолжение статьи. Предыдущая статья немного неполная, поскольку по совету @cpud47 добавил в исходный код реализацию динамических роутов, а также страницу с примером работы. Впрочем, логика там особо не поменялась, а результат можно посмотреть в исходниках.
Говоря о реактивности, я буду подразумевать механизм, который автоматически обновляет пользовательский интерфейс при изменении данных. По сути, все, что делает веб приложение - показывает данные и обновляет их при каких-либо событиях. Интерфейс при этом должен быть полностью согласован с ними, т.е. у нас должны отображаться только актуальные данные.
Далее я буду называть такие данные, изменение которых автоматически меняет интерфейс - реактивным состоянием, просто состоянием или хранилищем. Есть множество стандартных способов решения этой задачи. Крайне рекомендую для начального ознакомления с общими подходами прочесть вот эту статью. После этой, естественно )
Здесь мы реализуем свой паттерн реактивности двумя способами. Сначала определим наивный алгоритм, а затем более изощренный. Возможность применения этих реализаций никак не связана с настройками starter-kit, указанными в предыдущей части.
Строго говоря, можно полностью обойтись и без дополнительной реализации реактивных обновлений. Веб-компоненты уже по умолчанию обладают этим свойством, причем там можно вызывать исключительно точечные обновления, а не пересоздавать целый компонент, в отличие от React. Однако, некоторые проблемы возникают, когда состояние у нас потребляют разные компоненты, находящиеся в удаленных друг от друга частях кодовой базы (или DOM-дерева).
По сути, нам нужно сделать так, чтобы некоторые данные были доступны по всему приложению. Да, в данном случае мы должны создать глобальную переменную.
Всегда желательно ограничивать объем информации, которой владеет компонент только той частью, которую он непосредственно использует для своей отрисовки. Это уменьшит возможность их непреднамеренно менять и уменьшит зацепление (coupling) с другими компонентами. Поэтому, количество таких глобальных переменных должно быть сведено к минимуму. Именно поэтому в большинстве случаев обычно достаточно встроенных в ваш фреймворк методов реактивного обновления. В React, к примеру, если недостаточно пропсов, обычно достаточно состояния. Для хранения глобальных переменных в данном случае достаточно использовать контекст без всяких ухищрений вроде MobX или Redux. Кстати, по поводу Redux... Даже его создатель отстранился от него и его не поддерживает, поскольку считает, что этот state-manager не нужен. Для многих (ну ладно, для меня в частности) наличие Redux в проекте - признак legacy или слабой команды.
Еще одна проблема в том, что обычно детишки пихают всякий мусор в глобальные реактивные состояния на случай, если "вдруг пригодится, и тогда не нужно будет потом переписывать код"... Могут даже сослаться на какие-нибудь принципы SOLID или еще что-то, что они вычитали в какой-нибудь умной книжке.

На самом деле, большинство даже не понимает базовых принципов MVC, что уж говорить о SOLID. В качестве практического правила я все-таки предлагаю применить подход, состоящий в том, что пока не появилась острая необходимость, храним все локально. С таким подходом каждый компонент будет максимально независимым и таких состояний будет минимальное количество. А когда таких состояний совсем немного, необходимость в дополнительных решениях может в принципе не появиться... Поскольку может оказаться, что встроенных во фреймворк или браузер решений хватит с лихвой.
А я ведь предупреждал...
Здесь я покажу необычный (да, я знаю толк...) способ, который я не встречал нигде, но который вполне может решить описанные выше проблемы в браузерном окружении.
Дело в том, что в браузер уже встроен MutationObserver. Его можно применить для наблюдения за нодами, находящимися как выше, так и ниже по дереву. Но кто сказал, собственно, что эти ноды обязательно должны быть видимы? Мы ведь можем создать пустую ноду совсем без контента, и по мере необходимости наблюдать за ее атрибутами, менять их где-либо... А даже вычислять атрибуты на основании изменения атрибутов... И вот у нас уже готовое состояние...
Попробуйте у себя создать простенькую страницу с таким содержимым, ну или посмотрите на результат .
HTML
<!-- Собственно, наше хранилище... -->
<div id="storage" counter="0"></div>
<span>0</span>
<br />
<button>increase counter</button>
<script>...описан ниже</script>Содержимое скрипта
// Будет зависеть от storage, считывать значение счетчика из его атрибутов
const span = document.querySelector("span");
const config = { attributes: true, attributeOldValue: true };
const targetNode = document.getElementById("storage");
const callback = (mutationList, observer) => {
for (const mutation of mutationList) {
if (mutation.attributeName === "counter") {
// да, детка, считывай мои атрибуты...
const counter = targetNode.getAttribute("counter");
span.textContent = counter;
targetNode.setAttribute("inferred-attribute", Number(counter));
}
}
};
const observer = new MutationObserver(callback);
observer.observe(targetNode, config);
const button = document.querySelector("button");
button.addEventListener("click", () => {
// без понятия, что там кто потребляет, просто обновляем состояние
targetNode.setAttribute("counter", +targetNode.getAttribute("counter") + 1);
});
Да, императивно. Да, не очень удобно. Да, атрибуты это обычно строки, но любая нода - это объект, в который мы также можем записать любое свойство, которое содержит какие-либо сложные данные (массивы, объекты). Собственно, мы можем любой атрибут связать с такими дополнительными свойствами, и при его изменении, мы будем знать, какое свойство прочитать... Но это работает и не требует никаких зависимостей.
Я же буду предполагать, что нам прям вот необходим свой механизм обновлений (велосипедостроение - двигатель прогресса). Кроме того, реализовать его собственными силами - довольно приятный и забавный опыт.
Для наших реализаций мы будем использовать способ с прокси. ИМХО, он самый удобный - достаточно попросту изменить какое-либо свойство и тогда автоматически сработают обновления. Можно было бы и использовать события, но их количество имеет тенденцию быстро увеличиваться и применять их нужно с осторожностью.
Дополнительно введем несколько ограничений:
Состоянием может быть только обычный объект. Если нам нужно что-то экзотическое, храним это значение в поле объекта.
Мы не проверяем равенство текущего и предыдущего значения поля объекта. Нужно иметь в виду, что ловушки set срабатывают при любом присваивании, в том числе и при присваивании того же значения. Не будем менять стандартное поведение браузера. Иногда для производительности нам достаточно мутировать некоторый объект (например, добавить элемент в массив) и присвоить его же полю объекта. Проверку значений на равенство можно выполнить и вручную, но в этом редко возникает необходимость.
Вот самая примитивная работающая реализация.
const makeObservable = obj => {
let store = {
...obj,
listeners: [],
connect(listener) {
this.listeners.push(listener);
return () => {
this.listeners = this.listeners.filter(cb => cb !== listener);
};
},
notify(prop, value) {
this.listeners.forEach(callback => callback(prop, value));
},
};
store = new Proxy(store, {
set(...args) {
const [observable, prop, value] = args;
const defaultReturn = Reflect.set(...args);
observable.notify(prop, value);
return defaultReturn;
},
});
return store;
};
// Пример использования:
const store = makeObservable({
inputValue: "",
});
store.connect((prop, value) => {
console.log("prop", prop);
console.log("value", value);
output.textContent = store.inputValue;
});В чем недостатки данного подхода?
Начнем с необходимости ручной очистки памяти. Обычно это не является прям такой уж большой проблемой в прямых руках. Но все подвержены ошибкам, и неплохо бы предусмотреть её очистку при удалении/уничтожении элементов. Это довольно просто. К примеру, мы можем использовать WeakRef. При подключении мы будем указывать непосредственно элемент, наличие которого необходимо контролировать. А в колбеке обращаться к нему через специальную ссылку listener (это не функция, а именно ссылка на объект). Например, так:
store.connect(output, ({ listener }) => {
listener.textContent = store.inputValue;
});Код при этом немного усложнится. Нам потребуется изменить массив слушателей на Map, где ключи - слабые ссылки, а значения - колбеки. Поменяем метод connect:
connect(listener, callback) {
const ref = new WeakRef(listener);
this.listeners.set(ref, callback);
return () => this.listeners.delete(ref);
}Также поменяется и реализация логики уведомлений:
notify({ observable, prop, value }) {
this.listeners.forEach((callback, ref) => {
const listener = ref.deref();
if (!listener) return this.listeners.delete(ref);
// observable - наше хранилище, listener - зависимый объект
callback({ observable, listener, prop, value });
});
}Вот полный пример.
Хм... Как-то уже не очень просто получается... Ладно. Ну эту проблему решили.
Что еще? Необходимость вручную фильтровать свойства, на изменения которых необходимо реагировать. Черт с ним, добавим if в колбеке, от нас не убудет... Дальше.
Сложности организации взаимодействия нескольких хранилищ. К примеру, нам надо выполнить логику, зависящую от данных в нескольких сторах. На первый взгляд, мы могли бы передать общую функцию в store1.connect(f) и store2.connect(f)... А нет, стоп. Мы же решили отфильтровать только необходимые нам свойства. Получается, что одной функцией это не решить. Ну ладно. Мы можем сделать что-то вроде этого:
const commonLogic = ({ listener }) => {...};
store1.connect((payload) => {
const { prop } = payload;
const fields = ["a", "b"];
if (fields.includes(prop)) commonLogic(payload);
});
store2.connect((payload) => {
const { prop } = payload;
const fields = ["c", "d"];
if (fields.includes(prop)) commonLogic(payload);
});Ладно. У нас появился некоторый бойлерплейт... Нам нужно указать вручную свойства, которые нужно отслеживать, что уже не так уж и весело. Хотя, вроде пока неплохо.
Получается, мы можем праздновать победу? Можно публиковать на npm и надеяться на признание, богатство и славу?
Ну... Если повезет. Но может появиться еще небольшая проблема...
На звуки зарождения новой реактивной библиотеки из глубин Хабра, пробудившись от вечного бана придет некто Дмитрий Карловский. Он расскажет тебе кто ты есть, начнет терзать твою душу, уничтожать твое самомнение, втаптывать его в грязь... И лишь объединенные усилия паладинов Хабра своими минусами способны изгнать его туда, откуда он пришел.
Да... Как-то не задалась у нас наивная реализация хранилища.. Нужно подготовиться чуть лучше.
Начнем по-взрослому.
Для начала нам нужно узнать чуть побольше. Вот превосходная, хотя и довольно тяжелая для понимания статья. Некоторые аспекты и подводные камни рассмотрены весьма и весьма неплохо.
В нашей первоначальной наивной реализации получилось совсем неудобное API. Ну зачем наблюдаемые свойства-то вручную фильтровать? Давайте сделаем так, чтобы такие свойства определялись автоматически для реактивных хранилищ. Что-то вроде такого:
let result;
const cleanup = derive(() => {
result = storeA.a + storeB.b;
});Пусть у нас результат пересчитывается при изменениях любого значения (в соответствии с логикой работы ловушки set) в одном из хранилищ, а колбек возвращает функцию очистки. Напишем для начала простейший тест (на playwright):
test("Простейшая подписка", () => {
let a = { value: 0 };
a = createStore(a);
let b;
derive(() => {
b = a.value + 1;
});
expect(b).toBe(1);
a.value += 1;
expect(b).toBe(2);
a.value += 1;
expect(b).toBe(3);
expect(a.value).toBe(2);
});Ну, API, вроде удобное. В функцию derive мы должны передать колбек, который будет срабатывать каждый раз при изменениях только используемых в нем свойств хранилища (его предварительно нужно создать). Теперь вопрос: как этого достичь? Мы будем использовать все тот же Proxy. Для того, чтобы знать за какими свойствами наблюдать, нам потребуется:
создать объект, хранящий эти свойства (observableProps);
добавить ловушку get - чтобы вычислить, какие свойства мы будем использовать;
запустить callback хотя бы один раз, чтобы наши get-ловушки сработали.
Нам нужно как-то определить, идет ли у нас первоначальный анализ колбека. Воспользуемся тем, что JS - однопоточный язык, и одновременно у нас не могут анализироваться несколько колбеков. Поэтому нам будет достаточно одного объекта-переменной (для того, чтобы можно было его импортировать), в которой будем хранить эту информацию.
Получится такое (для начала):
export const variables = {
isDerivingLogicAnalysis: false,
// при анализе логики обработчика нам потребуется ссылка на него
derivingCallback: null,
};
const derive = callback => {
variables.isDerivingLogicAnalysis = true;
variables.derivingCallback = callback;
callback(); // первоначальный запуск без аргументов
variables.isDerivingLogicAnalysis = false;
variables.derivingCallback = null;
const cleanup = () => {...}
return cleanup;
};
const createStore = obj => {
// теперь обработчики будут срабатывать только при изменении указанных
// здесь свойств, а не при любой установке
const observableProps = {};
const proxy = new Proxy(obj, {
set(...args) {
const [target, prop, value] = args;
const oldValue = target[prop];
const defaultReturn = Reflect.set(...args);
const { isDerivingLogicAnalysis, derivingCallback } = variables;
if (isDerivingLogicAnalysis) {
// Наблюдать за значением свойства в обработчике, а потом его устанавливать -
// затея бессмысленная, да еще и ведет к бесконечному циклу.
// Поэтому, если мы добавили обработчик для текущего свойства, удалим его.
if (Object.hasOwn(observableProps, prop)) {
observableProps[prop] = observableProps[prop]
.filter(cb => cb !== derivingCallback);
}
return defaultReturn;
}
// Думаешь так просто? Как бы не так...
if (Object.hasOwn(observableProps, prop)) {
// Передадим на всякий случай аргументы в колбек, хотя я и не придумал,
// где же это может понадобиться.
const payload = { store: this, target, prop, value, oldValue };
observableProps[prop].forEach(cb => cb(payload));
}
return defaultReturn;
},
get(...args) {
const { isDerivingLogicAnalysis, derivingCallback } = variables;
if (isDerivingLogicAnalysis) {
const prop = args[1];
if (!Object.hasOwn(observableProps, prop)) observableProps[prop] = [];
// В колбеке может быть сколько угодно обращений к текущему свойству,
// но во время анализа мы должны добавить колбек только один раз
if (observableProps[prop].at(-1) !== derivingCallback) {
observableProps[prop].push(derivingCallback);
}
}
return Reflect.get(...args);
},
});
return proxy;
};Так. Наблюдение организовали. Разбираемся дальше.
Что у нас получилось? У нас есть один обработчик, в котором мы можем устанавливать сколько угодно свойств в нескольких хранилищах. Если нам потребуется его удалить, нам нужна будет ссылка на него. Соответственно, его удаление должно затронуть все хранилища, в которых он используется. Поэтому, нам нужно каким-то образом хранить информацию о таких хранилищах где-то глобально, чтобы очищать их всех разом. Для этого добавим свойство observables в нашу переменную variables. А запуск функции очистки пусть ищет этот колбек во всех хранилищах и удаляет его.
Для начала напишем тест на удаление:
test("Отключение наблюдения", () => {
const storeA = createStore({ value: 1 });
let b;
const cleanup = derive(() => {
b = storeA.value * 2;
});
storeA.value = 2;
expect(b).toBe(4);
storeA.value = 3;
expect(b).toBe(6);
cleanup();
storeA.value = 1;
expect(b).toBe(6);
});Обновим нашу ссылку с переменными:
export const variables = {
...
observables: new Map(),
};
const createStore = obj => {
const observableProps = {};
const proxy = ...;
const { observables } = variables;
// связь прокси и наблюдаемых свойств прокси
observables.set(proxy, observableProps);
return proxy;
};В реализацию derive добавим строчки:
export const derive = callback => {
//... уже описанная логика
const cleanup = () => {
variables.observables.forEach(observableProps => {
// помним, что один и тот же callback может срабатывать
// при изменении целого ряда свойств
for (const prop in observableProps) {
observableProps[prop] = observableProps[prop]
.filter(cb => cb !== callback);
}
});
};
return cleanup;
};Замечу, что очистка памяти в таких случаях должна происходить явно, поскольку неочевидно, когда обработчик может стать невалидным.
Что будет, если у нас одно значение в хранилище зависит от другого и при изменении одного должно меняться другое? Т.е. что-то такое:
test("Циклическая зависимость", () => {
const a = createStore({ value: 0 });
const b = createStore({ value: 0 });
derive(() => {
a.value = -b.value;
});
derive(() => {
b.value = -a.value;
});
b.value = 2;
expect(a.value).toBe(-2);
a.value = 1;
expect(b.value).toBe(-1);
});Нам нужно сделать так, чтобы такие изменения не вызывали вечного цикла. Есть несколько разных вариантов. Применим здесь такой подход. При запуске изменений (цепочки вычислений) в наблюдаемых свойствах, пусть свойства, которые запустили эти изменения отслеживаются. Если в каком-то колбеке мы снова устанавливаем то же самое свойство в одном хранилище, просто пропустим этот шаг, поскольку установка этого свойства уже была обработана. Для этого заведем поле issuers, которое будет хранить соответствие хранилища и свойств, которые затронуты текущей цепочкой вычислений. По окончании вычислений эти свойства удаляются.
export const variables = {
...
issuers: new Map(),
};Ну и код по запуску обработчиков несколько усложнится:
// где-то в ловушке set
if (Object.hasOwn(observableProps, prop)) {
const isTrigger = issuers.size === 0;
let propsInCurrentChain = issuers.get(target);
if (!propsInCurrentChain) {
propsInCurrentChain = new Set();
issuers.set(target, propsInCurrentChain);
}
if (propsInCurrentChain.has(prop)) return defaultReturn;
propsInCurrentChain.add(prop);
const payload = { store: this, target, prop, value, oldValue };
// все по-прежнему не так просто...
observableProps[prop].forEach(cb => cb(payload));
if (isTrigger) issuers.clear();
}
return defaultReturn;Норм. Тест проходит. Теперь нужно подумать и об обработке ошибок.
Что делать, если у нас ошибка возникла в цепочке вычислений? Как реагировать на это, чтобы избежать рассогласованности состояния? На это тоже нет однозначного ответа... Давайте для упрощения примем такой подход, что если случилась какая-либо ошибка, мы полностью отбрасываем результат цепочки и восстанавливаем последнее валидное значение, а также запускаем обработчики с ним. Тоже не ахти, но все же. Будем полагать, что состояние было консистентным при запуске, поэтому в try-catch оборачиваем только в случае, если это первый запуск в текущей цепочке. Как обычно, сначала напишем тест:
test("Если произошла ошибка в одной из derive-функций, система откатывается к последнему стабильному состоянию", () => {
const storeA = createStore({ value: 0 });
const fn = value => {
// если в хранилище нечетное значение выбросим ошибку
if (value % 2) throw new Error();
return value;
};
let b;
let c;
derive(() => {
b = fn(storeA.value) + 1;
c = fn(storeA.value) + 2;
});
test.step("Удачная установка свойства", () => {
storeA.value = 10;
expect(b).toBe(11);
expect(c).toBe(12);
});
test.step("Неудачная установка. Вычисляемые при изменениях значения остались нетронутыми", () => {
storeA.value = 11;
expect(storeA.value).toBe(10);
expect(b).toBe(11);
expect(c).toBe(12);
});
});Ну а теперь реализация:
// в недрах set...
if (Object.hasOwn(observableProps, prop)) {
// уже описанная логика...
// вот это заменим на ранний return + более сложную логику для начала цепочки
// observableProps[prop].forEach(cb => cb(payload));
// if (isTrigger) issuers.clear();
if (!isTrigger) {
// здесь пусть выбрасываются ошибки, они будут пойманы в начале цепочки
observableProps[prop].forEach(cb => cb(payload));
return defaultReturn;
}
try {
observableProps[prop].forEach(cb => cb(payload));
} catch (error) {
console.error(error);
issuers.clear();
issuers.set(target, propsInCurrentChain);
target[prop] = oldValue;
observableProps[prop].forEach(cb => cb({ ...payload, value: oldValue }));
}
issuers.clear();
}
return defaultReturn;Решение не идеальное и довольно спорное. Возможно, достаточно дать возможность приложению упасть..
Ну и последнее, на что я хочу обратить внимание - не всегда наши колбеки должны срабатывать СРАЗУ после изменения полей хранилища. К примеру, что если мы меняем их в цикле, и у нас должна выполниться какая-либо более-менее дорогая операция, например, определение координат в DOM? Как известно, это вызывает reflow, что может обернуться катастрофой для производительности...
В этом случае нам нужна возможность откладывать выполнение колбека до какой-то определенной временной метки... Например, отложить наше обновление в очередь макрозадач с помощью setTimeout, или в очередь отрисовки с помощью requestAnimationFrame. Каждый раз реализовывать эту логику муторно, поэтому нужно предусмотреть батчинг операций вычисления.
Как водится, тест:
test("Батчинг, встроенные функции", async () => {
const storeA = createStore({ a: 1 });
const storeB = createStore({ b: 1 });
// выполнять после синхронных операций
const batch = cb => batchEffects(cb, setTimeout, clearTimeout);
const callResults = [];
// должен один раз запуститься для анализа
const cleanup = batch(() => {
const storeValuesPair = [storeA.a, storeB.b];
callResults.push(storeValuesPair);
});
// значение установлено, но колбек не запущен
storeA.a = 10;
storeB.b = 10;
// значение установлено, но колбек не запущен
storeA.a = 5;
storeB.b = 5;
// значение установлено, колбек будет запущен с этим результатом
storeA.a = 0;
storeB.b = 1;
expect(callResults.length).toBe(1);
await expect(() => {
// проверка того, что колбек был запущен только 2 раза
expect(callResults.length).toBe(2);
const lastCallResult = callResults.at(-1);
const [a, b] = lastCallResult;
// второй раз с последними значениями
expect(a === 0 && b === 1).toBe(true);
}).toPass();
});Статья получилась очень объемная, не буду утомлять вас рассуждениями, поэтому просто покажу результат:
// мы должны передать колбек, а также функцию, возвращающую таймер и функцию его очистки.
const batchEffects = (cb, asyncFunc = requestAnimationFrame, cleanup = cancelAnimationFrame) => {
let timerId = -1;
let isFirstCall = true;
const deriveCleanup = derive(() => {
// если сработала новая установка свойств,
// старый колбек выполнять уже не нужно
cleanup(timerId);
// при анализе колбека выполняем всегда
if (isFirstCall) cb();
// в дальнейшем будем ставить в очередь и, для возможности отмены колбека,
// функция должна возвращать таймер
else timerId = asyncFunc(cb);
isFirstCall = false;
});
return () => {
deriveCleanup();
cleanup(timerId);
};
};Здесь мы такие объемные операции откладываем до следующей отрисовки (по умолчанию). Но можно отложить и в очередь макрозадач, например, с помощью setTimeout. Кроме того, мы можем вообще реализовать кастомную функцию.
const batch = cb => batchEffects(cb, setTimeout, clearTimeout);
// или вообще так....
const batch = cb => batchEffects(cb, () => setTimeout(cb, 100), clearTimeout);Все тестовые случаи можно посмотреть здесь, а полный код createStore здесь.
Что ж.. Надеюсь, что до этого места дошли хотя бы 5% от тех, кто начал читать эту статью..
Как видно, реализация своей логики реактивных обновлений - не такая уж простая задача... С другой стороны - не такая уж прям запредельно сложная. Хотя я и не испытывал это решение в проде (это просто мои эксперименты), думаю, будет работать более или менее.. Вам же, как и в предыдущей статье, я предлагаю самим провести свои изыскания и создать свою логику. Пощупать проблемы, которые могут возникнуть при этом, попрактиковаться в решении не совсем тривиальных задач. В конце концов, изучение и практика чистого Javascript и веб-стандартов неизбежно сделает вас сильнее, нежели изучение очередной хайповой библиотеки.
Что у нас получилось? У нас теперь есть роутер с подгрузкой кода и стилей только по необходимости (созданный в первой части), свой минималистичный (и довольно удобный) менеджер состояний. Starter-kit готов... Остался последний штрих. В последующей части рассмотрим работу с вебкомпонентами. Там тоже все не так очевидно.