Skip to content

Instantly share code, notes, and snippets.

@gordonbrander
Last active April 25, 2023 20:38
Show Gist options
  • Save gordonbrander/685b52581afc34fce163ba3b8af04581 to your computer and use it in GitHub Desktop.
Save gordonbrander/685b52581afc34fce163ba3b8af04581 to your computer and use it in GitHub Desktop.
microview - data-driven views that render once per frame, at most

Microview

What if... Virtual DOM... but by hand?
aha ha, just kidding...
unless.. ?

Microview is a tiny library for writing efficient data-driven DOM rendering logic by hand. DOM writes are driven by a pure data model. Using Microview, you can freely "bash the dom". Writes will be batched — they'll only happen once per animationframe, and only if the data model changes.

Here's a simple click counter example, written using Microview.

import {creator, renderer} from './microview.js'

const createClicker = creator((clicks, handle) => {
  const el = document.createElement('div')
  el.innerText = `Clicks: ${clicks}`
  el.addEventListener('click', handle)
  return el
})

const renderClicker = renderer((el, prev, curr, handle) => {
  el.innerText = `Clicks: ${curr}`
})

let clicks = 0

const handleClick = event => {
  event.preventDefault()
  clicks = clicks + 1
  renderClicker(clicker, clicks)
}

const clicker = createClicker(clicks, handleClick)

document.querySelector('body').appendChild(clicker)

Let's take a quick look at the key functions:

creator takes a factory function of (model, handle) => element, and transforms it so that it will associate the model with the element after it is created. handle is an extra parameter for passing in event listeners, etc.

renderer is where most of the magic happens. It takes a write function of (el, prev, curr, handle) => null and transforms it. When you call the resulting function with an element and a new state, it retrieves the already associated model, and checks to see if the previous and current model disagree. If they do, it calls the write function on the next animation frame, passing in prev and curr, which are the previous and current versions of the model. You can call the render function as many times as you like during a single frame. Only the last write within a frame will be scheduled, and the write will only occur if the model has actually changed.

Microview is deliberately unopinionated about how state should be updated. Its job is only to compare a prev and curr model, and schedule renders accordingly. How you choose to manage model updates is up to you. This means you can use Microview with things like Redux, the Elm app architecture pattern, or whatever.

Microview is composable. You can nest create and write calls inside creators and renderers to build up nested DOM components that will only update when the corresponding sub-component of the model has updated. (Note that while top-level writers may use renderer, nested writers should use writer to perform immediate writes during the same frame.)

The full suite of view functions:

  • creator(createf): decorates a create function so it associates the model.
  • create(writef, model, handle): same as creator, but allows you to pass in an undecorated create function.
  • upgrader(writef): decorates a first-write function so that it will associate the model after performing a first write. You can use this instead of creator to define "upgrade" functions for existing elements.
  • upgrade(writef, el, model, handle): same as upgrader but allows you to pass in an undecorated write function.
  • writer(writef): decorates a update-write function so that it will retreive the previously associated model. Same as renderer, but performs an immediate write, instead of waiting for next frame.
  • write(writef, el, model, handle) same as writer but allows you to pass in an undecorated write function.
  • renderer(writef): decorates an update-write function so that it will retreive the previously associated model, and makes sure to only schedule a single write per frame.
  • render(writef, el, model, handle): same as renderer, but allows you to pass in an undecorated write function.
  • isDirty(el, model): check if an element is out of sync with a given model. You shouldn't often need to use this directly.
/*
Released under MIT License
Copyright 2020 Gordon Brander
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
of the Software, and to permit persons to whom the Software is furnished to do
so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
const rest = f => first => (...rest) => f(first, ...rest)
// Create an element using a create function.
// Binds the model to the element after creation.
export const create = (createf, model, handle) =>
setViewmodel(createf(model, handle), model)
export const creator = rest(create)
// Upgrade an existing element using an upgrade function.
// Binds the model to the element after upgrading.
export const upgrade = (upgradef, el, model, handle) => {
upgradef(el, model, handle)
return setViewmodel(el, model)
}
export const upgrader = rest(upgrade)
// Immediately write to an element using a model.
// Only writes if the element is dirty.
export const write = (writef, el, model, handle) => {
if (isDirty(el, model)) {
writef(el, getViewmodel(el), model, handle)
setViewmodel(el, model)
}
return el
}
export const writer = rest(write)
// Write to an element using a model during the next animation frame.
// Renders at most once per frame.
// Only writes if the element is dirty.
export const render = (writef, el, model, handle) => {
const frame = requestAnimationFrame(t => {
write(writef, el, model, handle)
})
// Avoid extra writes by cancelling any previous renders that
// queued the next frame.
cancelAnimationFrame(el[$frame])
el[$frame] = frame
}
export const renderer = rest(render)
export const isDirty = (el, model) => el[$viewmodel] !== model
export const getViewmodel = el => el[$viewmodel]
export const setViewmodel = (el, model) => {
el[$viewmodel] = model
return el
}
const $viewmodel = Symbol('viewmodel')
const $frame = Symbol('animation frame')
@tantaman
Copy link

tantaman commented Jan 12, 2022

I like it. I had planned to build something almost identical a ways back when working out this framework free TodoMVC -- https://github.com/tantaman/fk-your-frameworks-todomvc

--
Unrelated -- love the ideas on subconscious and subtext. Docs definitely have to be broken apart at the block level to enable any sort of re-use of their contents in other contexts.

I think I've read every post you've written on substack more than once 😅

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment