Skip to content

Better DOM API

Convenience wrappers that unify querying, traversal, and event handling across Document, DocumentFragment, and Element. The goal is a smaller, more memorable API surface without pulling in a full library.

When to Use

  • Rapid prototyping where jQuery is overkill but raw DOM is verbose
  • Projects that want query/queryAll on every node type
  • Code that handles space-separated event names or needs automatic listener cleanup
  • Code that needs EventEmitter-style on/off/emit on any EventTarget (WebSocket, BroadcastChannel, custom targets)

The Pattern

Unified query methods

Call query and queryAll on any node type — Document, DocumentFragment, or Element — without checking which prototype supports querySelector:

javascript
const nav = document.query("nav");
const links = nav.queryAll("a");
links.forEach((link) => {
  link.classes.add("nav-link");
});

Shorthand properties

Use el.text, el.html, el.classes, and node.parent instead of the longer built-in names:

javascript
const heading = document.query("h1");
heading.text = "Welcome";
heading.classes.add("hero");
const container = heading.parent;

Attribute helper

Chain attr calls to set multiple attributes without breaking the expression — attr reads with one argument and writes with two:

javascript
Element.create("input")
  .attr("type", "email")
  .attr("placeholder", "you@example.com");

EventEmitter bridge for EventTarget

Shim on, off, emit, and once onto EventTarget.prototype so every event-capable object in the browser gets an EventEmitter-style API:

javascript
// Custom EventTarget
const bus = new EventTarget();
bus.on("update", (e) => console.log(e.detail));
bus.emit("update", { count: 1 });

// One-shot listener
bus.once("ready", (e) => bootstrap(e.detail));

// Works on any EventTarget subclass
const channel = new BroadcastChannel("app");
channel.on("message", (e) => console.log(e.data));

// Cleanup
channel.off("message", handler);

Because on and off are direct aliases for addEventListener and removeEventListener, all options still work — capture, signal, { once: true }, etc.:

javascript
button.on("click", handler, { once: true });
controller.signal.on("abort", teardown);

Elements, documents, and the window get the richer on/off defined below — those override this bridge because they sit further down the prototype chain than EventTarget.

Event helpers with cleanup

on accepts space-separated event names and returns a removal function. off mirrors the same API. These override the EventTarget bridge above for elements, documents, and the window — adding multi-event binding and automatic cleanup:

javascript
const cleanup = button.on("click touchstart", handleActivate);

// Later, remove both listeners at once
cleanup();

Static factory methods

Create elements, text nodes, and fragments without going through document methods:

javascript
const frag = DocumentFragment.create();
const heading = Element.create("h2");
heading.text = "Hello";
frag.append(heading);
document.query("#app").append(frag);

Implementation

Implementing unified query methods

Alias each built-in selector method onto all three prototypes so the same names work everywhere:

javascript
Document.prototype.query = Document.prototype.querySelector;
Document.prototype.queryAll = Document.prototype.querySelectorAll;
Document.prototype.find = Document.prototype.querySelectorAll;

DocumentFragment.prototype.query = DocumentFragment.prototype.querySelector;
DocumentFragment.prototype.queryAll =
  DocumentFragment.prototype.querySelectorAll;
DocumentFragment.prototype.find = DocumentFragment.prototype.querySelectorAll;

Element.prototype.query = Element.prototype.querySelector;
Element.prototype.queryAll = Element.prototype.querySelectorAll;
Element.prototype.find = Element.prototype.querySelectorAll;

Implementing shorthand properties

Define getters and setters on the built-in prototypes:

javascript
// el.classes instead of el.classList
Object.defineProperty(Element.prototype, "classes", {
  get() {
    return this.classList;
  },
});

// el.html instead of el.innerHTML
Object.defineProperty(Element.prototype, "html", {
  get() {
    return this.innerHTML;
  },
  set(value) {
    this.innerHTML = value;
  },
});

// el.text instead of el.innerText
Object.defineProperty(Element.prototype, "text", {
  get() {
    return this.innerText;
  },
  set(value) {
    this.innerText = value;
  },
});

// node.parent instead of node.parentNode
Object.defineProperty(Node.prototype, "parent", {
  get() {
    return this.parentNode;
  },
});

Implementing attr()

Return this from the setter path to enable chaining, and alias removeAttr to the built-in removeAttribute:

javascript
Element.prototype.attr = function attr(name, value) {
  if (arguments.length === 1) {
    return this.getAttribute(name);
  }
  this.setAttribute(name, value);
  return this;
};

Element.prototype.removeAttr = Element.prototype.removeAttribute;

Implementing the EventTarget bridge

Alias the verbose built-in names and add emit (via CustomEvent) and once (via the { once: true } option):

javascript
EventTarget.prototype.on = EventTarget.prototype.addEventListener;
EventTarget.prototype.off = EventTarget.prototype.removeEventListener;

EventTarget.prototype.emit = function emit(type, detail) {
  return this.dispatchEvent(new CustomEvent(type, { detail }));
};

EventTarget.prototype.once = function once(type, listener) {
  this.addEventListener(type, listener, { once: true });
};

emit always creates a CustomEvent with a detail payload — fine for custom events, but for synthetic native events like click you still need dispatchEvent(new MouseEvent("click")). Methods like removeAllListeners, listeners, and listenerCount are deliberately excluded — they would require a shadow listener registry, turning a zero-cost alias bridge into a stateful wrapper.

Implementing event helpers

Split space-separated event names and attach a listener for each. Return a function that removes all of them at once:

javascript
Element.prototype.on =
Document.prototype.on =
Window.prototype.on =
  function on(events, ...rest) {
    const el = this;
    events.split(/\s+/g).forEach((event) => {
      el.addEventListener(event, ...rest);
    });
    return function remove() {
      el.off(events, ...rest);
    };
  };

Element.prototype.off =
Document.prototype.off =
Window.prototype.off =
  function off(events, ...rest) {
    const el = this;
    events.split(/\s+/g).forEach((event) => {
      el.removeEventListener(event, ...rest);
    });
  };

Implementing static factories

Each class gets a static method that delegates to the corresponding document.create* call:

javascript
Element.create = function (...args) {
  return document.createElement(...args);
};

Text.create = function (...args) {
  return document.createTextNode(...args);
};

DocumentFragment.create = function () {
  return document.createDocumentFragment();
};

Trade-offs

ApproachProsCons
Better DOM APIUniform, terse, no depsMutates prototypes
jQueryBattle-tested, huge ecosystemLarge bundle
Native DOM onlyZero overheadVerbose, inconsistent API

Prototype mutation warning. Extending built-in prototypes can conflict with future platform APIs or third-party code. This pattern works best in controlled environments — prototypes, internal tools, and scripts you own end-to-end. Avoid it in shared libraries.