querySelector/ID component design
SilentCicero opened this issue · comments
So I've been experimenting a lot with life cycles and yoyo components. So far the most reliable component design I've found is to use randomly generated or pre-assigned ID's, querySelection and the MutationObserver.
Here is an example component:
const Component = function (_yield) {
var id = "id" + parseInt(Math.random()*1000000)
var open = true
function onload () {
console.log(id + "loaded!")
}
function onupdate () {
console.log(id + "mutated")
}
function onunload () {
console.log(id + "unloaded!")
}
function toggle () {
open = !open;
yo.update(document.querySelector("#"+id), render(_yield))
}
function render (_yield) {
return yo`
<div id=${id}>
${id}
<button onclick=${toggle}>Toggle</button>
<span> ${open && yo`<div> It's Open </div>` || yo`<div> It's Closed </div>`}</span>
<hr />
${_yield}
</div>
`
}
onmutation(id, onload, onunload, onupdate)
return render(_yield)
}
My reusable MutationObserver which tracks when the component loads, unloads and updates:
var watch = []
if (window && window.MutationObserver) {
var observer = new MutationObserver(function (mutations) {
for(var i = 0; i < watch.length; i++) {
var selector = document.querySelector(watch[i][0]);
if(selector && !watch[i][4]) {
watch[i][4] = 1
watch[i][1](watch[i])
}else if(!selector && watch[i][4]) {
watch[i][2](watch[i])
watch.splice(i, 1)
}
}
for (var i = 0; i < mutations.length; i++) {
var mutation = mutations[i]
for(var i = 0; i < watch.length; i++)
if(watch[i][0].substr(1) == mutation.target.id) watch[i][3](watch[i])
}
})
observer.observe(document.body, {childList: true, subtree: true})
}
function onmutation (el, l, u, m) {
l = l || function () {}
u = u || function () {}
m = m || function () {}
watch.push([(el[0] != "#" && "#" + el || el), l, u, m, 0])
}
-- 335 bytes min+gz
I believe this to be the most reliable way to make stand alone components that update themselves.
Central Reason: if your component internally selects a node and not an ID, the dom can be morphed from above, altering the selected node, without changing the original node target in the component. The component then becomes useless, as its event methods are targeting a dom node that no longer exists. I ran into this problem constantly when I was using yo.update
on higher level components, which didn't update the node targets of the morphed lower level components.
Using ID's and querySelecting seems to be the most reliable, light-weight and simple solution to this problem. This MutationObserver pattern is similar to @shama's on-load
, but it uses querySelecting and not an actual dom node.
I didn't find the morphdom-hooks
to be reliable either, as they would look for a dom node target that would sometimes not exist. This is because higher level hooks dont walk the dom lower and update component targets below. Diablo
is a system that does this. However, it follows the react pattern fairly heavily, and I couldn't get it running without issues. I'm not huge on a react knock off either for my components.
Would love to get thoughts on this design and a name for my querySelector mutationobserver =D?
Looks like a good component structure, especially as it's still just returning native nodes. So others could consume it without issue.
It's got me thinking though, morphdom
will retain the context to an element if it has the same id or whatever the getNodeKey
hook determines. Previously we were setting an id to get that effect. We just incremented a counter as parseInt(Math.random()*1000000)
might not be as random as you'd think. But what if the MutationObserver
assigned an id
instead? It seems like it could set a generated id, if one doesn't exist, and then future updates will retain the element context.
If that works, then components wouldn't have to concern themselves with generating an id.
@shama you read my mind.. no joke working on something like this right now
Okay, can you draw up some sudo code, I'm thinking the same thing. The mutation observer seems to provide more than enough facility. But my worry is just loosing track. Right now I'm dom walking to see changes in nodes added, removed and mutated. I'm using a data-set ID, but I'm setting the id within the component, this is so that I can grab the component later on when I update the component. It kinda works, but seems to loose the element once the entire app is updated a few times. Walking the added nodes in mutation observer seems to miss a couple new nodes, not sure why.
const yo = require("yo-yo")
var watch = {}
var components = {}
function walkChildren (n, v) {
v(n); for (var i = 0; i < n.childNodes.length; i++) walkChildren(n.childNodes[i], v)
}
if (window && window.MutationObserver) {
var observer = new MutationObserver(function (mutations) {
for (var i = 0; i < mutations.length; i++) {
var mutation = mutations[i]
/*if(mutation.target.dataset
&& mutation.target.dataset.yoid
&& typeof components[mutation.target.dataset.yoid] !== "undefined")
components[mutation.target.dataset.yoid].mutated(mutation.target)*/
console.log(mutation);
if(mutation.target.dataset
&& mutation.target.dataset.yoid
&& typeof components[mutation.target.dataset.yoid] !== "undefined")
components[mutation.target.dataset.yoid] = mutation.target;
var x;
for (x = 0; x < mutation.addedNodes.length; x++) {
walkChildren(mutation.addedNodes[x], function(node){
if(!node.dataset) return; if(!node.dataset.yoid) return
console.log(node.dataset.yoid);
if(!components[node.dataset.yoid]) components[node.dataset.yoid] = node // onmutation(node.dataset.yoid)
//components[node.dataset.yoid].node = node
//components[node.dataset.yoid].added(node)
});
}
for (x = 0; x < mutation.removedNodes.length; x++) {
walkChildren(mutation.removedNodes[x], function(node){
if(!node.dataset) return
if(!components[node.dataset.yoid]) return
//components[node.dataset.yoid].removed(node)
//delete components[node.dataset.yoid]
});
}
}
})
observer.observe(document.body, {childList: true, subtree: true})
}
function onmutation (yoid, l, u, m) {
//if(!components[yoid]) components[yoid] = {}
//components[yoid].added = l || function () {}
//components[yoid].removed = u || function () {}
//components[yoid].mutated = m || function () {}
}
function getComponent(id) {
return components[id];
}
const Component = function (_yield) {
var id = "id"+ parseInt(Math.random()*1000000);
var open = true
function onload (node) {
console.log(id + "loaded!")
}
function onupdate (node) {
console.log(id + "mutated")
}
function onunload (node) {
console.log(id + "unloaded!")
}
function toggle () {
open = !open;
console.log(components, components[id])
yo.update(getComponent(id), render(_yield))
}
function render (_yield) {
return yo`
<div data-yoid=${id}>
<button onclick=${toggle}>${id} - Toggle</button>
<span> ${open && yo`<div> It's Open </div>` || yo`<div> It's Closed </div>`} </span>
<hr />
${_yield}
</div>
`
}
onmutation(id, onload, onunload, onupdate)
return render(_yield)
}
var app = yo`
<div>
${Component(Component(Component(Component())))}
${Component(Component(Component(Component(Component()))))}
${Component(Component(Component(Component())))}
</div>
`
document.body.appendChild(app);
Oh rad! Nice work!
@shama it's still shotty, but if we can work this down, I think we can have live dom components =D...
Checkout SkateJS (https://github.com/skatejs/skatejs) and https://w3c.github.io/webcomponents/spec/custom/ -- for life cycle
It looks like even web components will use the mutation observer.
Right now we are using the mutation observer on the whole document.body
dom.. however, I think the right thing to do may be to observe just the component in question. I haven't tried this yet though... looks complicated.
But I agree, we can create a system where the ID is assigned upon the initial render. So perhaps the onmutation method sets a node.dataset.id. Then it gets tracked and returned throughout the dom. If you want to get the element back and the ID, you just use the node returned by onload
and set the ID in the component to the one found in the returned node node.dataset.id
.
const Component = function() {
let id, open
function onload (node) {
id = node.dataset.yoid; // =D
}
function onunload (node) {}
function onupdate (node) {}
function toggle () {
open = !open;
yo.update(components[id], render()); // =D =D
}
function render () {
return yo`<div><button onclick=${toggle}>Toggle</button> My component</div>`
}
return connect(render(), onload, onunload, onupdate);
}
@shama here is what I came up with. A mutation observer design with some helpers: retrieve
, connect
, update
.
- All dom nodes with the
data-yoid
get tracked. - The dom gets walked when nodes are added or removed, via the mutation records.
- If a component dom is mutated, added or removed, the component hooks get called. Id's are generated via the
connect
method. - Each dom component can now get a reliable component life cycle that persists over over any dom manipulation, above or below the component.
- Helper methods can exist in separate modules (i.e.
require("throw-down/connect")
orrequire("throw-down/retrieve")
) etc. - If we like this, I'll call the module "throw-down" =D (yoyo trick).
- The module works with any dom element not just yoyo (i.e. bel, or raw vanilla dom creation). I haven't gotten the hang of decent garbage collection, it seems I loose some component hooks when I remove keys from the components store.
- not sure how this would fare with thousands or even millions of dom elements... that is unknown @maxogden thoughts on speed/theory there?
- I do think we could optimize this further, not sure how yet.. maybe storing known children in each component or something..
- note. we may need to dom walk the mutation target children as well, not sure on that one yet
throw-down
would weigh about 473 bytes min+gz
var components = {} // setup components cache
function walkChildren (n, v) { // walk children function
v(n); for (var i = 0; i < n.childNodes.length; i++) walkChildren(n.childNodes[i], v)
}
if (window && window.MutationObserver) { // if mutation observer
var __o = new MutationObserver(function (mutations) {
for (var i = 0; i < mutations.length; i++) { // sift through mutations
var m = mutations[i], x
if(m.target.dataset && m.target.dataset.yoid) { // if mutation target is a yo component
components[m.target.dataset.yoid].node = m.target // add node to components cache
components[m.target.dataset.yoid].mutated(m.target) // fire mutation callback
}
for (x = 0; x < m.addedNodes.length; x++) { // check for components added
walkChildren(m.addedNodes[x], function(n){ // go through added nodes in mutation
if(!n.dataset) return; if(!n.dataset.yoid) return // if yo component
components[n.dataset.yoid].node = n // add node to components cache
components[n.dataset.yoid].added(n) // fire added callback
});
}
for (x = 0; x < m.removedNodes.length; x++) { // check for components removed
walkChildren(m.removedNodes[x], function(n){ // go through removed nodes in mutation
if(!n.dataset) return; if(!n.dataset.yoid) return // if yo component
components[n.dataset.yoid].removed(n) // fire removed callback
});
}
}
})
__o.observe(document.body, {childList: true, subtree: true}) // observe the body dom
}
// ./connect.js -- this will connect the element gen. yoid for component, and add to components cache
function connect (el, l, u, m) {
el.dataset.yoid = "id" + parseInt(Math.random() * 10000000)
components[el.dataset.yoid] = {
node: el, added: (l || function() {}),
mutated: (m || function() {}), removed: (u || function() {})
}
return el
}
// ./retrieve.js -- retrieve a component from the components cache with their yoid
function retrieve (yoid) {
return components[yoid];
}
// ./update.js -- a yo.update helper that moves the yoid from one el to another
function update (el, newEl, opts) {
el = typeof el === "string" && retrieve(el).node || el
newEl.dataset.yoid = el.dataset.yoid
yo.update(el, newEl, opts)
}
const Component = function(_yield) {
var id, open
function onload (node) {
id = node.dataset.yoid
}
function onupdate (node) {
id = node.dataset.yoid
}
function onunload (node) {
}
function toggle () {
open = !open
update(id, render(_yield))
}
function render (_yield) {
return yo`<div><button onclick=${toggle}>Toggle</button> ${open && "Open!" || "Closed!"} ${_yield}</div>`
}
return connect(render(_yield), onload, onunload, onupdate)
}
brilliant! 👍
MutationObserver support looks great http://caniuse.com/#search=MutationObserver
Global 86.4%
@shama @maxogden packaged... has no tests... we need to do a lot of tests... https://github.com/silentcicero/throw-down - so dont spread the word ;)
Github is here:
Can we clarify the pattern / use case? It's not clear to me how data is passed from a parent component to a child. Say we take the example component and want to pass it a random number.
var yo = require('yo-yo')
var color = require('randomcolor')
var Component = require('./component')
function renderParent(data) {
data = data || { number: 0, color: 'black' }
return yo`
<div id="parent">
<span style="color: ${data.color};">
${Component(data.number)}
</span>
</div>
`
}
var el = renderParent()
document.body.appendChild(el)
setInterval(function() {
yo.update(el, renderParent({
number: Math.random(),
color: color()
}))
}, 1000)
P.S. great job everyone
@nichoth so you can pass it with the component constructor or state management (redux/storeEmitter) - higher up in the context. Essentially, you can do it what ever way you want.
I like the component to be opts (options) first, _yield/children (DOM children/components) second in the component function/constructor, kind of like hyperx. See my yoyo-bootstrap
package.
So I would do something like:
const MyComponent = function(opts, _yield){
opts = typeof opts === "undefined" && {} || opts // catch undefined options
_yield = typeof _yield === "undefined" && "" || _yield // catch undefined yield/children
function render (_yield) {
return yo`<div>Random Number ${opts.randomNumber} - ${_yield}</div>`
}
return render(_yield);
}
const MyParentComponent = function(opts, _yield){
opts = typeof opts === "undefined" && {} || opts // catch undefined options
_yield = typeof _yield === "undefined" && "" || _yield // catch undefined yield/children
function render (_yield) {
return yo`<div>My parent component ${Component({randomNumber: Math.random()}, "Some Yield or Child content")}</div>`
}
return render(_yield);
}
document.body.appendChild(MyParentComponent());
So you pass the random numbers in the opts
object and then any child DOM elements/components can be passed through the _yield
. Note, this is entirely up to you, but this is the way I like to design the components intuitively. You could call _yield
something like `children`` if that makes any more sense.
And would the child's internal state be kept (via throw-down) after the parent is re-rendered? Or is this not the goal of module? Thanks for answering questions. I will try to look at it more tomorrow.
@nichoth the child can be kept in internal state, but from what I see so far, if you're modules union together parent(child(child2(child3())) the child _yields actually keep their composure. So you can use it intuitively like you would use HTML/React elements. But I think that's depending on how/when/where you morph. More tests are needed.
As for throw-down. That is really just going to help you keep track of an element from within a component. So when you use yo.update(myComponentElement,... ) within a component on the components main element, yo.update actually selects/updates the right element, and not one that either doesn't exist or has been morphed elsewhere.
const yo = require("yo-yo")
const connect = require("throw-down/connect")
const update = require("throw-down/update")(yo.update)
const Component = function(_yield) {
var el, open
function track (node) {
el = node
}
function toggle () {
open = !open
update(el, render(_yield))
}
function render (_yield) {
return yo`<div><button onclick=${toggle}>Toggle</button> ${open && "Open!" || "Closed!"} ${_yield}</div>`
}
return connect(render(_yield), track, null, track)
}
document.body.appendChild(Component());
All throw-down
is really doing here is keeping track of the components main element throughout dom morphing, so that when the toggle method is fired, it updates the right element. That's it. Here track
is fired when the component element is mutated or loaded. Track is just setting the el (element) variable so that internally I can use it when an internal state change happens like when the "toggle" button is clicked.
Great thank you