developit / htm

Hyperscript Tagged Markup: JSX alternative using standard tagged templates, with compiler support.
Apache License 2.0
8.68k stars 170 forks source link

Add helpers for tracking subtree staticness inside `h` functions #140

Closed jviide closed 4 years ago

jviide commented 4 years ago

This pull request modifies the evaluate function by adding more granular tools for tracking the staticness of a vnode and its children inside a h function.

HTM already automatically tracks and caches completely static subtrees (see #132). The tracking & caching process is completely transparent for the h function. That being said, there are still some hypothetical optimization possibilities if h functions got some more (optional) information. For example, vnodes with static props but some dynamic children would not fall under the "completely static subtree" umbrella that #132 optimizes. A VDOM-based UI framework could still use that staticness information to skip diffing such nodes and jump directly into diffing their children.

This PR adds two tools for exploring such optimization possibilities:

  1. The this variable inside h function calls are now set to an object that is bound to the html function call site and position inside the tagged template string. For example in the following case the this value stays the same:

    let html = htm.bind(function() { return this; });
    
    let x = () => html`<div>${'a'}</div>`;
    let a = x();
    let b = x();
    a === b; // true

    For different call sites and different h functions this changes:

    a = html`<div>${'a'}</div>`;
    b = html`<div>${'a'}</div>`;
    a === b; // false - different call sites
    
    a = x();
    html = htm.bind(function() { return this; });
    b = x();
    a === b; // false - same call site but different h functions
  2. this[0] inside an h function call is a number, tracking the staticness of the currently created vnodes.

    If the number's lowest bit is set then the vnode being created depends on dynamic values, but its children may or may not be dynamic. If the second-lowest bit is set then some of the vnode's children depend on dynamic values, but the vnode itself may or may not be dynamic.

    By that same logic, when the two lowest bits are both zeroes then the whole subtree rooted to the current vnode is static (and HTM will cache it). When the two lowest bits are both set then both the vnode and some of its children are dynamic.

These two features can be used in conjunction to e.g. annotate created vnodes with optimization information. For example, this could be stored in the created vnode's ._callsite property. Later, when two vnodes are diffed against each other, the diffing function could check whether their ._callsite properties are set and strictly equal and _callsite[0]'s lowest bit is set, and skip diffing the vnodes and move on to their children. In iffy pseudocode:

const html = htm.bind(function(tag, props, ...children) {
  const vnode = createElement(tag, props, children);
  vnode._callsite = this;
  return vnode;
});

// Then, in a module far far away...

function diff(oldVnode, newVnode) {
  if (oldVNode._callsite === newVNode._callsite && (newVNode._callsite[0] & 1)) {
    // Short-circuit diffing, because these vnodes are static and 
    // created by the same h function from the same template.
    return diffChildren(oldNode._children, newVnode._children);
  } 
  ...
}

With these changes HTM's overall performance seems to stay the same. htm.module.js.br size grows by 7 bytes (from 570 B to 577 B).

developit commented 4 years ago

Having a firm indicator that the callsite matches could almost make recycling viable again. It wouldn't have the issue of recycling trees across usages, instead only ever recycling stuff that was used at the same callsite (render --> unrender --> re-render)