Проблема и Решение

Это логическое продолжение статьи Реактивность без 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)

Выводы

По привычке я оставлял комментарии в коде. Если их собрать в один список, можно в целом резюмировать то что я хотел донести в этой статье:

  • Постепенно, декларативность становится привычкой, которая толкает описывать создание рутинного переиспользуемого кода в других местах (выносите вспомогательный код в разряд утилит);

  • Написанный однажды код должен быть переиспользован на сколько это возможно. Это позволит с течением времени писать его меньше;

  • С каждой абстракцией декларативность должна повышаться; С ростом количества решенных типовых задач, любая нишевая задача не должна составлять проблем для быстрого решения новой бизнес-задачи; Как следствие - любая проблемная нишевая задача должна быть решена как типовая с возможностью повторного использования написанного однажды кода;