用原生JavaScript构建响应式声明式UI
How to Build Reactive Declarative UI in Vanilla JavaScript

原始链接: https://jsdev.space/howto/reactive-vanilla-js/

## 使用原生 JavaScript 实现声明式 UI:总结 本文探讨了仅使用原生 JavaScript、Web API 和 `Proxy` 对象构建响应式、声明式 UI,从而绕过传统的 UI 框架。该实验侧重于一个实际场景:显示一个模态对话框,该对话框会定期轮询 API,直到满足特定条件。 核心原则是将 UI *应该做什么* 与 *如何实现* 分离开来。这通过一个用于元素创建的 **DOM 工具层**、一个用于跟踪更改而无需外部库的基于 **Proxy 的响应式状态** 系统,以及一个可重用的 **轮询逻辑** 抽象来实现。然后,**模态编排器** 将这些组件集成在一起。 示例展示了一个用于显示轮询对话框的简洁、声明式 API,其中消费者定义端点、成功标准、内容和回调函数——而不是底层实现细节。主要收获包括声明式方法的扩展性、原生 Web API 的强大功能以及可重用工具的优势。 该实验得出结论,虽然框架提供了便利性,但通过精心使用原生 JavaScript 可以实现声明式行为和响应式,强调无论使用何种工具,抽象的重要性。

一个 Hacker News 的讨论围绕着 jsdev.space 上一篇关于使用原生 JavaScript 构建响应式、声明式 UI 的文章。 许多评论者批评了文章的方法,认为其复杂性并不值得,最终仍然导致直接的 DOM 操作。 一个关键的争论点是 JavaScript 应该*在哪里*用于渲染 HTML。 一些人提倡服务器渲染 HTML,并使用最少的 JavaScript “点缀”(例如 HTMX、Hotwire/Turbo、LiveView)来构建更简单的、基于文档的应用程序。 另一些人则认为,复杂的高度交互式应用程序受益于完全客户端渲染的框架。 这场争论的中心是正在构建的应用程序类型,以及是否需要考虑禁用 JavaScript 的情况。 最后,人们对文章的质量提出了担忧,有多位用户怀疑它主要由 AI 生成。
相关文章

原文
January, 12th 2026 5 min read

Modern UI frameworks offer abstraction layers that make user interfaces declarative and reactive. However, the web platform itself exposes primitives that can be composed to achieve similar patterns without introducing a dedicated UI library. This article demonstrates an experimental approach for creating a reactive, declarative UI flow using only vanilla JavaScript, Web APIs, and Proxy-based state tracking.

The purpose of the experiment is to examine how far native capabilities can be pushed without framework-level abstractions and to illustrate architectural benefits of declarative behavior in UI code: improved clarity, maintainability, and reduced coupling.


The Target Behavior

The experiment focuses on a practical business scenario:

Display a modal dialog that performs periodic polling of an API endpoint. The dialog should remain open until a specific condition is met, then resolve or reject accordingly.

The modal dynamically:

  • mounts itself into the DOM
  • starts and manages a polling process
  • exposes reactive internal state
  • updates based on the polling result
  • closes automatically when finished
  • provides optional developer controls

The primary requirement is that consumer code defines what should happen, not how to wire it.


Declarative Usage Example

Example invocation:

uiModalEngine.showPollingDialog({
  endpoint: `${getServiceBaseUrl()}/process/wait_for/confirmation`,

  requestPayload: () => ({
    taskId: currentTask.id,
    mode: "rapid",
    includeAudit: true,
  }),

  requestOptions: {
    method: "POST",
    headers: { "Content-Type": "application/json" },
  },

  shouldContinue: (response) => response.ok && response.pending === true,

  intervalMs: 1000,

  buildContent: (mountNode) => {
    const contentBlock = uiModalEngine.createContentBlock({
      title: "Waiting for confirmation...",
      description: "This dialog will close automatically once the operation completes.",
    })
    mountNode.appendChild(contentBlock)
  },

  onResolved: ({ dialogNode, response }) => {
    metrics.track("operation_confirmed")
    dialogNode.remove()
  },

  onRejected: ({ dialogNode, error }) => {
    logger.error("operation_polling_failed", error)
    dialogNode.remove()
  },

  devToolsEnabled: false,
})

Declarative Takeaways

ConcernOwnership
UI behaviorDeclarative configuration
UI renderingModal orchestrator
DOM structureDOM utility layer
polling logicpolling helper
reactive stateProxy-based tracker

No framework is involved, yet responsibilities remain clearly segmented.


Core Building Block: DOM Utility Layer

To keep high-level code focused on behavior, DOM creation is delegated to a lightweight utility:

class DomToolkit {
  constructor(doc) {
    this.doc = doc
  }

  static getInstance(doc) {
    if (!DomToolkit.instance) DomToolkit.instance = new DomToolkit(doc)
    return DomToolkit.instance
  }

  createElement({ tag, classes, id, attrs = {}, styles = {}, html }) {
    const el = this.doc.createElement(tag)

    if (id) el.id = id

    if (typeof classes === "string") el.classList.add(classes)
    if (Array.isArray(classes)) classes.forEach(c => el.classList.add(c))

    Object.entries(attrs).forEach(([k, v]) => el.setAttribute(k, v))
    Object.entries(styles).forEach(([k, v]) => el.style[k] = v)

    if (html != null) el.innerHTML = html

    return el
  }
}

const domToolkit = DomToolkit.getInstance(document)

This removes boilerplate from business logic and centralizes standard element configuration.


Reactive State via Proxy

Next, the experiment introduces deep reactive state using the native Proxy object. This allows mutations at arbitrary depth to be observed without requiring explicit setters.

class DeepStateProxy {
  constructor(target, { onSet, onDelete } = {}) {
    this.onSet = onSet
    this.onDelete = onDelete
    return this.wrap(target, [])
  }

  wrap(node, path) {
    if (!node || typeof node !== "object") return node

    const handler = {
      set: (target, key, value) => {
        const fullPath = [...path, key]
        target[key] = this.wrap(value, fullPath)
        this.onSet?.(value, fullPath)
        return true
      },
      deleteProperty: (target, key) => {
        if (!(key in target)) return false
        const fullPath = [...path, key]
        delete target[key]
        this.onDelete?.(fullPath)
        return true
      },
    }

    Object.keys(node).forEach(k => {
      node[k] = this.wrap(node[k], [...path, k])
    })

    return new Proxy(node, handler)
  }
}

Usage example:

const state = new DeepStateProxy({
  attempts: 0,
  lastResponse: null,
}, {
  onSet: (value, path) => console.debug("state changed:", path.join("."), value),
})

This approach:

✔ enables deep mutation tracking
✔ does not require libraries
✔ keeps state as plain objects
✔ keeps consumer code minimal


Polling Logic as a Reusable Abstraction

To isolate asynchronous logic:

async function runPolling({ task, shouldStop, intervalMs }) {
  while (true) {
    const result = await task()
    if (shouldStop(result)) return result
    await new Promise(res => setTimeout(res, intervalMs))
  }
}

Isolating polling enables:

  • testability (polling logic has no DOM dependencies)
  • readability (consumer describes behavior declaratively)
  • reusability (polling can be embedded into other flows)

The orchestrator integrates DOM utilities, polling, and reactive state into a coherent unit:

ModalOrchestrator.prototype.showPollingDialog = function (cfg) {
  const {
    endpoint, requestPayload, requestOptions,
    shouldContinue, intervalMs,
    buildContent, onResolved, onRejected,
    devToolsEnabled = false,
  } = cfg

  const dialogNode = this.createDialogShell({ buildContent })
  document.body.appendChild(dialogNode)

  const state = new DeepStateProxy({
    attempts: 0,
    polling: true,
    aborted: false,
    lastResponse: { ok: false },
  }, {
    onSet: (value, path) => {
      if (devToolsEnabled) console.debug("state:", path.join("."), value)
    },
    onDelete: () => { throw new Error("state mutation violation") },
  })

  state.polling = true

  runPolling({
    task: async () => {
      const payload = requestPayload()
      const res = await fetch(endpoint, { ...requestOptions, body: JSON.stringify(payload) })
        .then(r => r.json())
        .catch(err => ({ ok: false, error: err.message, errored: true }))

      if (!shouldContinue(res) && !res.errored) state.polling = false
      else state.attempts++

      state.lastResponse = res
      return res
    },
    shouldStop: () => !state.polling,
    intervalMs,
  })
    .then(res => onResolved?.({ dialogNode, response: res }))
    .catch(err => onRejected?.({ dialogNode, error: err }))
}

Note the absence of framework-specific concepts such as:

  • components
  • hooks
  • virtual DOM
  • stores

Yet the intent remains clear and maintainable.


Observations & Takeaways

Key architectural observations include:

  1. Declarative descriptions scale better than imperative wiring
    The consumer code reads as a behavioral specification, not as a set of instructions.

  2. Reusable utilities reduce future cost
    DOM plumbing and polling logic are built once and reused many times.

  3. Native Web APIs are powerful enough for complex flows
    Proxy, fetch, Promise, and basic DOM operators enable experimentation without dependencies.

  4. Frameworks are optional, whereas abstraction is not
    Frameworks package abstractions; they are not the only way to achieve them.


Conclusion

This experiment shows that declarative UI behavior and reactive state management do not strictly require third-party frameworks. While production systems benefit from established ecosystems, understanding how native patterns can replicate core ideas provides architectural insight and improves reasoning about frameworks.

By relying solely on the web platform, the experiment highlights the expressive power of vanilla JavaScript, clarifies why modern frameworks emphasize declarativity and reactivity, and reinforces the idea that good abstractions—framework or not—ultimately enable scalable UI code.

联系我们 contact @ memedata.com