Hyperapp: Compile JSX right into virtual node objects avoiding `h` function

Created on 22 Jan 2018  路  27Comments  路  Source: jorgebucaran/hyperapp

There are an idea to avoid using h in favor of compilation step which will do the same work.
_Compile JSX:_

const view = (state, actions) => (
  <main>
    <h1>{state.count}</h1>
    <button onclick={actions.down}>-</button>
    <button onclick={actions.up}>+</button>
  </main>
)

_to:_

const view = (state, actions) => ({
  name: 'main',
  props: {},
  children: [
    { name: 'h1', props: {}, children: [state.count] },
    { name: 'button', props: { onclick: actions.down }, children: ['-'] },
    { name: 'button', props: { onclick: actions.up }, children: ['+'] }
  ]
})

Potentially the app should work faster this way because each function call requires additional computing resources. Also h which is still necessary for JSX haters could be tree shakied from your build.

Possible future improvements: adopt babel-plugin-transform-react-constant-elements to speedup the app even more:

const h1 = { name: 'h1', props: {}, children: ['Static content'] }
const view = (state, actions) => h1

Can anyone foresee potential pitfalls?

Community Discussion

Most helpful comment

@jorgebucaran Here's a taste of the JS Framework Bechmark results for lazy/thunks, run in-browser and scientifically clicking around at a steady pace. I'll run the full automated test again soon. I ran it last night, but my implementation was wrong, so the results were way too good to be true. Now that I've fixed it, the results line up with my expectations, showing big wins on partial page updates. I removed percentages which were within 20% as not being significant.

Test (avg. of 10 runs in browser) | Normal | Thunks | %Improved
-- | -- | -- | --
Create 1000 | 181.74 | 172.12 |
Update Every 10th - 1000 Nodes | 74.41 | 11.99 | 84%
Swap Rows - 1000 Nodes | 158.17 | 155.36 |
Create 10,000 Nodes | 1934.625 | 1913.02 |
Update Every 10th - 10,000 Nodes | 725.5909 | 571.29 | 21%
Swap Rows - 10,000 Nodes | 464.8091 | 300.21 | 35%

Keep Adding 1000 Nodes (only run once) | Normal | Thunks | %Improved
-- | -- | -- | --
Create 1000 | 144.3 | 158.3 |
Add 1000 to 1000 | 162.6 | 152 |
Add 1000 to 2000 | 229.6 | 171 | 26%
Add 1000 to 3000 | 228.9 | 181.2 | 21%
Add 1000 to 4000 | 307.1 | 246.7 | 20%
Add 1000 to 5000 | 277 | 207.3 | 25%

I know it's been said before that generating the tree isn't that expensive, but it can really add up on a large app, or even a small one that has complex logic to generate the VNode tree. It's a lot faster to do a few strict === checks around some components, and generate the new node only if needed.

I'm look forward to combining these lazy functions with the custom JSX optimizations to get the best of both worlds: faster node creation, and faster partial updates :sunglasses: My computer is going to hate me after running all of these variations through JS Framework Benchmark :desktop_computer: :boom:

The API is pretty ugly right now, so I'll PR once I've thought of a nicer way to use this and learn how to write tests for it.

All 27 comments

I started some work on a jsx-to-object compiler in https://github.com/vdsabev/jsx-to-object/pull/1/files#diff-1dd241c4cd3fd1dd89c570cee98b79dd but got held up with an Esprima issue parsing the ... spread operator.

While jquery/esprima#1588 is now fixed in master, there hasn't been a new release for over 7 months. Ideas on how to reliably use the version _with the fix_ are welcome.

Or, if anyone has suggestions on an alternative parser that supports JSX, I'd be happy to drop Esprima and use something else instead 馃槄

@vdsabev have you tried babylon parser? Or could it be implemented as a babel plugin?

Great suggestions, I'll try when I get the time!

I started with acorn first (on which babylon is based), but decided to switch to esprima (don't remember why anymore). From what I've read recently, babylon supports all the use cases I need, and has even had contributions from the TypeScript team.

I might write a babel plugin on top of jsx-to-object, the goal is to have it as a standalone utility that you can use from a CLI, gulp, whatever, so as to not strictly depend on babel. I don't have experience doing anything like that, so it'll be a learning opportunity.

In any case, this is not going to be hyperapp-specific at all. Potentially, hyperapp could remove the dependency on the h function, but I don't think that uses up that many bytes 馃槃

If you figure this out.. I would like to do the same thing for https://github.com/lukejacksonn/ijk so that you have the option of precompiling views of that type.

If I understand correctly, do you mean to achieve something like what prepack does?

@kenOfYugen No, I don't think so, nope. I can see where you are coming from, but this is way simpler than what prepack does.

The idea is to go one step further and instead of compiling JSX to h calls, compile JSX to virtual nodes directly.

JSX

//@jsx h
<div id="ok">OK</div>

with Babel and babel-plugin-transform-react-jsx:

h("div", { id: "ok" }, "OK")

This proposal:

{
  name: "div",
  props: { id: "ok" },
  children: "OK"
}

@JorgeBucaran OK I see, I got confused.
Since we are talking tooling too, why not Sweet.js for this task? Have you considered it @vdsabev? Something like this.

I am a jsx hater 馃槅 , but would love to try to create macros for @hyperapp/html in order to get rid of the dependency and h altogether (and save those precious ~200b for future optimizations).

@kenOfYugen I think the net result would be a lot more bytes because you would have raw nodes all over the place. Food for thought. 馃嵅 馃槈

@JorgeBucaran That is correct.
In the case of raw nodes, I believe they gzip/brotli wonderfully. The 'hello world' difference is ~161b vs ~169b gziped after the closure compiler for the h vs raw nodes versions respectively [edit: not including the h function itself]. I don' t have any insight on how well it scales though. I am not aware of any real-use benchmarks either.

I just found https://github.com/calebmer/node_modules/tree/master/babel-plugin-transform-jsx
This should serve as a good starting point. From what I saw it doesn't allow customizing the output out of the box, e.g. { type, props, children } instead of { elementName, attributes, children }, but it could work with some tweaking,

@vdsabev @JorgeBucaran @frenzzy
hey, some dirty small workaround for babel-plugin-transform-jsx (.babelrc):

"plugins": [
  ["transform-jsx", {
   "function": "(n=>({name:n.elementName,props:n.attributes,children:n.children || []}))",
   "useVariables": true   
  }],
]

and now

const Component = props => console.log(props.foo);
export default (state, actions) => <div>
  <Component foo="bar">Some child</Component>
</div>

transformed into something like:

const Component = props => console.log(props.foo);

(state, actions) => ({
  name: 'div',
  props: {},
  children: [{
    name: Component,
    props: {
      foo: 'bar'
    },
    children: ['Some child']
  }]
});

Looks good, but it doesn't work with hyperapp.

Component function is not executed, as result it doesn't build nested vNode structures (as 'h' function does)

It's not possible to create DOM elements from functions and createElement will fail with something: Couldn't create element from "props => console.log(props.foo)"

Any thoughts?

@sergey-shpak Component function is not executed...

Hmm, right, I see. This is a not so obvious limitation, but I think this means what were trying to do is impossible.

Looks like need to transpile components

<div>
  <Component foo="bar" />baz</Component>
</div>

into function call

{
  name: 'div',
  props: {},
  children: [
    Component({ foo: 'bar' }, ['baz'])
  ]
}

@frenzzy

{
  name: 'div',
  props: {},
  children: [
    Component({ foo: 'bar' }, ['baz'])
  ]
}

... I presume you mean

The core might add support for function to be able to use it as a vnode:

{ name, props, children: [
  () => vnode,
  // or
  { name: () => vnode, props, children }
] }

but currently I do not see benefit of this.

Let's close this for now. Feel free to add any notes here.

So.... I've recently decided to not hate on JSX so hard :laughing:

The readability is so much higher than nested h calls in large view functions, that I really want to convert my main project. However, the runtime perf costs due to the variable length arguments and extra function calls (h(Component)) are hard to swallow.

Today I've been working on getting babel-plugin-transform-jsx to work with hyperapp.

I'm new to Babel transforms, but I managed to get this working! The transform code was quick & dirty, but I'm really pleased with the results so far :smile:

As a proof of concept, here is my recent Lazy Components Codepen demo run through babel-plugin-transform-jsx then my own custom transform:
https://codepen.io/SkaterDad/pen/wmBKbM?editors=0010

Quick rundown:

  • Normal HTML elements get transformed to raw VNode objects (thanks to the first plugin). My plugin renames the properties and changes children: null to children: [] when needed.
  • Components & Lazy Components get turned into direct function calls. No more h(Component, props, children), just Component(props, children).

Next steps:

  • Clean up code to ensure I'm only modifying VDOM objects, and just any random object with the properties I'm targeting.
  • Package this up with babel-plugin-transform-jsx as a dependency, or maybe fork it?
  • ~@jorgebucaran Any interest in this being in the @hyperapp NPM scope?~
  • Could call it babel-plugin-transform-hyperapp-jsx or something to fit in with the rest of babel plugins?
  • Performance testing the output against the React JSX transform, and against raw h calls. Should be higher since there are less functions being invoked at runtime.
  • Compare compiled bundle sizes of an application

@SkaterDad Sounds amazing. 馃帀

@jorgebucaran Thought you might be interested in an update on my progress.

I've got the babel plugin working pretty well, with 3 modes.

  • Full optimization mode. This calls components directly as functions, and turns regular html nodes into objects. Since h is no longer used, you have to be more careful about children which are arrays, but I'm working on solutions.
  • Use h, passing multiple children as an array instead of variable arguments like react-jsx.
  • Same as above, but automatically add import { h } from 'hyperapp' to the files so you don't have to.

The function & module names are configurable, so any compatible vnode library should be okay.

I'll publish it to npm once I'm satisfied with the configuration options & tweak it a bit more.

I've done some benchmarking with interesting, but mixed, results. As you know, benchmarking is a rabbit-hole where logic goes out the door. :laughing:

In general, the "full optimization" mode performs very well compared to the two "h" modes. Chrome doesn't care much if the children passed to "h" are an array or multiple arguments, but other browsers seem to prefer the array. IE11 really loves the optimized version, so I guess function calls are expensive there.

When I have a few hours to kill, I'll try to run JS Framework Benchmark with various compilation options to get more formal results. I just wish it was able to get results for Firefox and Edge also!

I'm also exploring the performance implications of adding "lazy"/"thunk" support to hyperapp, similar to elm's. Since we have immutable state, our implementation can be similar. I'm seeing good results so far, and it's not much code, but would like to formally benchmark it before cleaning it up and submitting a PR. Is that something you're interested in seeing or should I wait for your diffing rewrite?

@SkaterDad Is that something you're interested in seeing or should I wait for your diffing rewrite?

Awesome! I'd say go for it! I don't think my rewrite would be affected too much anyway. 馃挴

@jorgebucaran Here's a taste of the JS Framework Bechmark results for lazy/thunks, run in-browser and scientifically clicking around at a steady pace. I'll run the full automated test again soon. I ran it last night, but my implementation was wrong, so the results were way too good to be true. Now that I've fixed it, the results line up with my expectations, showing big wins on partial page updates. I removed percentages which were within 20% as not being significant.

Test (avg. of 10 runs in browser) | Normal | Thunks | %Improved
-- | -- | -- | --
Create 1000 | 181.74 | 172.12 |
Update Every 10th - 1000 Nodes | 74.41 | 11.99 | 84%
Swap Rows - 1000 Nodes | 158.17 | 155.36 |
Create 10,000 Nodes | 1934.625 | 1913.02 |
Update Every 10th - 10,000 Nodes | 725.5909 | 571.29 | 21%
Swap Rows - 10,000 Nodes | 464.8091 | 300.21 | 35%

Keep Adding 1000 Nodes (only run once) | Normal | Thunks | %Improved
-- | -- | -- | --
Create 1000 | 144.3 | 158.3 |
Add 1000 to 1000 | 162.6 | 152 |
Add 1000 to 2000 | 229.6 | 171 | 26%
Add 1000 to 3000 | 228.9 | 181.2 | 21%
Add 1000 to 4000 | 307.1 | 246.7 | 20%
Add 1000 to 5000 | 277 | 207.3 | 25%

I know it's been said before that generating the tree isn't that expensive, but it can really add up on a large app, or even a small one that has complex logic to generate the VNode tree. It's a lot faster to do a few strict === checks around some components, and generate the new node only if needed.

I'm look forward to combining these lazy functions with the custom JSX optimizations to get the best of both worlds: faster node creation, and faster partial updates :sunglasses: My computer is going to hate me after running all of these variations through JS Framework Benchmark :desktop_computer: :boom:

The API is pretty ugly right now, so I'll PR once I've thought of a nicer way to use this and learn how to write tests for it.

Here's an automated run of JS Framework Benchmark. It was set to 5 iterations, and includes 3 versions of the hyperapp test, plus vanilla js. All keyed.

hyperapp-v1.2.0-keyed is the currently submitted implementation.

hyperapp-not-lazy and hyperapp-lazy use a modified "store.js" which is closer to the elm version. The big difference is that each data row includes a "selected" boolean, rather than having a single "selected" integer on the model. The lazy version wraps the <tr> generating part of the "Row" component.

image

Current API (using the benchmark example).
Any VDOM generating function be wrapped this way, not just components:

import { h, lazy } from "./hyperapp"

export default ({ data }) => (_, actions) => {
  // lazy(function, [array of properties])
  // the properties get passed to the function when needed
  return lazy(Row, [data, actions.select, actions.delete])
}

function Row(data, select, del) {
  const { id, label, selected } = data
  return (
    <tr key={id} class={selected ? "danger" : ""}>
      <td class="col-md-1">{id}</td>
      <td class="col-md-4">
        <a onclick={_ => select(id)}>{label}</a>
      </td>
      <td class="col-md-1">
        <a onclick={_ => del(id)}>
          <span class="glyphicon glyphicon-remove" aria-hidden="true" />
        </a>
      </td>
      <td class="col-md-6" />
    </tr>
  )
}

Will do my best to get a PR ready in the next couple of days as I get time. :smile:

Edit: I'm also going to experiment with another API which may be easier to sprinkle-in to existing code.

@SkaterDad We got a quite a substantial perf boost after #663 and the latest changes to the diff/patch algo, which include a new VNode shape and prefix/suffix trimming this is where we're at:

newperf

Can't wait to add lazy to that!

@SkaterDad I'm looking forward to combining these lazy functions with the custom JSX optimizations to get the best of both worlds: faster node creation, and faster partial updates.

Just to clarify, the results you shared here don't combine both optimizations, but instead show only the lazy optimizations, correct?

@jorgebucaran Just to clarify, the results you shared here don't combine both optimizations, but instead show only the lazy optimizations, correct?

Yes, that's correct.

(The custom JSX compilation stuff didn't really help the benchmarks significantly. It increased IE11 perf by a ton, but modern JS engines didn't care as much.)

What if use Prepack as the final optimization of a bundle instead of trying to optimize only vnodes creation? Will it improve performance significantly?

Thank you, @SkaterDad, for pioneering this feature. Introducing laziness is the next step after 2.0. 馃帀


@frenzzy Hmm, I've never actually tried Prepack in a Hyperapp bundle. 馃

@jorgebucaran My pleasure!

Was this page helpful?
0 / 5 - 0 ratings

Related issues

jamen picture jamen  路  4Comments

jbrodriguez picture jbrodriguez  路  4Comments

dmitrykurmanov picture dmitrykurmanov  路  3Comments

dwknippers picture dwknippers  路  3Comments

zaceno picture zaceno  路  3Comments