DEV Community

Cover image for Leveraging JS Proxies for the DOM
Nathan Pham
Nathan Pham

Posted on

Leveraging JS Proxies for the DOM

The Problem

A recurring problem for many front-end developers is choosing what framework to use. Maybe your mind skipped to React, or the new star, Vue. Or maybe you're into Ember and Mithril. No one cares about Angular though. We all know it's a bloated relic living somewhere in the Great Pacific Garbage Patch.

It's strange how we always skip over to create-[framework]-app or another boilerplate template without noticing the extreme amounts of overhead. Relatively simple side or personal projects don't require a framework at all. Choosing the vanilla JS option is considerably more responsible (we're not killing the client's poor Nokia browser with our 10 GB library) and requires no extensive bundler configuration. The browser was built for JavaScript, so use JavaScript.

Frameworks were created to boost productivity, modularize elements into reusable components, provide a novel way of manipulating data, ensure faster rendering through the virtual DOM, and supply a well supported developer toolset. We're missing out on a lot if we pick vanilla. Using native JS APIs is also an absolute nightmare. Who wants to write document.querySelectorAll 50 times?

Regardless, there isn't a need to re-invent the wheel. Although it may seem cool to have a functioning SPA, what you're really doing is writing another hundred lines of code or importing a heavy library with extensive polyfills just to rewrite the JS history API. It's not like the user cares if the url changed without refreshing the page. It's "smooth", but not if the page can't even load because of all of the crap you packed into it. Even Webpack can't save your file sizes now.

Creating Elements

There are several ways to tackle vanilla JS's lack of maintainability and ease of use. You could use this simple function I described in an earlier post on jQuery.

const $ = (query) => document.querySelectorAll(query)
Enter fullscreen mode Exit fullscreen mode

However, querying elements is not the only tool we need as developers. Oftentimes, it's creating the elements that's the problem.

// create a div element
const div = document.createElement("div")
div.classList.add("test")

// create a paragraph element & fill it with "Hello World!"
const p = document.createElement("p")
p.textContent = "Hello World!"

// append nodes to div and then to the body element
div.appendChild(p)
document.body.appendChild(div)
Enter fullscreen mode Exit fullscreen mode

Vanilla JS gets really ugly. Really fast. Feeling the itch to go back to React yet?

Proxies

Here's where the proxies come in. Proxies in JS allow you to "intercept and redefine fundamental operations for that object". As a bonus, it's supported by all the major browsers. Obviously, now that IE is dead, we don't have to worry about it anymore. Kinda like Angular!

I highly recommend reading the first few paragraphs of the MDN docs I linked above.

You can create proxies with the built-in Proxy class. It takes two arguments: a target object and a handler function that indicates how the target should be manipulated.

I like to think proxies are useful for "listening" to when a property in an object is accessed or changed. For example, you could extend arrays to support negative indexes, similar to Python.

export const allowNegativeIndex = (arr) => new Proxy(arr, {
    get(target, prop) {
        if (!isNaN(prop)) {
            prop = parseInt(prop, 10)
            if (prop < 0) {
                prop += target.length
            }
        }

        return target[prop]
    }
})

allowNegativeIndex([1, 2, 3])[-1]
Enter fullscreen mode Exit fullscreen mode

DOM Manipulation

I randomly stumbled upon this code snippet when I was scrolling through my Twitter feed. I can't explain how genius this is.

image

Using a proxy to create elements! While this clearly applies to Hyperapp (a "tiny framework for building hypertext applications"), there's no reason why this couldn't apply to vanilla JS.

Imagine writing this instead of document.createElement.

document.body.appendChild(div({}, 
    h1({ id: "test" }, "Hello World"),
    p({}, "This is a paragraph")
))

/*
<div>
    <h1 id="test">Hello World</h1>
    <p>This is a paragraph</p>
</div>
*/
Enter fullscreen mode Exit fullscreen mode

It doesn't require JSX or a fancy framework, and using functions based on the literal HTML5 tag actually makes a lot of sense.

The Code

You can find a working demo on Codepen and Replit.

First we need to have some logic to easily create elements. I'll call it h. h should accept three arguments: an HTML tag, a list of attributes/event listeners that should be applied to the element, and an array of children that should be appended to the element.

const h = (tag, props={}, children=[]) => {
  // create the element
  const element = document.createElement(tag)

  // loop through the props
  for(const [key, value] of Object.entries(props)) {

    // if the prop starts with "on" then add it is an event listener
    // otherwise just set the attribute
    if(key.startsWith("on")) {
      element.addEventListener(key.substring(2).toLowerCase(), value)
    } else {
      element.setAttribute(key, value)
    }
  }

  // loop through the children
  for(const child of children) {

    // if the child is a string then add it as a text node
    // otherwise just add it as an element
    if(typeof child == "string") {
      const text = document.createTextNode(child)
      element.appendChild(text)
    } else {
      element.appendChild(child)
    }
  }

  // return the element
  return element
}
Enter fullscreen mode Exit fullscreen mode

You could use this function as-is and immediately see some benefits.

h("main", {}, 
    h("h1", {}, "Hello World")
)
Enter fullscreen mode Exit fullscreen mode

This is much more developer friendly, but we can still make it better with proxies. Let's create a proxy called elements. Every time we access a property from elements, we want to return our newly created h function using the property as the default tag.

const elements = new Proxy({}, {
  get: (_, tag) => 
    (props, ...children) => 
      h(tag, props, children)
})
Enter fullscreen mode Exit fullscreen mode

Now we can write stuff that looks kinda like HTML directly in our vanilla JS. Amazing isn't it?

const { button, div, h1, p } = elements

document.body.appendChild(div({},
  h1({ id: "red" }, "Hello World"),
  p({ class: "blue" }, "This is a paragraph"),
  button({ onclick: () => alert("bruh") }, "click me")
))

// this also works but destructuring is cleaner
// elements.h1({}, "")
Enter fullscreen mode Exit fullscreen mode

State Management

Proxies also have a set method, meaning you can trigger an action (ie: a re-render) when a variable is changed. Sound familiar? I immediately thought of state management. In a brief attempt to marry proxies with web components, I went on to build a library called stateful components. Proxy-based state (Vue) and "functional" elements (Hyperapp) aren't a new idea. If you're looking for something a little more fleshed out you should give Hyperapp a go. I know this article railed on frameworks a lot, but that doesn't mean I don't recognize their utility and purpose in a given context.

Closing

I hope you enjoyed this short article. A lot of thanks to Matej Fandl for discovering this awesome hack, and I look forward to seeing what you build with proxies!

Discussion (3)

Collapse
efpage profile image
Eckehard

Hy,

there is a very similar approach in the document makeup library (DML).

DML uses wrappers to the HTML-DOM-API, so you can simply write

let myHeadline = h1("This is a headline")
button( "change Headline").onclick = () => myHeadline.textContent = "New Headline"
Enter fullscreen mode Exit fullscreen mode

As all functions return element references, it is very easy to build and navigate through the DOM tree. Please check out and give me a feedback. You will find a documentation and some live examples here and here.

BR
Eckehard

Collapse
phamn23 profile image
Nathan Pham Author • Edited

I think DML is a fantastic concept. It's well thought out, has a variety of examples & use cases, and supports new web technologies like web components. Honestly, I don't have a lot of critiques (and the ones I listed are just my preferences).

  1. DML uses selectBase and unselectBase to express how elements should be nested, which seems more verbose than explicitly showing the relationship through arguments.
  2. Automatically populating lists like the ul in DML seems somewhat unecessary. You can easily use map to implement a similar effect.
  3. Not sure if it was clear how to add more esoteric CSS styles, like :before, :after, and certain states like :hover and :focus.
// populating a list with data
const data = ["basketball", "golf", "tennis", "sleeping"]
const add = (el) => document.body.appendChild(el)
add(ul({ id: "sports", style: "color: red" }, data.map(item => li({}, item))))
Enter fullscreen mode Exit fullscreen mode

Overall, I think it's a great idea to promote object oriented web design, but I do have a few nitpicks here and there.

Collapse
efpage profile image
Eckehard • Edited

Hy Nathan,

thank you for your valuable feedback. DML is still a work in progress an may get some major revisions in the future. But the core concept serves very well. Maybe you have a look to the sources of the DML homepage to get an impression, how flexible the concept is. The whole page was set up in a few days from scratch and I was amazed, how well things go.

There are various elements in DML just for convenience. I have been working with compiled languages for years where the linker removes unneccessary code. So it was kind of a bad habit to put this into the core library. I´m still looking for a better module concept, but have still no satisfying solution. ES6-Modules are theoretical the best option, but have some drawbacks for me:

  • On a lib like DML you will get lengthy import lists
  • wildcard imports are nasty, you will need to write the import alias hundreds of times in your code
  • The es6-import is not as fine grained as would be needed. You just can control the import on a top level. Laguages like C++ let you control the import of class properties and methods in detail
  • executing html-files on a local file system is not possible with ES6-Modules, you get CORS errors.

Currently I had not too much problems to use scripts, but any suggestion is very welcome. For now I have separated the code in various scripts to keep the core library small, but there is still a lot of overhead in the core library.

You are absolutely right with the "more esoteric CSS" selectors, as far as I see it is not possible to use selectors on inline styles. There ar two solutions:

  • you can use CSS as usual, which works seamless (but breaks the concept a bit. In fact, this is, what anybody does in web designs)
  • you can use traditional events (see here)

Til now, DML plays very well and I´m happy about contributions. There are some examples in the "List of examples" and it´s also very easy to create "adapters" to WC-libraries like shoelace. There is an example on my homepage under "How to use external Webcomponent libraries"