Created
March 26, 2023 01:41
-
-
Save gordonbrander/cbf1f27bc52f83c09108ff19dd3b1138 to your computer and use it in GitHub Desktop.
cdom.js - cached dom writers that only write if property value changed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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