Skip to content

Instantly share code, notes, and snippets.

@gordonbrander
Created March 26, 2023 01:41
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save gordonbrander/cbf1f27bc52f83c09108ff19dd3b1138 to your computer and use it in GitHub Desktop.
Save gordonbrander/cbf1f27bc52f83c09108ff19dd3b1138 to your computer and use it in GitHub Desktop.
cdom.js - cached dom writers that only write if property value changed
// CDOM - Cached DOM
// CDOM minimizes DOM writes by caching last written value and only touching the
// DOM when a new value does not equal the cached value.
// The goal of CDOM is to make it safe to "bash the DOM" as often as you like
// without affecting performance, even bashing it every frame.
// Create a cached setter
export const CachedSetter = (ns, set) => (el, value) => {
let cacheKey = Symbol.for(`CachedSetter::${ns}`)
if (el[cacheKey] !== value) {
set(el, value)
el[cacheKey] = value
}
}
// Create a cached keyed setter
export const CachedKeyedSetter = (ns, set) => (el, key, value) => {
let cacheKey = Symbol.for(`CachedKeyedSetter::${ns}::${key}`)
if (el[cacheKey] !== value) {
set(el, key, value)
el[cacheKey] = value
}
}
// Set an attribute
export const attr = CachedKeyedSetter('attr', (el, key, value) => {
el.setAttribute(key, value)
})
// Set a style property
export const style = CachedKeyedSetter('style', (el, key, value) => {
el.style[key] = value
})
// Toggle a class
export const classname = CachedKeyedSetter(
'classname',
(el, classname, isToggled) => {
el.classList.toggle(classname, isToggled)
}
)
// Toggle an element hidden
export const hidden = CachedSetter('hidden', (el, isToggled) => {
el.toggleAttribute('hidden', isToggled)
})
// Set text content
export const text = CachedSetter('textContent', (el, value) => {
el.textContent = value
})
// Set an event handler property. Replaces any previous event handler.
export const on = (el, event, callback) => {
if (el[`on${event}`] !== callback) {
el[`on${event}`] = callback
}
}
// Get first element by selector within scope.
export const query = (scope, selector) =>
scope.querySelector(`:scope ${selector}`)
// A store that schedules a render for any updates sent to the store.
//
// Renders are batched, so that multiple updates within the same
// animation frame will only schedule one render next animation frame.
//
// Store model is updated through efficient mutation. Only store `update` can
// access this mutable state, making updates deterministic, as if state were an
// immutable data source.
//
// Returns a send function. Send messages to this function to mutate state
// and queue a render.
export const Store = ({flags=null, init, update, render}) => {
let isFrameScheduled = false
let state = init(flags)
const frame = () => {
isFrameScheduled = false
render(state)
}
const send = msg => {
update(state, msg, send)
if (!isFrameScheduled) {
isFrameScheduled = true
requestAnimationFrame(frame)
}
}
// Do first render on next tick.
Promise.resolve(state).then(render)
return send
}
// Transform a `send` function so that messages sent to it are wrapped with
// `tag`.
// Returns a new `send` function.
export const forward = (send, tag) => msg => send(tag(msg))
// Create an update function for a subcomponent of state.
// Also forwards effects.
export const cursor = ({get, set, tag, update}) => (big, msg, send) => {
let small = get(big)
if (small != null) {
update(small, msg, forward(send, tag))
}
}
// Create a tagging function that wraps value in an action with an id.
export const tagItem = id => value => ({type: 'item', id, value})
// FragmentCache
// Creates a template fragment cache.
// Returns a function that will convert template strings to fragments.
// Fragments are memoized in a cache for efficiency.
export const FragmentCache = () => {
let cache = new Map()
const clear = () => {
cache.clear()
}
const fragment = string => {
if (cache.get(string) == null) {
let templateEl = document.createElement('template')
templateEl.innerHTML = string
cache.set(string, templateEl)
}
let templateEl = cache.get(string)
let fragmentEl = templateEl.content.cloneNode(true)
return fragmentEl
}
fragment.clear = clear
return fragment
}
// Default document fragment cache.
export const Fragment = FragmentCache()
// An element that is a function of state.
// Constructs a shadow dom skeleton from a cached static template.
// Render is used to patch this skeleton in response to new states.
export class RenderableElement extends HTMLElement {
static template() { return "" }
constructor() {
super()
this.attachShadow({mode: 'open'})
let fragment = Fragment(this.constructor.template())
this.shadowRoot.append(fragment)
}
render(state) {}
}
// Holds a stateful store, and updates deterministically through messages
// sent to `element.send`.
export class StoreElement extends RenderableElement {
static init(flags) {}
static update(state, msg, send) {}
send = Store({
flags: this,
init: this.constructor.init,
update: this.constructor.update,
render: state => this.render(state)
})
render(state) {}
}
// Create a renderable element by tag name,
// rendering initial state and setting `send` address.
export const Renderable = (tag, state, send) => {
let el = document.createElement(tag)
el.send = send
el.render(state)
return el
}
const isListMatchingById = (items, states) => {
if (items.length !== states.length) {
return false
}
let idKey = Symbol.for('id')
for (let i = 0; i < states.length; i++) {
let item = items[i]
let state = states[i]
if (item[idKey] !== state.id) {
return false
}
}
return true
}
// Render a dynamic list of elements.
// Renders items. Rebuilds list if list has changed.
//
// Arguments
// tag: the tag name of the child elements
// parent: the parent element
// states: an array of states
//
// Requirements:
// - States must have an `id` property.
// - Element must have a `render` method.
export const list = (tag, parent, states, send) => {
// If all state IDs match all list IDs, just loop through and write.
// Otherwise, rebuild the list.
if (isListMatchingById(parent.children, states)) {
for (let i = 0; i < states.length; i++) {
let item = parent.children[i]
let state = states[i]
item.render(state)
}
} else {
let idKey = Symbol.for('id')
let items = []
for (let state of states) {
let item = Renderable(tag, state, forward(send, tagItem(state.id)))
item[idKey] = state.id
items.push(item)
}
// Replace any remaining current nodes with the children array we've built.
parent.replaceChildren(...items)
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment