WebReflection / hyperHTML

A Fast & Light Virtual DOM Alternative
ISC License
3.07k stars 113 forks source link

Architecture around "partial static rendering" #54

Closed Scott-MacD closed 7 years ago

Scott-MacD commented 7 years ago

This likely isn't the responsibility of hyperHTML/viperHTML in any way, but rather should be some sort of wrapper function or build step, but I've been spending the last few days on trying to wrap my head around the idea of doing partial static rendering on the server, and serving optimized render functions to the client.

My example is a simple card or similar, that renders an element with 3 sources of data: styles, i10n and state.

render`
    <dl class="${styles.wrapper}" data-id="${state.id}">
        <dt class="${styles.field}"> ${i10n.firstName} </dt>
        <dd class="${styles.value}"> ${state.firstName} </dd>

        <dt class="${styles.field}"> ${i10n.emailAddress} </dt>
        <dd class="${styles.value}"> ${state.emailAddress} </dd>
    </dl>
`;

The only piece of data that is dynamic here and would change is state, everything else is basically static and could be determined at build time. We have various different reasons for wanting to keep our actual content separate from template code, ranging from organizational and translation purposes, and ability to substitute data sources or a CMS in the future, and styles would be a list of class names generated by one of the various "CSS in JS" solutions out there, such as CSS Modules. It doesn't seem to make much sense dynamically calculating all of this on the client when we render state and having to send down extra data objects that shouldn't ever need to be used if you are doing server side rendering with viperHTML. Ideally it would be possible to supply our styles and content (with or without state) to the render function, and have the above (or something similar) generate a new render function such as:

render`
    <dl class="css-1cds5y" data-id="${state.id}">
        <dt class="css-jj86g3">First Name</dt>
        <dd class="css-h689hgd"> ${state.firstName} </dd>

        <dt class="css-jj86g3">Email</dt>
        <dd class="css-h689hgd"> ${state.emailAddress} </dd>
    </dl>
`;

Not really an "issue", just some food for thought, and am just curious if you have any thoughts yourself around these issues, or ideas on how to approach it.

joshgillies commented 7 years ago

As a point for optimisation I really like the thought here, especially if you're wanting to "hide" data sources from the client.

I agree this isn't really in the realm of responsibility for hyperHTML/viperHTML, however I think you could achieve something close to your desired outcome simply adopting a mustache like syntax for the client only template expressions.

With your example you could have:

render`
    <dl class="${styles.wrapper}" data-id="{{state.id}}">
        <dt class="${styles.field}"> ${i10n.firstName} </dt>
        <dd class="${styles.value}"> {{state.firstName}} </dd>

        <dt class="${styles.field}"> ${i10n.emailAddress} </dt>
        <dd class="${styles.value}"> {{state.emailAddress}} </dd>
    </dl>
`;

Which once rendered on the server passes the following desired output to the client:

render`
    <dl class="css-1cds5y" data-id="${state.id}">
        <dt class="css-jj86g3">First Name</dt>
        <dd class="css-h689hgd"> ${state.firstName} </dd>

        <dt class="css-jj86g3">Email</dt>
        <dd class="css-h689hgd"> ${state.emailAddress} </dd>
    </dl>
`;
Scott-MacD commented 7 years ago

Hi Josh,

Definitely appreciate the insight here, but unless I'm missing something, the code your suggesting would require running viperHTML and then doing a second pass with a traditional mustache parser would it not? If so, that would create a lot of overhead that would remove most of the benefit of using viperHTML in the first place.

Another alternative of that would be to instead have the js file be parsed and replace any mustache tokens at build time, but this starts to get messy.

I'm definitely going to keep playing around with options and try to find a clean way of doing things from a simple function.

WebReflection commented 7 years ago

spoiler: long answer with benchmarks, code, and ideas


Hi @Scott-MacD , first of all, I'd like to talk about this point:

serving optimized render functions to the client

The whole idea behind hyperHTML and viperHTML is to be the best compromise between simplicity, ease of use, and performance.

This means that while there's always a way to go slightly faster, the whole code is written in a way optimized for hot-jit-executions and you, as user, should never care about performance unless it is a real issue.

TL;DR you should focus on your app, and not on optimizing what's born optimized already :wink:

However, I see your use case a very valid one, so before giving you ideas and some code to play with, let's start checking out how much gain and for how much effort we have in terms of performance.

Dynamic VS Static

You can test the following code in either nodejs or the live test page console.

const viperHTML = this.hyperHTML || require('viperhtml');
// warm viperHTML code up (not needed, bench purpose)
viperHTML.wire()`<warm class="${'up'}">${'html'}</warm> ${'txt'}`;

// static variables
const
  styles = {
    wrapper: 'css-1cds5y',
    field: 'css-jj86g3',
    value: 'css-h689hgd'
  },
  i18n = {
    firstName: 'First Name',
    emailAddress: 'Email'
  }
;

// dynamic and static render
const dynView = (styles, i18n, state) => viperHTML.wire(state)`
  <dl class="${styles.wrapper}" data-id="${state.id}">
    <dt class="${styles.field}"> ${i18n.firstName} </dt>
    <dd class="${styles.value}"> ${state.firstName} </dd>
    <dt class="${styles.field}"> ${i18n.emailAddress} </dt>
    <dd class="${styles.value}"> ${state.emailAddress} </dd>
  </dl>
`;

const staticView = (state) => viperHTML.wire(state)`
  <dl class="css-1cds5y" data-id="${state.id}">
    <dt class="css-jj86g3"> First Name </dt>
    <dd class="css-h689hgd"> ${state.firstName} </dd>
    <dt class="css-jj86g3"> Email </dt>
    <dd class="css-h689hgd"> ${state.emailAddress} </dd>
  </dl>
`;

// used for benchmark purpose
let output;

As you can see, I've replicated your data and variables with the exception for the i18n (internationalization) which made more sense than your i10n 'cause I don't know what that is :smile:

Silly details a part, you can benchmark in console 3 calls per each render type and read results. I've put them in comments for my nodejs test.

console.log('\nDYNAMIC\n');

console.time('first dynamic');
output = dynView(styles, i18n,
  {id: 1, firstName: 'Andrea', emailAddress: 'no@matter.now'});
console.timeEnd('first dynamic'); // 0.458ms
console.log(output);

console.time('second dynamic');
output = dynView(styles, i18n,
  {id: 2, firstName: 'Alex', emailAddress: 'no@matter.now'});
console.timeEnd('second dynamic'); // 0.207ms
console.log(output);

console.time('third dynamic');
output = dynView(styles, i18n,
  {id: 3, firstName: 'John', emailAddress: 'no@matter.now'});
console.timeEnd('third dynamic'); // 0.088ms
console.log(output);

console.log('STATIC\n');

console.time('first static');
output = staticView({id: 1, firstName: 'Andrea', emailAddress: 'no@matter.now'});
console.timeEnd('first static'); // 0.120ms
console.log(output);

console.time('second static');
output = staticView({id: 2, firstName: 'Alex', emailAddress: 'no@matter.now'});
console.timeEnd('second static'); // 0.142ms
console.log(output);

console.time('third static');
output = staticView({id: 3, firstName: 'John', emailAddress: 'no@matter.now'});
console.timeEnd('third static'); // 0.041ms
console.log(output);

As you can see, the static version has less operations to do so it's obviously faster but once the code gets hot you can see that the difference is around 0.05 milliseconds.

Yes, that's like 20 <DL> per milliseconds instead of "just 10", but if you check on the browser side, you'll see that the difference is even less relevant (it varies from call to call)

screenshot from 2017-05-30 07-48-21

There's even a case where the dynamic call is faster than the static one ... but yeah, the browser does things in a different way so after the first call, it will always go speed-light specially with simple templates like that one without any HTML content and attributes + text.

Note about hyperHTML

While attributes and text content have no special meaning in viperHTML, these are highly optimized in hyperHTML.

If an attribute, like a class or even a handler, has the exact same value as before, this will not be set again so you can update/render a template with many attributes as many times as you need without seeing any performance degradation.

The same goes for text nodes, these are the cheapest update ever for hyperHTML, actually slower in viperHTML due strings that need to be sanitized.

The road to a static template

Like I've said, I see your use case as a very valid one and for various reasons like language, geo, theme related values that indeed are a one-off per user, and could even be cached through Service Workers and invalidated only when settings change.

I am not sure what are your constrains so I'll give you a couple of ideas, hopefully something to think about, surely to improve.

Using a template convention

When I've read @joshgillies suggestion about using mustache syntax inside a viperHTML template I died a little bit inside :smile: ... the moment that would be necessary is, in my opinion, the moment the whole hyper/viperHTML idea would officially fail.

I hope that won't happen, or at least not for this reason but in any case, if using a parser on top of your templates can be considered an acceptable solution, how about you explicitly mark static content in a way that's transparent for JS and friendly for a transformer?

// note: the static content has parenthesis \o/
render`
  <dl class="${((styles.wrapper))}" data-id="${state.id}">
    <dt class="${((styles.field))}"> ${((i18n.firstName))} </dt>
    <dd class="${((styles.value))}"> ${state.firstName} </dd>

    <dt class="${((styles.field))}"> ${((i18n.emailAddress))} </dt>
    <dd class="${((styles.value))}"> ${state.emailAddress} </dd>
  </dl>
`;

It could be wrapped in single parenthesis too but double avoids confusion with possible handlers such (ect)=>({obj:ect})

The idea is that once you have these kind of files, assuming these are isolated or inside a function that can be represented as string, you can easily give to the user their transformed equivalent.

// either from a file or as functionView.toString()
// with functionView = (render) => render`...`;
const template = 'render`\n\
  <dl class="${((styles.wrapper))}" data-id="${state.id}">\n\
    <dt class="${((styles.field))}"> ${((i18n.firstName))} </dt>\n\
    <dd class="${((styles.value))}"> ${state.firstName} </dd>\n\
\n\
    <dt class="${((styles.field))}"> ${((i18n.emailAddress))} </dt>\n\
    <dd class="${((styles.value))}"> ${state.emailAddress} </dd>\n\
  </dl>\n\
`;';

// transform templates via
const withStatics = (template, statics) => template.replace(
  /\$\{\(\((.+?)\)\)\}/g,
  ($0, $1) => $1.split('.').reduce(($, k) => $[k], statics)
);

// serve content as
console.log(
  withStatics(template, {styles, i18n})
);

This solution would be probably my pick, but I've got another idea too.

Using a pre-wired-tag

Both hyperHTML and viperHTML are nothing more than functions, and wires are functions too. Indeed, everything here is very close to functional programming, and you can transform upfront or after as you like.

Using a simple de-facto convention that nobody uses new String(...) in JavaScript but it's a thing anyway, you can flag your template values that are meant to be static in such easy way, and create wires that only once would go through all values and filter out those that are not dynamic.

const S = s => new String(s);
const wireStatics = (...args) => {
  const wire = viperHTML.wire(...args);
  const info = {};
  let toProcess = true;
  return (statics, ...values) => {
    if (toProcess) {
      const dyn = [statics[0]], updates = [];
      for (let i = 0; i < values.length; i++) {
        if (values[i] instanceof String) {
          // append to previous template value and next static bit
          dyn[dyn.length - 1] += values[i] + statics[i + 1];
        } else {
          // list of indexes to consider
          updates.push(i);
          // add next static bit to dynamic template
          dyn.push(statics[i + 1]);
        }
      }
      info.dyn = dyn;
      info.updates = updates;
      toProcess = false;
    }
    const newValues = [];
    for (let i = 0; i < info.updates.length; i++) {
      newValues[i] = values[info.updates[i]];
    }
    return wire(info.dyn, ...newValues);
  };
};

That's it, you wire now can be created through the same viperHTML.wire(...) signature:

let render = wireStatics();

And you can now benchmark results right away:

console.time('wireStatics first');
output = render`
  <dl class="${S(styles.wrapper)}" data-id="${state.id}">
    <dt class="${S(styles.field)}"> ${S(i18n.firstName)} </dt>
    <dd class="${S(styles.value)}"> ${state.firstName} </dd>

    <dt class="${S(styles.field)}"> ${S(i18n.emailAddress)} </dt>
    <dd class="${S(styles.value)}"> ${state.emailAddress} </dd>
  </dl>
`;
console.timeEnd('wireStatics first'); // 0.414ms
console.log(output);

state = {id: 2, firstName: 'Alex', emailAddress: 'no@matter.now'};
console.time('wireStatics second');
output = render`
  <dl class="${S(styles.wrapper)}" data-id="${state.id}">
    <dt class="${S(styles.field)}"> ${S(i18n.firstName)} </dt>
    <dd class="${S(styles.value)}"> ${state.firstName} </dd>

    <dt class="${S(styles.field)}"> ${S(i18n.emailAddress)} </dt>
    <dd class="${S(styles.value)}"> ${state.emailAddress} </dd>
  </dl>
`;
console.timeEnd('wireStatics second'); // 0.042ms
console.log(output);

state = {id: 3, firstName: 'John', emailAddress: 'no@matter.now'};
console.time('wireStatics third');
output = render`
  <dl class="${S(styles.wrapper)}" data-id="${state.id}">
    <dt class="${S(styles.field)}"> ${S(i18n.firstName)} </dt>
    <dd class="${S(styles.value)}"> ${state.firstName} </dd>

    <dt class="${S(styles.field)}"> ${S(i18n.emailAddress)} </dt>
    <dd class="${S(styles.value)}"> ${state.emailAddress} </dd>
  </dl>
`;
console.timeEnd('wireStatics third'); // 0.021ms
console.log(output);

Performance at the end of the day looks better but watch out above example does not work as expected in hyperHTML, it works only in viperHTML.

If it's a solution worth exploring or improving I might figure out a better example that does invalidate per state and statics instead of trapping in the outer scope a single wire without ever invalidating it.


I hope something here helped. I'll close this since it's not a bug but feel free to keep asking/writing/replying in here.

Best Regards

Scott-MacD commented 7 years ago

I might revisit this in the future, but for now the best I can come up with is to bake these values in at build time, in which viperHTML doesn't really offer much more benefit, and unfortunately seems to add more complexities than it's worth. Perhaps using HandleBars or something similar at build time is the answer to this specific problem after all.

const template = render`
  <dl class="{{styles.wrapper}}" data-id="${state.id}">
    <dt class="{{styles.field}}"> {{i18n.firstName}} </dt>
    <dd class="{{styles.value}}"> ${state.firstName} </dd>

    <dt class="{{styles.field}}"> {{i18n.emailAddress}} </dt>
    <dd class="{{styles.value}}"> ${state.emailAddress} </dd>
  </dl>
`;

is much nicer to work with than:

const template = 'render`\n\
  <dl class="${((styles.wrapper))}" data-id="${state.id}">\n\
    <dt class="${((styles.field))}"> ${((i18n.firstName))} </dt>\n\
    <dd class="${((styles.value))}"> ${state.firstName} </dd>\n\
\n\
    <dt class="${((styles.field))}"> ${((i18n.emailAddress))} </dt>\n\
    <dd class="${((styles.value))}"> ${state.emailAddress} </dd>\n\
  </dl>\n\
`;';

Plus, I think the fact that the mustache syntax is invalid JS is arguably a plus. These baked in values are not intended to ever be served by the client, and if we get a syntax error we can immediately conclude that we're trying to run code that hasn't been 'baked' for whatever reason instead of digging into a stack trace trying to find why our value is 'undefined';

As I said, I will likely still revisit this in the future, as although it's not particularly applicable to me right now, I see many valid scenarios where some content might make sense to be dynamic on the server but never passed down to the client, in which case this solution wouldn't be ideal (I would first have to run a function to bake in the initial values, and then run the result of that through viperHTML to render the initial page).

Coming from the server side, it would be nice to have some sort of method along the lines of something like viperHTML.bake({styles, i18n})`<div> ... </div>` that would then return the new render functions with these values being static, but after playing with this for a few days I haven't yet found a way to pull it off in a manner that I'm happy with.

WebReflection commented 7 years ago

is much nicer to work with than: const template = 'render\n`

indeed I've never said you should write that string manually, it was an example of a transform applied to a template for users.

... you can easily give to the user their transformed equivalent.

The point with viperHTML is that you can have the exact same template for both client and server like it is for viper-news and use a transformer as build task in case you need to reuse that template on the client.

The second alternative gives you a way to never update statics.

Anyway, you can use viperHTML wich always produces strings and replace at runtime anything inside {{staches}} and do the same at build time for the client so I don't understand why you would need mustaches or handlebar, it looks like you'd use a hammer to simply string.replace

I can't see the need of any extra libraries but hey ... feel free to solve your issue as you prefer :-)

Scott-MacD commented 7 years ago

You're probably right about that, I simply wasn't happy with the server side solution I was able to come up with so far and we already had a build process in place for an older project that used handle bars that worked out of the gate without any further work needed. I've simply spent too much time evaluating the proper way to do this at this point in this project, but definitely appreciate all the ideas and advice you've put fourth. We will almost definately be refining this further down the road.

davidbwaters commented 6 years ago

Sorry to resurrect this, but had some feedback. I think using Babel or Webpack to optionally do whatever static optimizations that can be done at build. The trend seems to be shifting towards this; Vue and others spit out render functions, React's Prepack and others optimize JS, and Svelte embraces the idea.

I think you could maintain the simplicity and readablity option and have an Webpack/Babel optimize script that does what it can and you could add to incrementally when you see potential perf benefits for those building, like Prepack or even Clojure.

I noticed nanohtml (choo/yo-yo's bel successor that also uses template literals) has a static optimize add-on. Their readme says rendering is 'about twice as fast'. I'm not sure how relevant that is here, but that made me think of this issue and come back and share. If potential gains from static (and any other) optimizations that can be made at build time are substantial, it's worth consideration. It could also raise visibility and build the user base, since it's the hot idea right now, for what it's worth. Raises the appeal to 'influencers' who love their build/perf tweaks and could boost benchmarks even higher.

HyperHTML's drop-in usability advantage can be kept top priority, but I still think a big chunk of its user base already uses a build system that spits out mangled+minified code anyways and would appreciate a performance boost from a Babel/Webpack plugin. Just my 2 cents. <3 ing the project. Thanks.

WebReflection commented 6 years ago

hyperHTML is very competitive already in terms of performance, and adding any tools around it would ruin its nature:

If you look for keyed results here, you see hyperHTML is all been and happily faster than Vue and Preact.

Developers have the tendency to worry about stuff that doesn't need to be worried about, so whenever there will be a real gain in doing anything to improve performance, I'll think about it.

Right now what's needed are:

I have all these checks.

WebReflection commented 6 years ago

Also closing this conversation since everything discussed at the beginning has changed and the issue itself doesn't exist anymore, the code changed a lot since May 2017.

Regards