Проблема и Решение
Это логическое продолжение статьи Реактивность без React или как обойтись без id в html элементах (для погружения в контекст прошу прочитать сначала ее), но эта статья - ответ на ту "боль", которая описана в этом комментарии - опишу пример, демонстрирующий, насколько важна декларативность в вопросах управления поведением "аппки" (за этим стоят вопросы сохранения высокого уровня абстракции и, как следствие, масштабируемости приложения). Задача - сделать управление мутациями DOM более декларативным и, как заявлено в заголовке, использовать реактивность на примере управления состоянием (выводы - в конце).
Сначала обозначу результат
Итак, результатом успешного эксперимента предлагаю считать возможность декларативным описанием управлять поведением приложения (а точнее, его частью).
Это пример вызова модалки внутри которой инициирован поллинг, к примеру, для ожидания проведения всех транзакций после успешного сканирования штрих-кода в ней. Ожидается выполнение всех операций (в 1С и/или где-то еще о чем знает бэкенд, который будет этот поллинг отрабатывать), после чего модалка должна закрыться. Внутри модалки будет создан контекст для отслеживания счетчика запросов и описания поведения на успешный ответ (успех ответа описан в строке 13 в коде ниже ? функция conditionToRetry, получившая ответ API в аргументе, вернет необходимость продолжать поллинг).
modalsMagic.runPollingInModal({
url: `${getAPIBaseUrl()}/PARTNER_API_EXAMPLE/wait_for/verified`,
getData: () => {
// -- NOTE: Можно целиком getData передать при вызове
// Если мы говорим о динамических данных, главное ссылаться на мутабельный источник (не попадите в ловушку "замыканий")
// Из списка прописных истин: Источник истины должен быть единственным
const body = { id: tradeinId }
// --
body.odd_success = 5
body.random_success = true
return body
},
reqOpts: {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
},
conditionToRetry: (res) => res.ok && !!res.should_wait,
interval: 1000,
createContentFn: (content) => {
// -- NOTE: Постепенно, декларативность становится привычкой,
// которая толкает описывать создание рутинного переиспользуемого кода в других местах
// а в целевом коде оставлять только то что позволит его читать как "хорошую книгу"
const internalContent = modalsMagic.getBarcodeVerifyWaitingContent({
specialText:
'Отсканируйте данный штрих-код в 1С и дождитесь в этом окне сообщения об успешной выплате',
barcodeUrl: 'https://example.com/TARGET_BARCODE_FOR_EXAMPLE',
})
// NOTE: В переменной content самый обычный DOM-элемент, который будет вмонтирован
// --
content.appendChild(internalContent)
},
onSuccess: ({ modalElm, response }) => {
metrixAbstraction.ym('reachGoal', 'dkp_signed')
// NOTE: Do something else...
// NOTE: Здесь все-таки решил дать управление разработчику
// В данном примере достаточно удалить элемент из DOM дерева
modalElm.remove()
},
onError: (err) => {
groupLog('payout_card', 'onError', [err])
// NOTE: Do something else...
},
isDevModeEnabled: false,
})
☝️ Еще раз обозначу фокус: Декларативность - сестра таланта, для всего остального есть Garbage Collector (не злоупотреблять). В нашем случае декларативность заключается в понятном описании поведения в 40 строках кода выше.
Метод под капотом
Под капотом фокус на "чистоте" функций и достижении декларативности, которая должна поглощать код все больше (иначе, какой в ней смысл?)
// NOTE: Постараюсь добавить столько кода,
// сколько неоходимо для сохранения контекста
class DOMMagicSingletone {
constructor(document) {
// ...
}
static getInstance(document) {
if (!DOMMagicSingletone.instance)
DOMMagicSingletone.instance = new DOMMagicSingletone(document)
return DOMMagicSingletone.instance
}
createProxiedState({ initialState, opts }) {
return new this.DeepProxy(initialState, opts)
}
createDOMElement({ tag, className, id, attrs = {}, nativeHandlers = {}, style = {}, innerHTML }) {
const elm = document.createElement(tag)
switch (tag) {
case 'button':
// NOTE: Не обращайте внимания,
// у меня привычка задавать явно все что требует разметка
elm.type = 'button'
break
default:
break
}
if (!!id) elm.id = id
const addClassNameIfString = (elm, className) => {
if (typeof className === 'string')
elm.classList.add(className)
}
if (!!className) {
if (typeof className === 'string')
addClassNameIfString(elm, className)
else if (Array.isArray(className))
for (const cn of className)
addClassNameIfString(elm, cn)
}
if (!!attrs && Object.keys(attrs).length > 0) {
for (const key in attrs)
elm.setAttribute(key, attrs[key])
}
if (!!nativeHandlers && Object.keys(nativeHandlers).length > 0) {
for (const key in nativeHandlers)
elm[key] = nativeHandlers[key]
}
if (!!style && Object.keys(style).length > 0) {
for (const key in style)
elm.style[key] = style[key]
}
if (!!innerHTML)
elm.innerHTML = innerHTML
return elm
}
}
const domMagic = DOMMagicSingletone.getInstance(document)
// NOTE: Написанный однажды код должен быть переиспользуем на сколько это взможно
class ModalsMagic extends DOMMagicSingletone {
constructor(ps) {
super(ps)
// ...
}
__getModalElm(arg) {
const {
createContentFn,
wrapperId,
controls,
_addContentAfterActions,
isCenteredVertical,
size,
verticalControlsOnly,
} = arg
const requiredParams = [
'wrapperId',
]
const errs = []
for (const param of requiredParams)
if (!arg[param])
errs.push(`Missing required param: ${param}`)
if (errs.length > 0)
throw new Error(`modalsMagic._getModalElm ERR! ${errs.join('; ')}`)
const wrapper = this.createDOMElement({
tag: 'div',
style: {
boxSizing: 'border-box',
// NOTE: centered & fixed
display: 'flex',
justifyContent: 'center',
alignItems: isCenteredVertical ? 'center' : 'flex-start',
position: 'fixed',
overflowY: 'auto',
top: '0px',
right: '0px',
left: '0px',
bottom: '0px',
padding: 'var(--std-m-6-swal-like) var(--std-m-2-swal-like) var(--std-m-6-swal-like) var(--std-m-2-swal-like)',
background: 'rgba(255, 255, 255, 1)',
height: '100dvh',
animation: 'fade-in 0.6s',
},
id: wrapperId,
})
const _maxSize = {
md: 600,
lg: 1000,
}
const container = this.createDOMElement({
tag: 'div',
style: {
boxSizing: 'border-box',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'flex-start',
borderRadius: '16px',
animation: 'scale-in 0.6s',
maxWidth: !!size && !!_maxSize[size] ? `${_maxSize[size]}px` : `${_maxSize.md}px`,
marginBottom: 'var(--std-m-1)',
},
className: ['extra-step-wrapper', 'box-shadow-1', 'stack-1'],
})
// -- 1. Content
if (!!createContentFn) {
const content = this.createDOMElement({
tag: 'div',
})
createContentFn(content)
container.appendChild(content)
}
// --
// -- 2. Controls
if (!!controls && Array.isArray(controls) && controls.length > 0) {
const controlsWrapper = this.createDOMElement({
tag: 'div',
style: {
boxSizing: 'border-box',
display: 'flex',
justifyContent: 'flex-end',
alignItems: 'center',
width: '100%',
},
})
let c = 0
for (const btnData of controls) {
const btn = this.createDOMElement({
tag: 'button',
innerHTML: btnData.label,
className: [
'sp-button',
...(btnData.classNames || []),
],
style: {
animation: 'fade-in 0.1s, scale-in 0.2s',
},
id: btnData.id || `btn-${c}-${this._getRandomString(5)}`,
})
btn.onclick = () => {
// NOTE: fade-out animation exp
if (btnData.noStepOutAnimation) {
btnData.cb(wrapper, { btnElm: btn, originalLabel: btnData.label })
} else {
wrapper.style.animation = 'fade-out 0.2s, scale-out 0.2s'
setTimeout(() => {
btnData.cb(wrapper, { btnElm: btn, originalLabel: btnData.label })
}, 0)
}
}
controlsWrapper.appendChild(btn)
c += 1
}
container.appendChild(controlsWrapper)
}
// --
if (!!_addContentAfterActions) _addContentAfterActions(container)
wrapper.appendChild(container)
return wrapper
}
// NOTE: С каждой абстракцией декларативность должна повышаться
runPollingInModal(props) {
const {
interval,
url,
getData,
reqOpts,
createContentFn,
conditionToRetry,
onSuccess,
onEachResponse,
onError,
isDevModeEnabled,
isCenteredVertical,
} = props
const state = this.createProxiedState({
initialState: {
counter: 0,
isPollingEnabled: false,
isAborted: false,
lastResponse: {
ok: false,
message: 'Ответ не получен',
},
},
opts: {
set(target, path, value, _receiver) {
switch (path.join('.')) {
case 'counter':
if (!!groupLog)
groupLog('[DEBUG] poll', `poll: ${value}`, ['target:', target, 'path:', path])
break
case 'lastResponse':
if (typeof onEachResponse === 'function')
onEachResponse({ response: value })
break
default:
break
}
},
deleteProperty(_target, _path) {
throw new Error('Cant delete prop')
},
},
})
const elm = this.__getModalElm({
isCenteredVertical,
createContentFn,
wrapperId: `polling-elm-${Math.random()}`,
// onClose: isClosable ? (w) => {} : null,
controls: isDevModeEnabled
? [
{
label: 'Stop & Close',
cb: (w) => {
state.isPollingEnabled = false
state.isAborted = true
w.remove()
},
},
{
label: 'Stop polling',
classNames: ['sp-button_blue'],
cb: (_w) => {
state.isPollingEnabled = false
state.isAborted = true
// w.remove()
},
},
]
: null,
})
if (!!elm) this.document.body.appendChild(elm)
state.isPollingEnabled = true
// NOTE: Еще одна абстракция (опустим, чтоб не перегружать эту статью)
// Как говорится в одной умной книге,
// "Код должен читаться как хорошая книга" (Роберт «Дядя Боб» Мартин)
poll({
fn: async () => {
// NOTE: Особо фанатичным могу предложить прикрутить signal
// Для отмены "лишних" запросов
// Но делать этого я не буду, т.к. это выйдет за рамки данной статьи
const res = await fetch(url, {
body: JSON.stringify(getData()),
...reqOpts,
})
.then((response) => response.json())
.catch((err) => ({ ok: false, message: err.message || 'No msg', isErrored: err instanceof Error }))
switch (true) {
case !conditionToRetry(res) && !res.isErrored:
state.isPollingEnabled = false
break
default:
state.counter += 1
break
}
state.lastResponse = res
return res
},
validate: () => !state.isPollingEnabled,
interval,
})
.then((res) => {
if (state.isAborted) throw new Error('isAborted')
if (!!onSuccess) onSuccess({ modalElm: elm, response: res })
})
.catch((err) => {
if (!!onError) onError({ modalElm: elm, error: err })
})
}
}
DeepProxy (гвоздь программы)
К слову о реактивности. Решил воспользоваться тем, что есть в JS под капотом ? Proxy объект. И тулза для быстрого создания сложных проксированных состояний. Я когда нахожу такие штуки, пишу коммент, где я это нашел (вдруг будет интересно туда вернуться).
// NOTE: https://es6console.com/
class DeepProxy {
constructor(target, handler) {
this._preproxy = new WeakMap()
this._handler = handler
return this.proxify(target, [])
}
makeHandler(path) {
const dp = this
return {
set(target, key, value, receiver) {
if (typeof value === 'object') value = dp.proxify(value, [...path, key])
target[key] = value
if (dp._handler.set) dp._handler.set(target, [...path, key], value, receiver)
return true
},
deleteProperty(target, key) {
if (Reflect.has(target, key)) {
dp.unproxy(target, key)
const deleted = Reflect.deleteProperty(target, key)
if (deleted && dp._handler.deleteProperty) dp._handler.deleteProperty(target, [...path, key])
return deleted
}
return false
},
}
}
unproxy(obj, key) {
if (this._preproxy.has(obj[key])) {
obj[key] = this._preproxy.get(obj[key])
this._preproxy.delete(obj[key])
}
for (const k of Object.keys(obj[key]))
if (typeof obj[key][k] === 'object')
this.unproxy(obj[key], k)
}
proxify(obj, path) {
// NOTE: obj will be mutated anyway
if (Array.isArray(obj)) {
obj.forEach((item, i) => {
if (typeof item === 'object') obj[i] = this.proxify(obj[i], [...path, obj[i]]) // ? TODO: debug
})
} else {
for (const key of Object.keys(obj)) {
try {
if (typeof obj[key] === 'object' && !!obj[key]) {
obj[key] = this.proxify(obj[key], [...path, key])
}
} catch (err) {
console.log(err)
}
}
}
const p = new Proxy(obj, this.makeHandler(path))
this._preproxy.set(p, obj)
return p
}
}
window.DeepProxy = DeepProxy
window.createProxiedState = ({ initialState, opts }) => new DeepProxy(initialState, opts)
Выводы
По привычке я оставлял комментарии в коде. Если их собрать в один список, можно в целом резюмировать то что я хотел донести в этой статье:
Постепенно, декларативность становится привычкой, которая толкает описывать создание рутинного переиспользуемого кода в других местах (выносите вспомогательный код в разряд утилит);
Написанный однажды код должен быть переиспользуем на сколько это возможно. Это позволит с течением времени писать его меньше;
С каждой абстракцией декларативность должна повышаться; С ростом количества решенных типовых задач, любая нишевая задача не должна составлять проблем для быстрого решения новой бизнес-задачи; Как следствие - любая проблемная нишевая задача должна быть решена как типовая с возможностью повторного использования написанного однажды кода;
Комментарии (6)

cmyser
06.01.2026 21:57автор ! Очень советую познакомились с $mol ! Он как раз про декларативность на максимуме !
И про максимальную переиспользуемость кода !
Например там можно вытащить к себе в проект любой подкомпонент любого приложения - даже из внешнего репозитория !
И само приложение тоже можно взять целиком
Вот например статья про декомпозицию компонент https://habr.com/ru/articles/804193/

pravosleva Автор
06.01.2026 21:57Я смотрел пару лет назад, тогда мне показалось, что инструмент сам по себе по сложности сильно превосходит задачи, которые он решает. Выглядит, конечно, как реактивный двигатель производства иных цивилизаций. Не то что бы $mol та штука, в которую можно быстро вкатиться с учётом того, что нужно освоить ЯП, если в этом изменений не было )) так что, боюсь, потребуется времени больше чем на что-либо другое (более очевидное).
В остальном, Дмитрий молодец, хорошая штука. К этому нужно делать что-то вроде обучающего модуля, чтоб проще было вкатиться

cmyser
06.01.2026 21:57На самом деле не сложный, просто необычный
И слой view ( html ) строго отделён от TS кода
Да и я могу помочь разобраться, основные концепции очень простые

dominus_augustus
06.01.2026 21:57Не могу понять почему все повторяют мантру о том, что сделано что-то нереально крутое, но при этом никто этим не пользуется. Противоречие не находите? Даже не понятно по итогу, а какую проблему решает этот фреймворк, он ее вообще решает. За 10 лет не сделать адекватный туториал, это показатель. Мне вот кидали такую хрень, в чем «убийца фреймворков» превосходит всех остальных, https://mol.hyoo.ru/#!section=docs/=f43e8e_z87u2z смотрел на реакт, и вот не вижу в упор чего-то, что нельзя решить в рамках экосистемы реакта. А ставить преимуществом то, что решается в реакте 2-мя библиотеками, ну это сильно , вроде религия не запрещает подключать сторонние пакеты
hardtop
Какая красота. В середине кода. Чтобы нервный тик вызвать. Потом искать это будет очень занимательно
pravosleva Автор
Ну соррямба, atomic design system не прикрутил в этот простейший пример ) но ты молодец, что заметил
Конечно, можно вынести в конфиг настроек отдельно. Хотя, пожалуй, лучше решить это средствами чистого css - условно задать класс возможность есть.