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

Rewrite implementation #170

Closed
wants to merge 38 commits into from
Closed

Rewrite implementation #170

wants to merge 38 commits into from

Conversation

tornqvist
Copy link
Member

This is pretty much a complete rewrite of the implementation aimed at solving three specific issues with nanohtml:

  • Performancerebuilding the complete DOM tree on every update is a significant overhead
  • Componentscurrent implementations come with shortcomings like leaking proxy nodes
  • Asynchronous renderauthoring asynchronous behaviour, especially in SSR, is a hassle

This is not a finished implementation, but I wish to get a discussion going and would like to get feedback on this from the community. Included in the PR is a sample implementation of a component interface as well as a wrapper for lazy (async) components with fallback – these are in a state of experimentation.

API

const { html, render, Ref } = require('nanohtml')

render(main(), document.body)

function main () {
  var ref = new Ref()
  return html`
    <body>
      <input id=${ref}>
      <button onclick=${() => alert(ref.value)}>Click me</button>
    </body>
  `
}

Performance

Nanohtml is commonly used with nanomorph. Though the DOM isn't necessarily slow, rebuilding the complete DOM tree on each update and diffing + morphing that tree does impose a significant performance overhead.

This implementation is based on an implementation by @goto-bus-stop (https://gist.github.com/goto-bus-stop/5b54d652af860f614a1dcba28eb80691). In short, it identifies which parts of the template might change and creates bindings for updating these parts. The bindings are stored in a WeakMap with the element as key, meaning they can be garbage collected when the element is removed. Templates are only parsed once and cached with the template itself as key – thanks to the neat feature of template literals that the template array is unique per template, not per invocation.

This approach has resulted in a ~11x performance improvement over nanohtml + nanomorph.

Components

The most popular implementation of components for nanohtml is nanocomponent. The interplay between nanomnorph and nanocomponent sometimes results in leaking proxy nodes and a complex and flawed diffing algorithm. Nanocomponent in itself comes with a sometimes overly verbose API and demands quite complex implementations to determine if the component should update. Authors also have to manually manage component instances.

This implementation does not completely replace nanocomponent, but it offers an API for doing so. By extending the built in Partial class (requires implementing the render and update methods) one can create any kind of component interface. I have taken a stab at it with nanohtml/component. A few examples of use can be seen in this gist.

See an example implementation of a stateful component
var { html, render, Partial, cache } = require('./lib/browser')

function Component (fn, key, initial) {
  if (this instanceof Component) {
    this.initial = initial
    this.key = key
    this.fn = fn
    return this
  }
  key = key || Symbol(fn.name)
  return function (initial) {
    return new Component(fn, key, initial)
  }
}

Component.prototype = Object.create(Partial.prototype)
Component.prototype.constructor = Component

Component.prototype.render = function (oldNode) {
  var { fn, initial } = this
  var ctx = cache.get(oldNode)
  var state = (ctx && ctx.state.get(Component)) || initial
  var partial = fn(state, function onupdate (state) {
    var partial = fn(state, onupdate)
    partial.update(ctx)
    ctx.state.set(Component, { state, partial })
  })
  ctx = partial.render(oldNode)
  ctx.state.set(Component, { state, partial })
  return ctx
}

Component.prototype.update = function (ctx) {
  var { partial } = ctx.state.get(Component)
  partial.update(ctx)
}

var Button = Component(function Button (count, update) {
  return html`<button onclick=${() => update(count + 1)}>Clicked ${count} times</button>`
})

render(html`
  <body>
    ${Button(0)}
  </body>
`, document.body)

Asynchronous render

As part of choo the most prominent behaviour for async render to emit an event, render some loading state and once data is available, issue another render. In my experience, this works fine for consumer websites and simple web apps, but as complexity increases this has proven quite the bottleneck. The issue is most prominent when you have nested async dependencies, e.g. fetch list of things and then fetch item from list. This problem is especially troublesome when doing SSR. One solution, used by bankai is to perform a double render pass, once to collect promises and once again to render with resolved data. The API for this isn't really ideal and I find that it is prone to race condition, especially when having nested async dependencies.

This implementation addresses promises in tandem with generators. This is so that a component can be both asynchronous and synchronous, exposing promises when the data is not cached, but being rendered synchronously once it is.
The server side implementation unwinds generators to find promises and returns a promise which resolves to the rendered HTML string – pretty much what @diffcunha proposes in choojs/choo#646.

function * mostPopularArticle () {
  var articles = yield cachedOrFetch('/articles/popular')
  var article = yield cachedOrFetch(`/articles/${articles[0].id}`)

  return html`
    <article>
      <h1>${article.heading}</h1>
      <p>${article.preamble}</p>
      <a href="/articles/${article.id}">Read more</a>
    </article>
  `
}

I've started work on a way to render fallback content while a promise is being resolved but need to work out how to handle race conditions which easily arise when content changes while awaiting promise resolution, see nanohtml/lazy and accompanying tests.

Other benefits

  • Async render can easily be used to make lazy routes a core functionality of choo to fetch views as they are needed.
  • The fact that state and bindings are attached to the DOM element enables a lot of interesting possibilities for debugging tools which can inspect and manipulate any elements internal state, even in production.

Trade-offs

  • File size is at ~6kb, making it hard to keep the 4kb promise of choo. Though about 30% of that is hyperx which can be dropped as soon as we migrate the transforms.
  • It's all in one big file. I tried splitting it up using an OOP approach but quickly ran into circular dependency problems. Having it in one big file enables extensive scoping, which is nice. I tried commenting as best I could.
  • Irregular return values – returns promises when top level partial is async, otherwise returns DOM. Same goes for SSR, returns string or promise.

@goto-bus-stop goto-bus-stop self-requested a review June 13, 2020 15:26
@goto-bus-stop
Copy link
Member

I haven't had time to look at this closely and won't in the near/not-so-near future :( IMO, if you've been using it successfully in projects already, you should feel free to merge this PR.

@tornqvist
Copy link
Member Author

No problem @goto-bus-stop. Actually, I'll go ahead and close this. I have a way less bulky version in the works but I'm hesitant to wether this is a reasonable evolution of nanohtml. There are so many breaking changes that an entirely new module almost makes more sense, also, I'm seeing great benefit in merging framework code (state handling, events, i.e. choo) with the view engine (nanohtml/nanomorph). On top of that I think the logical next move is to migrate over to es modules to achieve bundle-less development (see snowpack). All this put together means choo/nanohtml would be barely recognisable, which is why I'm considering a new module.

If development on choo has ceased or if there is wide support in the community for this kind of progression I'd be happy continue here, but it doesn't feel right to just merge such breaking changes without broad consensus in the community.

@tornqvist tornqvist closed this Dec 2, 2020
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

Successfully merging this pull request may close these issues.

2 participants