loading...

I wrote a fake UI framework in 3 lines of code

ronnewcomb profile image Ron Newcomb ・3 min read

I wanted one of my new personal UI projects to be a back-to-basics affair. After six years of Angular/React I decided I'd keep my simple project simple and see if I still could, for example, make main menu dropdowns in pure CSS. Do things like the good old days but with CSS Grid and arrow functions.

I got an "append-only" CSS monstrosity in a single day. My index.html was only better in that it might take a whole week before sprouting tentacles in that same disturbing shade of purple.

When I broke apart the large files into smaller ones, I found myself using a structure that closely mirrored web components. The fancy popovers, for example, required a lot of CSS with a touch of HTML and JS. Dropdown menus and layout helpers etc. all required most if not all three of HTML, CSS, and JS. Folders were rearranged. File-naming conventions happened.

I also missed having a readable DOM. I missed looking at useful HTML tags instead of an endless parade of div and span. I still didn't need a reactive framework that re-renders custom tags on-the-fly in response to auto-diffing of JS model data, and .innerHTML was working fine for what little wasn't static, but I did want something better than the, ahem, bad old days. I wanted readable HTML and good code structure even in this mostly static site. This is what I wrote.

 <script type="module">
      /* the "render once" UI framework */

      const loadHtmls = element =>
        Array.from(element.children)
          .map(child => (child.tagName.includes("-") ? loadHtml : loadHtmls)(child));

      const loadHtml = el =>
        fetch("html/" + el.tagName + ".html")
          .then(response => response.text())
          .then(html => {
            if (html.match(/{{innerHTML}}/)) html = html.replace(/{{innerHTML}}/g, el.innerHTML);
            Array.from(el.attributes).forEach(attr => (html = html.replace(new RegExp("{{" + attr.name + "}}", "g"), attr.value)));
            el.innerHTML = html;
          })
          .then(_ => loadHtmls(el))
          .then(_ =>
            Array.from(el.querySelectorAll("script")).forEach(old => {
              const replacement = document.createElement("script");
              replacement.setAttribute("type", "module");
              replacement.appendChild(document.createTextNode(old.innerHTML));
              old.replaceWith(replacement);
            })
          );

      loadHtmls(document.body);
    </script>
  </body>
</html>

The last line invokes the first line on the displayable bits of index.html in which this snippet lives.

The first line looks through the immediate children of the passed-in element. If the element's tag contains a hyphen <like-this></like-this> then the element is passed to the second line. Else the element is passed to the first line for recursion.

The HTML5 spec states that a tag containing a hyphen is guaranteed to always be UnknownElement. I have no way to tell a built-in div from a custom foobar, and that's an easy restriction to live with to keep this snippet low effort.

Another restriction tradeoff, this time for the second line, requires all "components" to be in the same folder, named after the tag they implement. Even though the single-folder rule won't scale well, the filename convention doubles as a wonderful code organization tenet. <some-component> is implemented by some-component.html, which in turn uses <style>, <script> or <link> to involve CSS and JS. Easy.

The second line fetches the component file from the server, replaces substitutions {{likeThis}} in its HTML for the values of the same-named attribute: <some-component likeThis='42'>. It also replaces {{innerHTML}} with the element's pre-render innerHTML which is useful for components like <flex-row> which wrap what goes into the flexbox.

    <flex-row wrap="nowrap">
      <named-panel name="Sidebar"></named-panel>
      <named-panel name="Main Content"></named-panel>
    </flex-row>

Finally, we recurse into the newly-updated DOM so the custom components can invoke other custom components. Only after we finish the DOM do we run scripts, which might addEventListener DOM elements. (HTML5 proscribes running scripts automatically, and I've heard bad things about eval even though it's dramatically less typing.)

This "framework" obviously lacks too much to be a framework. The whole thing could probably be ran and rendered server-side into a nearly completely static site since vanilla JS does any actual runtime changes. And yet like most personal projects, I'm kind of fond of it, in the same way I might be fond of a little pet dog that's maybe kind of ugly to most people. But he's my pet dog, and he looks a lot better since we removed the tentacles.

Discussion

pic
Editor guide