Проблема и Решение
Это логическое продолжение статьи Реактивность без 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)
Выводы
По привычке я оставлял комментарии в коде. Если их собрать в один список, можно в целом резюмировать то что я хотел донести в этой статье:
Постепенно, декларативность становится привычкой, которая толкает описывать создание рутинного переиспользуемого кода в других местах (выносите вспомогательный код в разряд утилит);
Написанный однажды код должен быть переиспользован на сколько это возможно. Это позволит с течением времени писать его меньше;
С каждой абстракцией декларативность должна повышаться; С ростом количества решенных типовых задач, любая нишевая задача не должна составлять проблем для быстрого решения новой бизнес-задачи; Как следствие - любая проблемная нишевая задача должна быть решена как типовая с возможностью повторного использования написанного однажды кода;
