jorgebucaran / hyperapp

1kB-ish JavaScript framework for building hypertext applications
MIT License
19.07k stars 781 forks source link

V2 Lazy lists/components #721

Closed jorgebucaran closed 5 years ago

jorgebucaran commented 6 years ago

The idea is to avoid building a virtual DOM patching altogether. Think of it as built-in component memoization. Lazy views, not be confused with V1's deprecated "lazy components", is a way to tell Hyperapp to reuse a subtree from the old virtual DOM when patching the DOM and skip the diff.

Applications are not lazy out of the box. Instead, we designate what components will be lazy based on one or more properties in the state and Hyperapp will evaluate them only when the specified properties change. Large lists are usually great candidates for laziness.

To create a lazy view, import Lazy from the hyperapp package and wrap a view function like this.

import { Lazy } from "hyperapp"
import { Foo } from "./components/foo"

export const LazyFoo = props => <Lazy view={Foo} foo={props.foo} bar={props.bar} />

Hyperapp will render Foo only when props.foo and props.bar change.

To understand why laziness is useful, you need to remember that Hyperapp calculates the virtual DOM from scratch whenever the state changes. Then, compares it against the actual DOM, applying changes. While this is already highly-efficient, laziness allows us to optimize this process further.

Here's a working example. Try it in this code playground first to see what it does.

import { h, app, Lazy } from "hyperapp"

const randomColor = () =>
  Array.from({ length: 6 }).reduce(
    (color, i) => color + "0123456789ABCDEF"[Math.floor(Math.random() * 16)],
    "#"
  )

const List = props => (
  <div style={{ color: randomColor() }}>
    <h1>List</h1>
    {props.list.map(i => (
      <span>{i}</span>
    ))}
  </div>
)

const LazyList = props => <Lazy view={List} list={props.list} />

const Increment = state => ({ ...state, count: state.count + 1 })
const MoreItems = state => ({
  ...state,
  list: state.list.concat(state.list.length + 1)
})

app({
  init: () => ({
    list: [1, 2, 3],
    count: 0
  }),
  view: state => (
    <main>
      <button onclick={Increment}>{state.count}</button>
      <button onclick={MoreItems}>More Items</button>
      <List list={state.list} />
      <LazyList list={state.list} />
    </main>
  ),
  node: document.getElementById("app")
})

Clicking the button increments state.count. Hyperapp will update the DOM to display the new count, recalculating the entire view tree from scratch. If the List only depends on state.list, why render it when state.count changes? Hyperapp will re-render LazyList only when it needs to, that is when state.list changes. And thanks to immutability, checking if state.list has changed is as cheap as oldProps.list !== props.list. Our job is to identify these scenarios and strategically add laziness to our views.

Let's be lazy! 💯

okwolf commented 6 years ago

@jorgebucaran any proposed API for what using this in Hyperapp would look like? 🤔

okwolf commented 6 years ago

Shouldn't the <Lazy> component define what props it depends on somehow? I believe recompose also has something similar with onlyUpdateForKeys. Also, how does this compare with memoizing components?

SkaterDad commented 6 years ago

I've experimentally implemented this with a similar API to how it works in elm. You have a function that takes the view function and an array of objects/values which will be used for === checks. You need to specify which props you care because you want to ignore things like event handling functions.

The vdom part of it was pretty much copied from elm and snabbdom. Not at a desktop right now, so I'll post some code snippets tomorrow.

jorgebucaran commented 6 years ago

@okwolf What is the difference between reselect and recompose? Lol.

okwolf commented 6 years ago

@jorgebucaran What is the difference between reselect and recompose? Lol.

Recompose is a toolkit of handy reusable HOCs that support compose. It's very React-specific.

Reselect allows you to create memoized selector functions that are only reevaluated when one of their arguments change. It is commonly used with Redux, but not specific to it.

jorgebucaran commented 6 years ago

In that case, reselect sounds more like what we want to do here. Do you agree?

okwolf commented 6 years ago

I would agree that we probably want something that behaves like Reselect but ideally without having to do the same amount of wiring. It would be 💯🔥 if we could handle this automatically somehow.

Perhaps we could make views lazy by default instead of the reverse?

aminnairi commented 6 years ago

Why not a lazy prop for components?

const Home = () => <h1>Home page</h1>

const About = () => <h1>About page</h1>

const view = state => (
  <div>
    <ul>
      <li>
        <Link to="/">Home</Link>
      </li>
      <li>
        <Link to="/about">About</Link>
      </li>
    </ul>

    <hr />

    <Route path="/" render={Home} lazy />
    <Route path="/about" render={About} />
  </div>
)

// About is not lazy loaded, compared to Home in this example
jorgebucaran commented 6 years ago

Perhaps we could make views lazy by default instead of the reverse?

Could make things worse. But I couldn't say until I try it. I'm still not going with lazy by default. I want this to be a built-in feature like Elm if possible, though.

References:

okwolf commented 6 years ago

Benchmarks or it didn't happen 😜

SkaterDad commented 6 years ago

@okwolf Benchmarks or it didn't happen

I've done them, and with an elm-style lazy implementation, there is a trade-off in perf. If you make everything lazy, you have to do extra work during the diff/patch process to loop over the properties and determine if they changed. Sprinkling a few lazy checks in strategic spots will give you more benefits.

Here's a codesandbox where I implemented this a while ago using the JS Frameworks Benchmark implementation. Check the "elmish-lazy" folder. Beyond the framework code changes, I had to restructure the model and actions to take advantage of the lazy checks (i.e. copied what elm did). https://codesandbox.io/s/k3jj2qlpk5

Key changes in the hyperapp.js file:


Here is how you would use it (see row.js in the codesandbox):
```jsx
import { h, lazy } from "./hyperapp"

export default ({ data }) => (_, actions) => {
  return lazy(Row, [data, actions.select, actions.delete], data.id)
}

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>
  )
}
jorgebucaran commented 6 years ago

I have news! I implemented a POC that works and I'll be shipping this feature with the official 2.0 release, but not in the 2.0 alpha, as it will take me at least a week to fine tune it to my liking and there's still a lot to do.

Now that I understand the problem a bit better, let me explain what this feature is about, how it works and how you can use it.

Problem

We'll begin with an example. Imagine you have a view like so:

const view = state  => (
  <div>
    <ListView list={state.list} />
    {/* ...other stuff... */}
    <h1>{state.otherData}</h1>
    <button onclick={OtherDataChanged}>Update Other Data</button>
  </div>
)

Now, suppose this is your state.

const initialState = {
  otherData: 0,
  list: ["one", "two", "three", ... "seven hundred sixty-six thousand two"]
}

Every time we render the view, we make a new VDOM tree. We know that if otherData changes, we'll re-render the entire view, causing ListView to generate a bunch of vnodes. ListView only cares about state.list, so this is wasteful.

We want to render ListView only when state.list changes and not otherwise.

Solution

We could solve this issue today using memoization. We can serialize state.list and use it as a key to cache the result of the view. I want to introduce an alternative solution, built-into Hyperapp itself.

import { h, Lazy } from "hyperapp"

const view = state  => (
  <div>
    <Lazy render={ListView} list={state.list} />
    {/* ...other stuff... */}
    <h1>{state.otherData}</h1>
    <button onclick={OtherDataChanged}>Update Other Data</button>
  </div>
)

Now, Hyperapp can render ListView only when state.list changes. That's it!

Did you like this API? What would you change?

Notes

The Lazy function marks a part of your VDOM as "lazy" and tells Hyperapp the whole tree below it depends on one or more values. Hyperapp checks the referential integrity of those values, if they changed, then we'll compute the lazy view's virtual DOM as usual, otherwise we'll reuse the previous one.

This feature is not about memoizing vnodes, you can still do that if you want. This feature is about remembering whether the last vnode was lazy or not. Incidentally, the development of this feature could lay out the plumbing for built-in dynamic component support!

SkaterDad commented 6 years ago

@jorgebucaran <Lazy render={ListView} list={state.list} />

Interesting API. I assume whatever props you add to Lazy get passed down to the component in render, and all of them are === checked? The example makes it sound like this only applies to arrays, so maybe another more general-use example would be good also.

With JSX, this is pretty clean. Without, it looks a little weird: h(Lazy, { render: ListView, list: state.list }), but as a perf optimization you only use occassionally, I'm okay with it.

jorgebucaran commented 6 years ago

@SkaterDad The example makes it sound like this only applies to arrays, so maybe another more general-use example would be good also.

True, the solution is for any values, primitive types, arrays, objects (state fragments), etc. We check for referential integrity, not each member of the array or object.

The solution should look clean for both JSX and vanilla Lazy(...).


This development paves the way for implementing dynamic (imported) components. Just an idea:

const view = state  => (
  <Dynamic render={() => import("./fooBar"} />
)
artem-v-shamsutdinov commented 6 years ago

I got confused by:

"This feature is about remembering whether the last vnode was lazy or not."

So I have a couple of questions:

1) Is it true that given:

<Lazy render={ListView} list={state.list} />

the ListView will be re-rendered only if state.list points to a different object than it did last time?

2) And if that assumption is true, given:

<Lazy render={ListView} list={state.list} moreData={state.moreData} />

will the ListView be re-rendered only if either state.list or state.moreData point to a different object than last time?

Thanks, :)

jorgebucaran commented 6 years ago

@russoturisto Yes to (1) and (2). You got it.

selfup commented 6 years ago

Memoization is quite simple so I am all for this :smile:

Here is a fib example for those that haven't heard of it:

ES5 for simplicity

function fib(n, cache) {
  cache = cache || {};

  if (n < 2) {
    return n;
  } else {
    if (cache[n]) {
      return cache[n]
    } else {
      cache[n] = fib(n - 1, cache) + fib(n - 2, cache);

      return cache[n];
    }
  }
}

So I guess here we would cache all props?

Or would you do a diff of previous and current?

How can we differentiate previous and current state when things can be objects though?

Just like:

JSON.stringify(previousObject) === JSON.stringify(newObject)

🤔

jorgebucaran commented 6 years ago

@selfup How can we differentiate previous and current state when things can be objects though?

We check for referential equality using a ===. See my https://github.com/hyperapp/hyperapp/issues/721#issuecomment-402150041 for an example.

The key is that Hyperapp's state is immutable! 🎉

Keep in mind that in V2 you are responsible for merging the next state with the last state to produce the new state, but the idea is the same.

I've made a diagram to explain this.

Let's say this is our lastState and nextState.

img_0040

We now merge { ...lastState, ...nextState } to produce newState.

img_0041

Red is all new stuff. Black is the old stuff. Notice that our top-level state prop A is red now. That's because create a new object during the merge.

Now imagine that you have several views within your main view, some depend only in state.A, others in state.B, state.C and some in a mixture of those. By default Hyperapp will redraw everything. But we know that's wasteful. Only the views that depend in the red stuff should be redrawn, and those that depend in the black stuff should be left like they are.

Why? Because our views are pure functions. Some state comes in and some VDOM comes out. Same inputs produce the same outputs. The black stuffs is the part of state that didn't change across the transition from lastState to newState, so there is no need to redraw the views that depend on it.

img_0042

A lazy view that depends on state.B or state.C or state.B && state.C will not be redrawn. That's what this feature is about. So what we do is we just return the last virtual node, that we have during diffing/patching.

EDIT: A more accurate diagram/representation can be seen here.

selfup commented 6 years ago

Thanks very much for this! I guess === can check the reference too. Yea I like the idea of not redrawing things that don't need to be redrawn 😄

frenzzy commented 6 years ago

But again, why this Lazy component should be in core?

SkaterDad commented 6 years ago

@frenzzy The lazy checks have to be done within the vdom resolution/patching algorithm, and the Lazy component would have to be tailored to the implementation.

Straight up memoization can already be done externally, of course.

frenzzy commented 6 years ago

But resolution/patching algorithm already skips the whole subtree if a vnode is the same as previous one: https://github.com/hyperapp/hyperapp/blob/8da3edec673f9b67da03771b8713bda3a2b23519/src/index.js#L291-L292

okwolf commented 6 years ago

@frenzzy But resolution/patching algorithm already skips the whole subtree if a vnode is the same as previous one

Yes but you have to write the memoization of your component by hand. This would move that work out of userland with a syntax similar to Html.lazy. This is also similar to React.PureComponent.

frenzzy commented 6 years ago

Oh, I will try to ask one more time... Here is an implementation of Lazy component with api suggested above:

const cache = new WeakMap()
const Lazy = (props) => {
  const prev = cache.get(props.render)
  if (prev && shallowEqualObjects(prev.props, props)) {
    return prev.node
  }
  const next = { node: props.render(props), props }
  cache.set(props.render, next)
  return next.node
}
// Usage: <Lazy render={ComponentName} {...otherProps} />

It already works as you expected with Hyperapp v1. See demo: https://codepen.io/frenzzy/pen/ZRNBBr/left?editors=0011 (input change does not cause re-render the lazy component)

The question is, why

import { Lazy } from 'hyperapp'

and not

import { Lazy } from 'hyperapp-lazy'

What the profit? It just adds OPTIONAL code to the core.

Updated: I forgot about tree-shaking, than maybe it is ok :)

zaceno commented 6 years ago

I think I get this now... At first I was with @frenzzy, thinking this is just about bundling a memoization solution in with hyperapp. Now I think I understand how integrating the memoization into core is better than userland memoization. Tell me if this is correct:

Take this example:

import {memoize} from 'some-memoization-library'
...
const FancyList = memoize(props => {...})
...
<div>
  <h2>Users:</h2>
  <FancyList list={state.users} />
  <h2>Groups:</h2>
  <FancyList list={state.groups} />
</div>

Since the values in the list prop alternates between users and groups every render, the memoization can't just compare with the previous props: it needs to keep a dictionary of all previous props+results. Meaning it needs to age out stuff in some other way.

Also, a userland memoization solution can't assume that just because an array instance is the same as before, nothing has changed. It needs to do a deep comparison or a serialization

These are the reasons generic memoization solutions like moize have so many options -- it requires tuning for each use-case.

In contrast, a built in Lazy component suggested as above:

import {Lazy} from 'hyperapp'
...
const FancyList = props => {...}
...
<div>
  <h2>Users:</h2>
  <Lazy render={FancyList} list={state.users} />
  <h2>Groups:</h2>
  <Lazy render={FancyList} list={state.groups} />
</div>

It's still technically memoization, but with two important factors:

Since it's built into the rendering algo, it can memoize separately for each position in the vdom. So a history of several previous props isn't necessary. Just a simple comparison between previous and current props.

Because props come from the state, we can assume that same instance of arrays means nothing has changed deeper. Different instances of the array means something has changed deeper. Hence, there is no need for deep comparisons or serialization. A simple === check is sufficient to know wether to use the memo or not.

Ergo, building into core allows us to use the simplest most basic memoization technique, which is way more efficient than general third-party libs like moize.

Did I understand this correctly?

jorgebucaran commented 6 years ago

@zaceno ...building into core allows us to use the simplest most basic memoization technique

This is true! 💯

However, I don't think that a userland solution would perform worse than a built-in one. The latest Superfine benchmark results use userland laziness as @frenzzy proposed and the net effect is the same.

let cachedRows = []

const RowsView = ({ state }) => {
  return state.rowData.map(({ id, label }, i) => {
    cachedRows[i] = cachedRows[i] || {}

    if (cachedRows[i].id === id && cachedRows[i].label === label) {
      return cachedRows[i].view
    }

    const view = (
      <tr key={id}>
        <td>{id}</td>
        <td>
          <a onClick={[Select, id]}>{label}</a>
        </td>
        <td>
          <a onClick={[Delete, id]}>
            <span class="icon-remove" />
          </a>
        </td>
      </tr>
    )

    // Remember the last row
    cachedRows[i].id = id
    cachedRows[i].label = label
    cachedRows[i].view = view

    return view
  })
}

I want to create a new issue to explain lazy views/components from scratch now that I understand it a little better myself. I plan to do it later when I am ready to implement it, but likely after the V2 release. I think the best introduction to the idea behind this feature is on the elm blog here.

EDIT: The issue now explains how laziness works in Hyperapp and how we can use hyperapp.Lazy in detail. There's also a code playground you can play with. 🎉

zaceno commented 6 years ago

@jorgebucaran I think the reason that technique works as well as having the memoization built in is because there is only one use of RowsView in the view. If there were multiple uses of RowsView showing different sets of rows, it wouldn't be able to memoize anything any longer.

By building it into patch, we can have separate memos for separate instances of the same component, to get around the reuse problem.

I'm all for it :)

SkaterDad commented 5 years ago

@jorgebucaran Any updates on this you can share?

I've been experimenting on a copy of the V2 code, and have implemented the Lazy and Dynamic components.

Getting Lazy to work was pretty easy, and I re-used the isSameValue function you already had.

Dynamic was a bit tricker, but I took some inspiration from Vue. Getting the component to download was pretty easy, but telling hyperapp to redraw once it's loaded required plumbing the render function through a few layers. Ideally, you could tell hyperapp to just continue rendering the part of the tree the dynamic component was on, but by the time the download finishes, the application may be in a different state, so I played it safe and did a full render.

If you're open to a PR, I can tidy things up.

The API is essentially what you showed earlier in the thread, but I added a fallback view for dynamic components.


// ./components/whatever.js
function Whatever(props) {
  return h('h1', {}, props.message)
}

// ... 
// This can't be an anymous function
function AsyncComponent() {
  return import("./components/whatever.js")
    .then(module => module.default)
}

function view(state) {
  return h('div', {}, [
    Lazy({ render: TestView, someProp: state.lazy, key: "test" }),
    Dynamic({ render: AsyncComponent, fallback: Fallback, message: "This was loaded dynamically."}),
   ])
}
lukejacksonn commented 5 years ago

How does the code look for Lazy and Dynamic here @SkaterDad?

SkaterDad commented 5 years ago

@lukejacksonn After a little refactoring, here are those 2 functions. I added some basic JSDoc comments for clarity. I introduced two new integers to the top of the file: LAZY_NODE and ASYNC_NODE.

The basic idea is those functions create special objects which the VDOM engine knows to treat in a special way. These special objects have a function on them which allows the underlying view function to be resolved & rendered. The full props object gets passed straight to the view function for simplicity.

/**
 * Lazy Component
 * @param {Object} props - Properties to pass to lazy component.
 * @param {string} props.key - VNode key - required for maximum benefit.
 * @param {Function} props.render - View function which should be lazy.  Can not be anonymous!
 */
export var Lazy = function(props) {
  return {
    type: LAZY_NODE,
    key: props.key,
    props: props,
    lazy: function() {
      var node = props.render(props)
      node.props = props
      return node
    },
  }
}

For the dynamic component, we need a way to prevent it from being downloaded twice. To do that, I borrowed a trick from Vue, and attach some properties to the render function that was passed in. On the first pass, we mark it as loading, kick off the Promise, and return the fallback view. Once the promise resolves, the downloaded function gets attached as the resolved property, and we re-render the app. Since the resolved property now exists, we can return it synchronously on subsequent renders.

/**
 * Dynamic Import Component.
 * @param {Object} props Properties to pass along to imported component.
 * @param {Function} props.render - Function that returns a promise, which resolves to the view function.  Can not be anonymous!
 * @param {Function} props.fallback - Function that renders a fallback view function.
 */
export var Dynamic = function(props) {
  return {
    type: ASYNC_NODE,
    props: props,
    key: props.key,
    load: function(cb) {
      if (props.render.resolved) {
        return props.render.resolved(props)
      } else {
        if (!props.render.isLoading) {
          props.render.isLoading = true
          props.render().then(function(fn) {
            props.render.resolved = fn
            cb() // <-- hyperapp passes this in. currently re-renders the whole view.
          })
        }
        return props.fallback(props)
      }
    },
  }
}

To make it all work, I had to re-introduce a resolveNode function and strategically call it within the diffing algorithm (in 3 places). The resolving is quite simple.

// The "render" callback may need to be replaced with the "setState" function,
// depending on how initial state of dynamic components is being handled.
// Right now, this implementation assumes your state object is fully set up, and you're just waiting
// for views & actions.
var makeNodeResolver = function(render) {
  return function(node, oldNode) {
    var newNode = node

    if (newNode.type === LAZY_NODE) {
      newNode =
        oldNode && isSameValue(newNode.props, oldNode.props)
          ? oldNode
          : newNode.lazy()
    }

    if (newNode.type === ASYNC_NODE) {
      return newNode.load(render)
    }

    return newNode
  }
}

@jorgebucaran I'm pretty happy with how the code works, but I don't want to step on your toes if you have something in progress. I'm going to continue experimenting with this, and make sure it actually works with actions & effects, too.

EDIT: Actions work just fine, which makes sense, as they're just value objects now. Setting the initial state needed by the dynamic view is the challenge, especially in a friendly API.

In my apps, the view & actions are the vast majority of the file sizes, so I would be perfectly happy to set up my complete state object on app startup, and dynamically load expensive view/action sets as needed. I'm sure not everyone will agree with that idea, but it's an option.

jorgebucaran commented 5 years ago

@SkaterDad I'm not considering dynamic views for V2. I'm not suddenly against the idea, but implementing them is no longer a priority. Perhaps V3. Lazy views are a different story.

SkaterDad commented 5 years ago

@jorgebucaran I'm not considering dynamic views for V2. I'm not suddenly against the idea, but implementing them is no longer a priority. Perhaps V3. Lazy views are a different story.

Fair enough. I've considered using effects to do dynamic imports also. On route change, for example, you could Promise.all([TheData, TheComponent]), and handle storing the returned functions in the app code.

Want a PR with the lazy implementation? It required significantly less code than the dynamic views.

jorgebucaran commented 5 years ago

@SkaterDad To the V2 branch? Yes, go ahead. 💯

jorgebucaran commented 5 years ago

Lazy views are now implemented and available in the latest beta. This issue now explains how laziness works in Hyperapp and includes a gentle introduction to hyperapp.Lazy. Here's a code playground.

Thank you, @SkaterDad for the implementation and everyone else who contributed to this discussion.