canjs / can-component

Custom elements and widgets
https://canjs.com/doc/can-component.html
MIT License
8 stars 8 forks source link

customElements timings with can-component #200

Open justinbmeyer opened 6 years ago

justinbmeyer commented 6 years ago

Changing the DOM and measuring the DOM should happen a two separate times. The difference in performance can be profound:

can-component and CanJS was built with this performance problem in mind. This is why you render a template "disconnected" and then use inserted and other events to know when the DOM is connected. It's currently very difficult to cause layout thrashing as seen in the above example.

This issue is to discuss how to translate this to customElements which have two lifecycle hooks:

Understanding thrashing

This article is a good start: http://kellegous.com/j/2013/01/26/layout-performance/

The way I think about it is that you want to do writes in a big batch, and then reads in a big batch.

For example, we want people to build all of their HTML, update CSS properties, etc, but not read any values like clientWidth.

Once you begin reading values, read as many values as you'd like. Then you can return to updating the DOM.

Considerations besides performance

justinbmeyer commented 6 years ago

Render on parentNode

One idea might be for parentNode to create the VM and render. I'm not sure if we can trap this, but it would allow setup like:

var myElement = document.createElement("my-element");
myElement.property = "value";

disconnectedElement.appendChild( myElement ) //<-- here is where the VM is created and the view rendered.

Cons:

justinbmeyer commented 6 years ago

Render during Element Creation

In this model, elements would render right away.

Pros:

Cons:

justinbmeyer commented 6 years ago

Activate method

In this design, elements would not render right away, but register themselves to be "activated".

var myElement = document.createElement("my-element");
myElement.property = "value";

Component.activate(); // all components have VM created and rendered.

myElement.children //-> [ ... ]

Every time stache renders or mutates the DOM, it would call activate().

Pros:

Cons:

matthewp commented 6 years ago

Provide lifecycle hooks for when it is safe to read DOM

We could provide a lifecycle hook that gets called in its own rAF after rendering occurs. This would be in its own queue that could take place in a rAF after the DOM_UI queue (or after the Mutate queue possibly).

This would be nice because it could be called ever time after its template gets updated. Something like:

Component.extend({
  tagName: "hello-world",

  mutatedCallback: function(){
    // Reads are safe here
  }
});
matthewp commented 6 years ago

The side-effect problem is not limited to only a few small cases. Many view libraries work by creating DOM that never gets inserted. This is what CanJS does. Here's another that does the same thing. Creating DOM, and then later cloning + hydrating is common pattern.

Additionally, almost all (maybe all?) canjs apps do XHR requests when their ViewModel is bound, which happens in can-view-live before insertion. Here's where bitballs does. Here's where donejs-chat does.

Within the boundaries of can-view-callbacks and can-stache this isn't so bad because it's a mostly controlled environment. But once you support document.createElement as your API to create components you have to be much more careful.

justinbmeyer commented 6 years ago

@matthewp to clarify what you are saying ... doing XHR requests is only a problem with Element creation if people make custom elements and use them outside CanJS.

While this is a problem limited within CanJS applications. I agree that's a deal-breaker. The custom elements should work in as many use cases as possible.

This is why I think something like activate might make the most sense; even more so because we could design these components such that if activate isn't called and the element is inserted, it immediately complete all lifecycle hooks. This should work well for 3rd party use.

But for CanJS, or integrations that want the benefits of detached DOM creation (performance enforcement), they can simply call this method after rendering content intended for inserting in the page.

Regarding mutatedCallback, I think if we are going to go down that road, we should solve all this more generally. The problem is that you sometimes want to write-read-write.

A bad (but most simple I can come up with) example might be keeping some absolute div (coverDiv) over another div backgroundDiv that's layout is changing box-sizing.

Naively, you'd want to:

backgroundDiv.style.boxSizing = "border-box";
var width = coverDiv.offsetWidth,
      height = coverDiv.offsetHeight;
coverDiv.style.width = width +"px";
coverDiv.style.height = height +"px";

If you had multiple of these happening at the same time, you'd want something more like:

queues.domMutate.enqueue(function(){
  backgroundDiv.style.boxSizing = "border-box";

  queues.domRead.enqueue(function(){
    var width = coverDiv.offsetWidth,
      height = coverDiv.offsetHeight;

    queues.domMutate.enqueue(function(){
      coverDiv.style.width = width +"px";
      coverDiv.style.height = height +"px";
    })
  })
})

One critical thing about the domMutate <->domReadrelationship ... is that once we start flushingdomRead, we finish all of them before we "back up" to earlier queues, includingdomMutate`.

Currently, if a task is enqueued in an earlier queue, all queues pause and start flushing tasks in the earliest queue. This wouldn't be the case here. We want all domRead to finish, then we could back-up into domMutate and start changing things and creating more domMutate if necessary.

matthewp commented 6 years ago

I think this discussion should be moved to can-element because can-component's automount capability has 2 implementations; custom elements and mutation observers. Since the MO method can only render after insertion the CE implementation should do the same, for consistency.

can-element won't have an MO implementation (you can use MO as a partial polyfill, though) so we have more freedom to do things differently. I'm starting to favor moving all rendering into a rAF task so we can more tightly control that queues are processed only every 16ms at a time.

justinbmeyer commented 6 years ago

@matthewp it's relevant here because of how we are going to prompt people to test can-component in #199. We need to give people a way of testing components (ideally without requiring them to actually insert the component into the page).

Regarding the rAF ... I'm uneasy about adding async until we have very solid debugging tools around our queues and very good patterns too. It's my understanding that the async queue system of ember is one of it's least liked parts.

phillipskevin commented 6 years ago

ideally without requiring them to actually insert the component into the page

Is it really a big deal to require elements be inserted into the page in order to test components?

If you want to test things that don't require the element to be in the page, you should probably just be testing the ViewModel.

justinbmeyer commented 6 years ago

@phillipskevin we do this all the time in can-stache. Tests are much faster because of it.