Реактивность без фреймворков (просто эксперимент на чистом JS + Web APIs)
- среда, 7 января 2026 г. в 00:00:07
Это логическое продолжение статьи Реактивность без React или как обойтись без id в html элементах (для погргужения в контекст прошу прочитать сначала ее), но эта статья - ответ на ту "боль", которая описана в этом комментарии - опишу пример, демонстрирующий, насколько важна декларативность в вопросах управления поведением "аппки" (за этим стоят вопросы сохранения высокоо уровня абстракции и, как следствие, масштабируемости приложения). Задача - сделать управление мутациями DOM более декларативным и, как заявлено в заголовке, использовать реактивность на примере управления состоянием.
Итак, результатом успешного эксперимента предлагаю считать возможность декларативным описанием управлять поведением приложения (а точнее, его частью).
Это пример вызова модалки внутри которой инициирован поллинг, к примеру, для ожидания проведения всех транзакций после успешного сканирования штрих-кода в ней. Ожидается выполнение всех операций (в 1С и/или где-то еще о чем знает бэкенд, который будет этот поллинг отрабатывать), после чего модалка должна закрыться. Внутри модалки будет создан контекст для отслеживания счетчика запросов и описания поведения на успешный ответ (успех ответа описан в строке 13 в коде ниже 👇).
modalsMagic.runPollingInModal({
url: `${getAPIBaseUrl()}/PARTNER_API_EXAMPLE/wait_for/verified`,
getData: () => {
const body = { id: window.proxiedState.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: () => {
metrixAbstraction.ym('reachGoal', 'dkp_signed')
// NOTE: Do something else...
},
onError: (err) => {
groupLog('payout_card', 'onError', [err])
// NOTE: Do something else...
},
isDevModeEnabled: false,
})☝️ Еще раз обозначу фокус: Декларативность - сестра таланта, для всего остального есть Garbage Collector (не злоупотреблять). В нашем случае декларативность в понятном описании поведения в 36 строках кода выше.
Под капотом фокус на "чистоте" функций, достижении декларативности, которая должна поглощать код все больше (иначе, какой в ней смысл?)...
// 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.document.createElement('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) {
let btn
switch (true) {
case btnData.isUnstyled:
btn = this.createDOMElement({
tag: 'div',
innerHTML: btnData.label,
className: [...(btnData.classNames || [])],
style: {
animation: 'fade-in 0.1s, scale-in 0.2s',
...(btnData.style || {}),
},
id: btnData.id || `btn-${c}-${this._getRandomString(5)}`,
})
break
default:
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)}`,
})
break
}
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 })
}, 100)
}
}
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 (true) {
case path.join('.') === 'counter':
if (!!groupLog)
groupLog('[DEBUG] poll', `poll: ${value}`, ['target:', target, 'path:', path])
break
case path.join('.') === '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 () => {
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
state.counter += 1
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 })
})
}
}К слову о реактивности. Решил воспользоваться тем, что есть в 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)
По привычке я оставлял комментарии в коде. Если их собрать в один список, можно в целом резюмировать то что я хотел донести в этой статье:
Потепенно, декларативность становится привычкой, которая толкает описывать создание рутинного переиспользуемого кода в других местах (выносите вспомоательный код в разряд утилит);
Написанный однажды код должен быть переиспользуем на сколько это возможно. Это позволит с течением времени писать его меньше;
С каждой абстракцией декларативность должна повышаться; С ростом количества решенных типовых задач, любая нишевая задача не должна составлять проблем для быстрого решения новой бизнес-задачи; Как следствие - любая проблемная нишевая задача должна быть решена как типовая с возможностью переиспользования написанного однажды кода;