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

Proposal: Introduce DOM diffing #4595

Open
justinbmeyer opened this Issue Nov 10, 2018 · 4 comments

Comments

Projects
None yet
3 participants
@justinbmeyer
Copy link
Contributor

justinbmeyer commented Nov 10, 2018

TLDR: We would like to incrementally add support for DOM diffing so users can opt-in to using it to increase performance in critical pieces of their applications.

This was discussed on a recent live stream (1:31).

CanJS uses diffing of observables to performantly update the DOM in most situations.

You can see in the GIF of this code below that when an item is added in the middle of the list, only the new element is inserted into the DOM (there is a more complex example described in https://forums.donejs.com/t/why-is-my-color-choosers-frame-rate-with-vue-so-much-higher-than-with-canjs/986):

view: `
    {{# for(item of items) }}
        <p>{{item.name}}</p>
    {{/ for }}
    <button on:click="addItemInTheMiddle()">Add item</button>
`,

ViewModel: {
    items: {
        default() {
            return new DefineList([{ name: "one" }, { name: "two" }, { name: "three" }]);
        }
    },
    addItemInTheMiddle() {
        this.items.splice(1, 0, { name: "four" });
    }
}

diff-works

There are some situations not handled by this technique. For example, if a new List is created when a change happens:

ViewModel: {
    items: {
        default() {
            return new DefineList(["one", "two", "three"]);
        }
    },

    get formattedItems() {
        const formatted = new DefineList([]);

        this.items.forEach(name => {
            formatted.push({ name: name });
        });

        return formatted;
    },

    addItemInTheMiddle() {
        this.items.splice(1, 0, "four");
    }
}

diff-doesnt-work-derived

An identity property can be added to each item in the List in order to make it work with CanJS's diffing system, but this is not always intuitive and can be difficult for users to implement:

get formattedItems() {
    const Formatted = DefineList.extend("Formatted", {
        "#": DefineMap.extend({
            name: { type: "string", identity: true }
        })
    });
    const formatted = new Formatted([]);

    this.items.forEach(name => {
        formatted.push({ name: name });
    });

    return formatted;
}

Situations like this are very suitable for a DOM diffing approach because when a change happens, the new DOM is often very similar to the previously rendered DOM. We should make it possible to use DOM diffing in CanJS without requiring breaking changes or huge testing efforts on our users.

A strategy for accomplishing this could be:

  1. Make a DOM-diff-based view
  2. Make a DOM-diff-based helper
  3. Make {{# for(item of items) }} use DOM-diffing
  4. Add full support for component lifecycles when diffing

These are discussed in the sections below

1. Make a DOM-diff-based view

To initially introduce a DOM-diffing approach in CanJS, we could provide a way to create renderer functions that use DOM diffing. This means that you could introduce DOM diffing for a single Component by using this new view engine instead of stache:

import { Component, diffStache } from "can";

Component.extend({
  tag: "...",

  view: diffStache(`
      ...
  `),

  ViewModel: {}
});

2. Make a DOM-diff-based helper

Once we have a proven solution for DOM-diffing, we can allow users to opt-in to using it in stache by providing a helper that can be used to replace for...ofloops:

view: `
    {{# diffFor(item of formattedItems) }}
        <p>{{item.name}}</p>
    {{/ for }}
`

3. Make {{# for(item of items) }} use DOM-diffing for "normal" elements

Once this "diffFor" works, we can allow the normal for...of loop to use diffing for normal (non-Component) elements.

4. Add full support for component lifecycles when diffing

The last step would be to add support for Components inside for...of loops, which would require us to handle Component lifecycle events (connected, removed, etc).

@justinbmeyer

This comment has been minimized.

Copy link
Contributor

justinbmeyer commented Nov 12, 2018

Issues with vdom diffing:

  • the vdom has cycles. Not sure how to teach the vdom how to be diffed ..
@matthewp

This comment has been minimized.

Copy link
Contributor

matthewp commented Nov 12, 2018

Worker rendering didn't do diffing. It recorded dom mutations like incremental rendering does.

@matthewp

This comment has been minimized.

Copy link
Contributor

matthewp commented Nov 12, 2018

Why use can-simple-dom and not just create some real DOM nodes that are not inserted into the page?

@phillipskevin phillipskevin changed the title Introducing dom diffing Proposal: Introduce DOM diffing Nov 15, 2018

@phillipskevin

This comment has been minimized.

Copy link
Collaborator

phillipskevin commented Nov 15, 2018

@justinbmeyer's original description:

This issue are thoughts around how to introduce virtual DOM diffing to CanJS as a solution to the performance problems related to https://forums.donejs.com/t/why-is-my-color-choosers-frame-rate-with-vue-so-much-higher-than-with-canjs/986/5.

I'd like to introduce it incrementally, so that folks could get some basic diffing without having to make it work completely in CanJS.

As we know SimpleDOM can already be used by can-stache. I think @matthewp as part of making workerthread rendering has made some form of diff and patching. (@matthewp where is this done?). If there isn't good diff and patching, it's likely can-diff/deep can help.

We could change can-view-live so that if it gets observables that produce DOM that is not of the same "document", we can diff the DOM result and then apply the patch:

var makeSimpleDOM = require("can-vdom/make-document/");
import {fragment} from "can";

var DOCUMENT = makeSimpleDOM();

var html = value.with( fragment("<div class='foo'/>", DOCUMENT) );

viewLive.html(el, html );

html.value = fragment("<div class='bar'/>", DOCUMENT)

The next problem is how can we switch to creating SimpleDOM instead of using the normal document. Obviously, there's globals, but that feels weird.

Currently, the templates get compiled in the section builders:

https://github.com/canjs/can-stache/blob/4af7b26e64be2623dda88dcfce9b24d6291184fd/src/html_section.js#L127

this.compiled = target(this.targetData, getDocument());

We could make compiling targets delayed. Perhaps target's hydrate() could take arguments specifying the type of document.

This information could also be passed down by stache helpers somehow. Currently, it's expected that scope, nodeList gets passed. Could the document be made part of nodeList.

We could make a {{# diff( item of items ) }}.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment