joicomponents
Version:
Design patterns to build native web components
222 lines (193 loc) • 11.2 kB
JavaScript
/**
* SetupMixin
*
* SetupMixin adds a reactive setupCallback() method to an element.
* The setupCallback() is a method for:
* 1. setting default attribute values,
* 2. populating the shadowDOM, and/or
* 3. other heavier setup tasks that the browser might want to delay.
*
* The constructor() can be used to establish the properties of the element,
* but as the constructor is often called `post-instantiation` anyway,
* you might want to skip the constructor entirely, and only use the setupCallback() instead.
*
* Comment: Maybe the constructor behaviour should be different.
* Maybe the attributes should be populated into HTMLElements before the constructor is called.
* But in any case, you often might want to delay the constructor of the element, so
* this is not really that important.
*
* The setupCallback() can be triggered at the following times:
* 1. "firstConnected" (default): just before the first connectedCallback(),
* 2. <number> such as 40 (ms), the que of setupCallbacks are run asap.
* But if many setupCallbacks are added at the same time, the process of running setupCallback is
* only allowed to run up until the <number> of ms after the start of the requestAnimationFrame callbacks started at a time.
* After <number> of ms the flushing of waiting setupCallback()s are temporarily halted and
* queued in a new requestAnimationFrame callback.
* This enables the browser to render the view and process other events before proceeding, thus
* un-blocking the browser.
* 3. "blocking", (run asap at the first available requestAnimationFrame callback),
* "blocking" is implemented by adding a very high number as its value (ie. 666666).
* 4. "enterView", when the element first enters the viewport, using the IntersectionObserver API,
* if the browser does not support the IntersectionObserver API, the Mixin falls back to the default "firstConnected"
* behavior.
* 5. "domContentLoaded". Run when the domContentLoaded or in the first rAF if the page has already finished loading.
*
* ### Priority for <number> and "blocking" setupCallback()s:
*
* There is a single que for running all setupCallback() methods on all types of elements.
* This que is always sorted and prioritized so that elements with the highest ms priority are always first in line.
* If two elements have identical priority, it is first in first out.
* The "blocking" setupCallback are queued in the same que, they are simply given a very high number (ie. 666666 (6x6)).
*
* ## Race between connectedCallback() and setupCallback()
*
* It is not uncommon to do tasks in connectedCallback() depend on setupCallback().
* For example, setupCallback() might set up a shadowDOM, and
* then connectedCallback() binds an event listener to an element in that shadowDOM.
* However, if the execution of setupCallback() is scheduled to be run when "domContentLoaded",
* and the element is connected to the DOM while the page is loading and before "domContentLoaded"
* (a very common scenario), then either:
*
* 1. setupCallback() must be performed immediately before the first connectedCallback(), as in "firstConnected" state, or
* 2. the execution of connectedCallback() must be rescheduled to run after the delayed setupCallback() has run.
*
* To delay connectedCallback() require two steps:
* 1. In connectedCallback(): at the very beginning, add a check to see if this.isSetup is true.
* If it is not, then cancel the connectedCallback() at this time.
* 2. In setupCallback(): at the very end, add a check to see if the element is connected.
* If it is, then run connectedCallback() that we had canceled in the previous step.
*
* The code looks like this:
setupCallback(){
...
this.isConnected && this.connectedCallback();
}
connectedCallback(){
if (!this.isSetup) return;
...
}
*
*/
/**
* setupIntersectionObserver monitors a list of target elements and
* calls setupCallback on the element the first time they enter the view.
*/
const setupIntersectionObserver = window.IntersectionObserver ?
new IntersectionObserver(entries => {
for (let entry of entries) {
if (entry.isIntersecting) {
entry.target.setupCallback();
setupIntersectionObserver.unobserve(entry.target);
}
}
}) : undefined;
/**
* setupCallback
*/
const domContetLoadedQue = []; //que for elements on which setupCallback will be called
const queElems = []; //que for elements on which setupCallback will be called
const queTimes = []; //que for the priority in ms for these elements
function addToDomContetLoadedQue(el, ms){
if (document.readyState !== "loading")
return addToQue(el, ms);
domContetLoadedQue.push(el);
if (domContetLoadedQue.length === 1)
document.addEventListener("domContentLoaded", flushDomContentLoadedQue);
}
function flushDomContentLoadedQue(){
for (var i = 0; i < domContetLoadedQue.length; i++)
domContetLoadedQue[i].setupCallback();
}
function addToQue(el, ms) {
let pos = 0; //find the position of the el in the que based on its time priority
while (pos < queTimes.length && ms <= queTimes[pos]) //if the two ms priorities are identical, it is FIFO ordered.
pos++;
queElems.splice(pos, 0, el); //add the el
queTimes.splice(pos, 0, ms); //and the time priority in the que
if (queElems.length === 1) //if this is the first el in the que, start the flushing
requestAnimationFrame(flushQue);
}
function flushQue(startTime) {
while (queElems.length) { //while there are more elements in the prioritized que
let endTime = queTimes[0] + startTime; //look at the priority time of the first in que
if (endTime <= performance.now()) //test that the currentTime has not surpassed the priority for the element
return requestAnimationFrame(flushQue); //if there is not enough time in this round, then do more flushQue next rAF
queElems.shift().setupCallback(); //if there is more time this round, then remove the element from the que and run setupCallback() on it.
queTimes.shift();
}
}
const first = Symbol("first");
export const SetupMixin = function (Base) {
return class SetupMixin extends Base {
static get setupSetting() {
return "firstConnected"; //or: <number> ms, "blocking", "enterView"
}
constructor() {
super();
let type = this.constructor.setupSetting;
if (type === "enterView" && !window.IntersectionObserver)
type = "firstConnected";
this[first] = type !== "firstConnected";
if (!this[first])
return;
if (type === "enterView")
return setupIntersectionObserver.observe(this);
if (type === "domContentLoaded")
return addToDomContetLoadedQue(this, 666666);
if (type === "immediate")
return addToQue(this, 666666);
if (type instanceof Number)
return addToQue(this, type);
throw new TypeError("static get setupSetting must return either 'firstConnected', 'domContentLoaded', 'blocking', 'enterView' or a Number. Illegal value: " + type);
}
connectedCallback() {
this[first] || ((this[first] = true) && this.setupCallback());
if (super.connectedCallback) super.connectedCallback();
}
}
};
//* problem. The "how much time do I have available each frame" guessing game.
//* problem. I don't know how much time the render takes!! I never know how much time the render takes.
//* problem. The render takes different amount of time from time to time, so it is hard to evaluate anyways.
//* problem. this means that to guess the idle time is a guessing game. A constant guessing game.
// guess: 1. assumption each frame takes 16ms. This is true for 60fps.
// but now things are starting to come in 100fps and 120fps...
// So this timespace is not good.. But.. Maybe since 60fps is ok when we have setupTasks back-logged.
// maybe to say that 60fps is good enough.
// but again, maybe I should aim for the smallest frame..
// guess: 2. render+ui the time render+ui events takes will vary greatly.
// Sometimes, no render happens. It can be for example 0-4ms?
// Sometimes, only render happens. 4-7ms depending on the browser and processor etc?
// Sometimes, both render and ui events happens. scrolling. 7-16ms depending on the browser and processor etc?
// guess: 3. transition the user can suddenly, from one frame to the next, start ui events.
// some of the setupTasks can be very heavy for the render, thus making the render longer.
// some of the setupTasks can be very light for the render, thus making the render short.
// plan a: +2-2 go up up up until break, then down down down until ok, then up again
// plan b: do the +2-2 logic.
// but every time the browser goes up, you add the number to an yes list.
// and every time the browser goes down, you add the number to an no list.
// the yes and no lists are then gradually populated.
// the yes and no lists has an average.
// when making a new guesstimate, you go for halfway between the yes and the no averages.
// this makes for a very stable guesstimator.
// But this guesstimator will also be very slow to respond to changes both in gained and reduced resources.
//plan b+ what to do when the
// plan c: similar to plan b.
// it uses averages going upwards. but instead of doing averages, do the last success and last failure only.
// If the user starts ui events, this will make the algorithm browser is much faster at correcting
// the yes and no lists are then gradually populated.
// the yes and no lists has an average.
// when making a new guesstimate, you go for halfway between the yes and the no averages.
// plan c: do the halfway between /2-2 logic.
// but every time the browser goes up, you add the number to an yes list.
// and every time the browser goes down, you add the number to an no list.
// the yes and no lists are then gradually populated.
// the yes and no lists has an average.
// when making a new guesstimate, you go for halfway between the yes and the no averages.
//1. The whidle needs to end every time to let the render work.
// So 6ms(?) out of every 16ms we prioritize for update of the screen.
// But, it might be less than 6ms, so we might want to aim for 4.
// Maybe we should run a frame just to meassure how short the frametime CAN be..
//2. The whidle should try to maximize the time it spends within each frame.
//3. If the whidle uses more time than it has, the result will be that the frame will take too long.
//4.