-
Notifications
You must be signed in to change notification settings - Fork 781
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
V2 Lazy lists/components #721
Comments
@jorgebucaran any proposed API for what using this in Hyperapp would look like? 🤔 |
Shouldn't the |
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. |
@okwolf What is the difference between reselect and recompose? Lol. |
Recompose is a toolkit of handy reusable HOCs that support 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. |
In that case, reselect sounds more like what we want to do here. Do you agree? |
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? |
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 |
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: |
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). Key changes in the hyperapp.js file:
export function lazy(func, args, key) {
return {
func,
args,
key,
thunk: function() {
var node = func.apply(null, args)
node.func = func
node.args = args
node.key = key
return node
},
}
}
function lazyEqual(node, oldNode) {
if (!node.args || !oldNode || !oldNode.args) return false
var i = node.args.length
var match =
node.func === oldNode.func && node.args.length === oldNode.args.length
while (match && i--) {
match = node.args[i] === oldNode.args[i]
}
return match
}
function resolveNode(node, oldNode) {
var newNode =
typeof node === "function"
? resolveNode(node(globalState, wiredActions), oldNode)
: node
if (newNode.thunk) {
newNode = lazyEqual(node, oldNode) ? oldNode : newNode.thunk()
}
if (newNode == null) newNode = ""
return newNode
}
function patch(parent, element, oldNode, node, isSvg) {
if (node === oldNode) { Here is how you would use it (see row.js in the codesandbox): 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>
)
} |
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. ProblemWe'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 We want to render SolutionWe could solve this issue today using memoization. We can serialize 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 Did you like this API? What would you change? NotesThe 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! |
Interesting API. I assume whatever props you add to With JSX, this is pretty clean. Without, it looks a little weird: |
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"} />
) |
I got confused by: "This feature is about remembering whether the last vnode was lazy or not." So I have a couple of questions:
<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?
<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, :) |
@Russoturisto Yes to (1) and (2). You got it. |
Memoization is quite simple so I am all for this 😄 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) 🤔 |
We check for referential equality using a The key is that Hyperapp's state is immutable! 🎉
I've made a diagram to explain this. Let's say this is our We now merge Red is all new stuff. Black is the old stuff. Notice that our top-level state prop Now imagine that you have several views within your main view, some depend only in 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 A lazy view that depends on
|
Thanks very much for this! I guess |
But again, why this |
@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. |
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 |
Oh, I will try to ask one more time... 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. 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 :) |
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 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 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 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 Did I understand this correctly? |
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
})
}
EDIT: The issue now explains how laziness works in Hyperapp and how we can use |
@jorgebucaran I think the reason that technique works as well as having the memoization built in is because there is only one use of By building it into I'm all for it :) |
@jorgebucaran Any updates on this you can share? I've been experimenting on a copy of the V2 code, and have implemented the Getting
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."}),
])
} |
How does the code look for |
@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: 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 /**
* 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 /**
* 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 // 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. |
@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. |
Fair enough. I've considered using effects to do dynamic imports also. On route change, for example, you could Want a PR with the lazy implementation? It required significantly less code than the dynamic views. |
@SkaterDad To the V2 branch? Yes, go ahead. 💯 |
A lazy view is a part of the VDOM that is evaluated only if one or more specified state properties change (#721). This concept is borrowed from Elm's Html.lazy. This feature is not related to V1 lazy components. Any function you wrap inside of the new `Lazy` function will re-render if the props passed in have changed. It works by creating a special object which the VDOM patching algorithm uses to compare the "old" node props with the "new" node props. If the props are the same (`===`), the old VNode is used without having to compute it again. When the VDOM patching sees that `oldNode === newNode`, it skips traversing that part of the DOM tree, potentially saving a lot of work!
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 Thank you, @SkaterDad for the implementation and everyone else who contributed to this discussion. |
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 thehyperapp
package and wrap a view function like this.Hyperapp will render
Foo
only whenprops.foo
andprops.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.
Clicking the button increments
state.count
. Hyperapp will update the DOM to display the new count, recalculating the entire view tree from scratch. If theList
only depends onstate.list
, why render it whenstate.count
changes? Hyperapp will re-renderLazyList
only when it needs to, that is whenstate.list
changes. And thanks to immutability, checking ifstate.list
has changed is as cheap asoldProps.list !== props.list
. Our job is to identify these scenarios and strategically add laziness to our views.Let's be lazy! 💯
The text was updated successfully, but these errors were encountered: