Yeah, I even have a blog on my website! This is the place where I drop short write-ups with tips and tricks, offer old hardware for sale, maybe even share a song with you, my dear audience and (future) clientele.
Blog
My Secret to Super Fast AB Test Loading
When I am testing elements that take some time to load, the last thing I want is a flash of un-styled content, or see the unchanged element jump into its changed state. Since a few years, browsers have a great API built in that I use to achieve super fast loading of my test code: Mutation Observer. (link opens new tab)
In this post I’ll explain how I use this API to my advantage.
Make sure your script is loaded as soon as possible. It’s OK if you load it asynchronously, but you want it to be available as the first piece of JS the page is loading.
Here’s the function I use to observe when an element gets added to the DOM. I basically wrap a querySelector in a MutationObserver. The latter will fire upon every DOM mutation. The querySelector will then test for the existence of my element. If that returns true, I disconnect the observer if I don’t need it anymore. Finally, I run the callback function that was passed as the second parameter.
const observeDOM = (config, callback) => {
// use a regular function so `this` refers to the Mutation Observer
new MutationObserver(function() {
const element = document.querySelector(config.selector);
if (element) {
// disconnect if you don't need it again
if(config.disconnect) this.disconnect();
// test and run the callback function
if(typeof callback === "function") callback(element);
}
}).observe(config.parent || document, {
subtree: config.recursive,
childList: true
});
}
I use a ‘normal’ function keyword on the Mutation Observer function because if I don’t, I won’t be able to disconnect it if that is what I want. This will then refer to the Window object and not the MutationObserver instance.
const config = {
selector: 'li.timeline-item', // what element are we looking for?
parent: document.querySelector("ul.timeline"), // narrow down search scope if possible...
recursive: true, // look at descendant elements too
disconnect: false // set to true when one hit is enough
}
In the config file above you can see that I am observing an unordered list for additions of list items. Since disconnect is set to false, the observer will fire on every mutation and do the element test again. Note: You can prevent triggering on the same element over and over again by adding a class (.found) to the element as soon as it’s found, and change your selector accordingly: a li.timeline-item:not(.found) selector does that trick just fine.
// run this function when the element is found
const callback = console.log;
Here is a simple example of a callback function you can run when you have a hit. In your case you probably want to kick off your AB test code. See what I did there?
// kickoff mutation observer
observeDOM(config, callback);
Last but not least, you want to start observing by calling your function with config and callback parameters.
In the next post, I’ll show you how to create DOM elements – recoveryArea style!
Happy coding!
Notify Enter Viewport the proper way: Intersection Observer.
In previous articles I have written about how to track elements when they enter or leave the browsers viewport. The reason why you might want to track this is when you are doing a test on an element that is outside the viewport on page initialisation, you don’t want to count this as a visitor immediately but only when the element is scrolled into view.
If the (changed) element is seen by the visitor, you fire an event, not before. In the past I accomplished this by using something called notify enter viewport. This worked just fine but it came with a lot of caveats.
For instance, notify enter viewport only accepts elements that are already loaded in the DOM. Second, a lot of calculations have to be done inside the function before we know an elements position. It’s complicated.
Modern browsers come with an API that can help us accomplish basically the same in a much simpler way, the Intersection Observer.
Here’s a code example of how I use it in my own AB testing framework.
const observeIntersections = function (config) {
// loop through all entries in config array
config.forEach(function (options) {
document.querySelectorAll(options.element).forEach(function (element) {
const observer = new IntersectionObserver(function (entries) {
entries.forEach(function (entry) {
if (entry.isIntersecting) {
sendDimension({
event: 'trackEventNI',
eventCategory: abtest.testid + ": " + abtest.testName,
eventAction: 'Viewport hit for element [' + element + ']',
eventLabel: abtest.variation,
eventNonInteraction: true
});
}
});
}, {
root: null, // use document viewport as container
rootMargin: "0px",
threshold: 1 // fire callback as each of these thresholds is reached
});
observer.observe(element);
});
})
};
observeIntersections([{
element: 'h4', // the element or elements we want to observe
tag: 'H4 Element',
threshold: 1,
root: null,
rootMargin: "0px"
}, {
element: 'h2#Intersection_observer_concepts_and_usage',
tag: 'H2 title element',
threshold: 1,
root: null,
rootMargin: "0px"
}, {
element: 'iframe.live-sample-frame.sample-code-frame',
tag: 'IFRAME ELEMENT',
threshold: [0, 0.5, 1],
root: null,
rootMargin: "0px"
}
]);
Notify Enter Viewport
Sometimes we are testing a page element or component that, when the page has loaded, lies outside of the viewport. This has the simple consequence that you should not count this page load as a visitor to your test. Only when the element you are testing scrolls into view, the visitor can be added to your test. To accomplish this, we needed some code that keeps track of the elements position on the page with regard to the page’s dimensions.
This one still relies on jQuery, I’ll upload a JS only version soon.
var notifyEnterViewport = function(o) { var raf = window.requestAnimationFrame || window.webkitRequestAnimationFrame || window.mozRequestAnimationFrame || window.msRequestAnimationFrame || window.oRequestAnimationFrame, l = $(window).height(), r = $(window), t = r.scrollTop(), enter = function(e) { e.addClass("MM_inside_viewport"), e.hasClass("MM_outside_viewport") && e.removeClass("MM_outside_viewport") }, exit = function(e) { e.addClass("MM_outside_viewport"), e.hasClass("MM_inside_viewport") && e.removeClass("MM_inside_viewport") }, loop = function() { var e = r.scrollTop(); t !== e && (t = e, o.length && $.each(o, function(e) { var o = $(this), raf = o.height(), t = o.offset().top, loop = t - r.scrollTop(), n = t - r.scrollTop() + raf; 0 <= loop && n <= l && !o.hasClass("MM_inside_viewport") && (console.log("test: element " + (e + 1) + " scrolled into view"), enter(o)), (n < 0 || l < loop) && !o.hasClass("MM_outside_viewport") && (console.log("test: element " + (e + 1) + " scrolled out of view"), exit(o)) })), raf(loop) }; raf && loop(); } elementsToCheck = $("div#checkThisElement"); notifyEnterViewport(elementsToCheck);
- UPDATE!
var notifyEnterViewport = function(el) {
var w = window,
raf = w.requestAnimationFrame,
vpHeight = w.innerHeight,
lastPgYO = w.pageYOffset,
scroll = function() {
var height = el.offsetHeight,
elOffset = offSet(el),
top = elOffset.top,
pTop = top - w.pageYOffset,
pBottom = pTop + height;
if (pTop >= 0 && pBottom <= vpHeight && !el.classList.contains('mm_enter')) {
enter(el);
}
if ((pBottom < 0 || pTop > vpHeight) && !el.classList.contains('mm_exit')) {
exit(el);
}
},
offSet = function(el) {
var rect = el.getBoundingClientRect(),
pgxo = w.pageXOffset,
pgyo = w.pageYOffset;
return {
top: rect.top + pgyo,
left: rect.left + pgxo
};
},
enter = function(el) {
//console.log("enter: ", el);
el.classList.add('mm_enter');
el.classList.remove('mm_exit');
},
exit = function(el) {
//console.log("exit: ", el);
el.classList.add('mm_exit');
el.classList.remove('mm_enter');
},
loop = function() {
var PgYO = w.pageYOffset;
if (lastPgYO === PgYO) {
raf(loop);
return;
} else {
lastPgYO = PgYO;
scroll();
raf(loop);
}
};
if (raf) loop();
};
// call:
notifyEnterViewport(document.querySelector('ul#sub_nav > li.active:not(.first)'));