The DOM is fast, layout is fast, CSS transitions are smooth; but doing any at the same time can cause nasty performance glitches. This explains why it's easy to demo a 60fps transition, but larger apps are often janky.
dom-scheduler
helps express the types of operation you're doing, and in which order. Under the hood dom-scheduler
ensures everything happens with the best perceived performance.
$ npm install dom-scheduler
<script src="node_modules/dom-scheduler/dom-scheduler.js"></script>
This project has 2 main goals:
- Preventing trivial DOM changes in some unrelated part of your code from ruining a transition.
- Enabling developers to easily express the ideal sequence for a change happening in phases (with Promise chains).
scheduler.attachDirect()
- Responding to long interactionsscheduler.feedback()
- Showing feedback to quick interactionscheduler.transition()
- Animations/transitionsscheduler.mutation()
- Mutating the DOM
As a rule of thumb
- Anything that takes more than 16ms (including engine work) should be kept out of direct blocks
.feedback()
and.transition()
blocks should mainly contain hardware accelerated CSS transitions/animations- In mutation blocks, anything goes
Using debug mode with a browser timeline profiler can help you spot issues (eg. a feedback block causing a reflow). You can always refer to the excellent csstriggers.com while writing new code.
Let's take a simple example like adding an item at the top of a list. To do that smoothly we want to:
.transition()
everything down to make room for the new item.mutation()
to insert the new item into the DOM (outside of the viewport, so the item doesn't flash on screen).transition()
the new item in the viewport
Without dom-scheduler
this means:
setupTransitionOnElements();
container.addEventListener('transitionend', function trWait() {
container.removeEventListener('transitionend');
writeToTheDOM();
setupTransitionOnNewElement();
el.addEventListener('transitionend', function stillWaiting() {
el.removeEventListener('transitionend', stillWaiting);
cleanUp();
});
});
But we'd rather use promises to express this kind of sequence:
pushDown(elements)
.then(insertInDocument)
.then(slideIn)
.then(cleanUp)
Another badass sequence, using a promise-based storage system might be something like
Promise.all([reflectChangeWithTransitions(), persistChange()])
.then(reflectChangeInDocument)
.then(cleanUp)
reflectChangeWithTransition()
is a scheduled transitionpersitChange()
is your backend callreflectChangeInDocument
is a scheduled mutationcleanUp
is a scheduled mutation
To reap all the benefits from the scheduled approach you want to
- "annotate" a maximum of your code, especially the mutations
- use the shared scheduler instance (exported as
scheduler
) - use the debug mode (see below)
Direct blocks should be used for direct manipulation (touchevents, scrollevents...). As such they have the highest priority.
You "attach" a direct block to a specific event. The scheduler takes care of adding and removing event listeners. The event object will be passed to the block
as the first parameter.
scheduler.attachDirect(elm, evt, block)
scheduler.detachDirect(elm, evt, block)
scheduler.attachDirect(el, 'touchmove', evt => {
el.style.transform = computeTransform(evt);
});
scheduler.feedback(block, elm, evt, timeout)
Feedback blocks should be used to encapsulate CSS transitions/animations triggered in direct response to a user interaction (eg. button pressed state).
They will be protected from scheduler.mutation()
s to perform smoothly and
return a promise, fulfilled once evt
is received on elm
or after timeout
ms.
The scheduler.feedback()
has the same priority as scheduler.attachDirect()
.
scheduler.feedback(() => {
el.classList.add('pressed');
}, el, 'transitionend').then(() => {
el.classList.remove('pressed');
});
scheduler.transition(block, elm, evt, timeout);
scheduler.transition()
should be used to protect CSS transitions/animations. When in progress they prevent any scheduled scheduler.mutation()
tasks running to maintain a smooth framerate. They return a promise, fulfilled once evt
is received on elm
or after timeout
ms.
scheduler.transition(() => {
el.style.transition = 'transform 0.25s ease';
el.classList.remove('new');
}, el, 'transitionend').then(() => {
el.style.transition = '';
});
scheduler.mutation(block);
Mutations blocks should be used to write to the DOM or perform actions requiring layout to be computed.
We shoud always aim for the document to be (almost) visually identical before and after a mutation block. Any big change in layout/size will cause a flash/jump.
scheduler.mutation()
blocks might be delayed (eg. when a scheduler.transition()
is in progress). They return a promise, fullfilled once the task is eventually executed; this also allows chaining.
When used for measurement (eg. getBoundingClientRect()
) a block can return
a result that will be propagated through the promise chain.
scheduler.mutation(() => {
el.textContent = 'Main List (' + items.length + ')';
});
.direct()
blocks are called insiderequestAnimationFrame
.attachDirect()
and.feedback()
blocks have the highest priority and delay the rest..transition()
delays executuon of.mutation()
tasks..transition()
s are postponed while delayedmutation()
s are being flushed- When both
.transition()
s and.mutation()
s are queued because of.attachDirect()
manipulation,.transition()
s are run first
While it can have a negative impact on performance, it's recommended to turn the debug mode on from time to time during development to catch frequent mistakes early on.
Currently the debug mode will warn you about
- Transition block for which we never get an "end" event
- Direct blocks taking longer than 16ms
We're also using console.time
/ console.timeEnd
to flag the
following in the profiler:
animating
, when a feedback or transition is ongoingprotecting
, when a direct protection window is ongoing
You can turn on the debug mode by setting debug
to true
in dom-scheduler.js
.
To illustrate the benefits of the scheduling approach the project comes with a demo app: a big re-orderable (virtual) list where new content comes in every few seconds. At random, the data source will sometime simulate a case where the content isn't ready. And delay populating the content.
The interesting part is of course the "real life" behaviors:
- when new content comes in while scrolling
- when the edit mode is toggled while the system is busy scrolling
- when anything happens during a re-order manipulation
(You can turn on the naive
flag in dom-scheduler.js
to disable scheduling and compare.)
The index.html
at the root of this repository is meant for broad device and browser testing so we try to keep gecko/webkit/blink compatibility.
A (potentially outdated) version of the demo is usually accessible at http://sgz.fr/ds and should work on any modern browser.
The examples/demo
is a 'certified' packaged-app where we experiment with web components and other stuff.
$ npm install
$ npm test
If you would like tests to run on file change use:
$ npm run test-dev
Run lint check with command:
$ npm run lint
Mozilla Public License 2.0