javascript

Реактивность без фреймворков (просто эксперимент на чистом JS + Web APIs)

  • среда, 7 января 2026 г. в 00:00:07
https://habr.com/ru/articles/983268/

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

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

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)

Выводы

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

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

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

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