Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

querySelector/ID component design #15

Open
SilentCicero opened this issue Apr 24, 2016 · 15 comments
Open

querySelector/ID component design #15

SilentCicero opened this issue Apr 24, 2016 · 15 comments

Comments

@SilentCicero
Copy link

SilentCicero commented Apr 24, 2016

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?

@SilentCicero SilentCicero changed the title querySelector/random ID component life cycle hooks querySelector/ID component component design Apr 24, 2016
@SilentCicero SilentCicero changed the title querySelector/ID component component design querySelector/ID component design Apr 24, 2016
@shama
Copy link
Collaborator

shama commented Apr 24, 2016

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.

@SilentCicero
Copy link
Author

SilentCicero commented Apr 24, 2016

@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);

@shama
Copy link
Collaborator

shama commented Apr 24, 2016

Oh rad! Nice work!

@SilentCicero
Copy link
Author

SilentCicero commented Apr 24, 2016

@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);
}

@SilentCicero
Copy link
Author

SilentCicero commented Apr 24, 2016

@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") or require("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)
}

@Ryanmtate
Copy link

brilliant! 👍

@kumavis
Copy link

kumavis commented Apr 24, 2016

MutationObserver support looks great http://caniuse.com/#search=MutationObserver

Global 86.4%

@SilentCicero
Copy link
Author

@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 ;)

@SilentCicero
Copy link
Author

Github is here:

https://github.com/SilentCicero/throw-down

@nichoth
Copy link
Contributor

nichoth commented Apr 25, 2016

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

@SilentCicero
Copy link
Author

SilentCicero commented Apr 25, 2016

@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.

@nichoth
Copy link
Contributor

nichoth commented Apr 25, 2016

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.

@SilentCicero
Copy link
Author

SilentCicero commented Apr 25, 2016

@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.

@nichoth
Copy link
Contributor

nichoth commented Apr 25, 2016

Great thank you

@SilentCicero
Copy link
Author

@nichoth just made a critical update to throw-down update to 0.1.1+ =D it tracks attributes now.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

5 participants