Based on this Q&A, I try to write some native JS code (no use of libraries) that will have elements addition hierarchy/dependency, which means that I'll have to wait for some elements to show up/load before I go on with the function. I have the following function to create and use MutationObservers in order to track DOM changes:
/**
* Creates an observer that tracks added DOM elements and executes a function when they're added
* #param {HTMLElement} observedObject - the dom element to observe
* #param {Function} elementFunc - the function to execute when elements are added
* #param {Number} timeoutMs - the amount of miliseconds to wait before disconnecting the observer
*/
function observeDOM(observedObject, elementFunc, timeoutMs) {
var connected = false;
var observer = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
if (!mutation.addedNodes) return
for (var i = 0; i < mutation.addedNodes.length; i++) {
if(elementFunc(mutation.addedNodes[i])) {
// disconnect when the function returns true
observer.disconnect();
break;
}
}
})
});
observer.observe(observedObject, {
childList: true
, subtree: true
, attributes: false
, characterData: false
});
connected = true;
// disconnect the observer after N seconds
if(timeoutMs >= 0) {
setTimeout(function() {
if(connected) {
observer.disconnect();
connected = false;
}
}, timeoutMs);
}
}
And I use the function above like that:
// do some staff...
observeDOM(document.body, function(node) {
if(node.className === "class1") {
// do some staff and wait for another element
observeDOM(document.body, function(node) {
if(node.className === "class2") {
// do some staff and wait for another element
observeDOM(document.body, function(node) {
if(node.className === "class2") {
//[...]
return true;
}
return false;
}
return true;
}
return false;
}
return true; // stop the observer
}
return false; // go on listening to elements addition
}
Do you think there is more elegant way to achieve it? What bothers my eyes the most is the tonnes of nested block I'll create when I have a big amount of elements to wait for !
Related
How can I write this mutation observer code, using async/await?
I want to return true after console.log("Button is appearing...");. Could someone show me the best way to write this code?
I also need to clarify, this code is watching for a button, which appears and then disappears. And the reappears again, multiple times.
So the mutationObserver, is watching for the button to appear multiple times. Not just once.
var target = document.querySelector('[search-model="SearchPodModel"]')
var observer = new MutationObserver(mutate);
function mutate(mutations) {
for (let i = 0; i < mutations.length; i++) {
if (mutations[i].oldValue === "ej-button rounded-corners arrow-button search-submit holiday-search ng-hide") {
console.log("Button is appearing...");
return true;
};
};
};
var config = { childList: true, attributes: true, characterData: true, subtree: true, attributeOldValue: true };
observer.observe(target, config);
Preface: I would strongly recommend not relying on a mutation observer to watch for a button's class attribute to change. It's very much a last resort thing to do. Look for anything else you can hook into that happens which is what makes the button appear/disappear and hook into that instead.
But getting to your question:
Since you want repeated notifications, promises (and thus async/await) is not the right model for this. A promise is only settled once.
There's no JavaScript built-in for it, but what you want is often called an observable and it has (typically) subscribe and unsubscribe methods. Here's a really basic, naive implementation of an observable (using modern JavaScript; run it through Babel or similar if you need to support older environments), but you may want to go looking for a library (such as Rx.js — not an endorsement, I haven't used it, just an example I happen to know about) with something more feature-rich and, you know, tested:
class Observable {
// Constructs the observable
constructor(setup) {
// Call the observable executor function, give it the function to call with
// notifications.
setup((spent, value) => {
// Do the notifications
this.#notifyObservers(spent, value);
if (spent) {
// Got a notification that the observable thing is completely done and
// won't be providing any more updates. Release the observers.
this.#observers = null;
}
});
}
// The observers
#observers = new Set();
// Notify observers
#notifyObservers(spent, value) {
// Grab the current list to notify
const observers = new Set(this.#observers);
for (const observer of observers) {
try { observer(spent, value); } catch { }
}
}
// Add an observer. Returns a true if the subscription was successful, false otherwise.
// You can't subscribe to a spent observable, and you can't subscribe twice.
subscribe(observer) {
if (typeof observer !== "function") {
throw new Error("The observer must be a function");
}
if (this.#observers.has(observer) || !this.#observers) {
return false;
}
this.#observers.add(observer);
return true;
}
// Remove an observer. Returns true if the unsubscription was successful, false otherwise.
unsubscribe(observer) {
return this.#observers ? this.#observers.delete(observer) : false;
}
}
Then you might create an observable for this mutation:
// Create an observable for the button
const buttonAppearedObservable = new Observable(notify => {
const target = document.querySelector('[search-model="SearchPodModel"]');
const observer = new MutationObserver(mutate);
function mutate(mutations) {
for (const mutation of mutations) {
if (mutation.oldValue === "ej-button rounded-corners arrow-button search-submit holiday-search ng-hide") {
// Notify observers. The first argument is `false` because this observable isn't "spent" (it may still
// send more notifications). If you wanted to pass a value, you'd pass a second argument.
notify(
false, // This observable isn't "spent"
mutation.target // Pass along the mutation target element (presumably the button?)
);
};
};
};
// Set up the observer
const config = { childList: true, attributes: true, characterData: true, subtree: true, attributeOldValue: true };
observer.observe(target, config);
});
Once you'd set that observable up, you could subscribe to it:
buttonAppearedObservable.subscribe((spent, button) => {
if (spent) {
// This is a notification that the button appeared event will never happen again
}
if (button) {
// The button appeared!
console.log(`Button "${button.value}" appeared!`);
}
});
Live Exmaple:
class Observable {
// Constructs the observable
constructor(setup) {
// Call the observable executor function, give it the function to call with
// notifications.
setup((spent, value) => {
// Do the notifications
this.#notifyObservers(spent, value);
if (spent) {
// Got a notification that the observable thing is completely done and
// won't be providing any more updates. Release the observers.
this.#observers = null;
}
});
}
// The observers
#observers = new Set();
// Notify observers
#notifyObservers(spent, value) {
// Grab the current list to notify
const observers = new Set(this.#observers);
for (const observer of observers) {
try { observer(spent, value); } catch { }
}
}
// Add an observer. Returns a true if the subscription was successful, false otherwise.
// You can't subscribe to a spent observable, and you can't subscribe twice.
subscribe(observer) {
if (typeof observer !== "function") {
throw new Error("The observer must be a function");
}
if (this.#observers.has(observer) || !this.#observers) {
return false;
}
this.#observers.add(observer);
return true;
}
// Remove an observer. Returns true if the unsubscription was successful, false otherwise.
unsubscribe(observer) {
return this.#observers ? this.#observers.delete(observer) : false;
}
}
// Create an observable for the button
const buttonAppearedObservable = new Observable(notify => {
const target = document.querySelector('[search-model="SearchPodModel"]');
const observer = new MutationObserver(mutate);
function mutate(mutations) {
for (const mutation of mutations) {
if (mutation.oldValue === "ej-button rounded-corners arrow-button search-submit holiday-search ng-hide") {
// Notify observers. The first argument is `false` because this observable isn't "spent" (it may still
// send more notifications). If you wanted to pass a value, you'd pass a second argument.
notify(
false, // This observable isn't "spent"
mutation.target // Pass along the mutation target element (presumably the button?)
);
};
};
};
// Set up the observer
const config = { childList: true, attributes: true, characterData: true, subtree: true, attributeOldValue: true };
observer.observe(target, config);
});
buttonAppearedObservable.subscribe((spent, button) => {
if (spent) {
// This is a notification that the button appeared event will never happen again
}
if (button) {
// The button appeared!
console.log(`Button "${button.value}" appeared!`);
}
});
// Stand-in code to make a button appear/disappear every second
let counter = 0;
let button = document.querySelector(`[search-model="SearchPodModel"] input[type=button]`);
let timer = setInterval(() => {
if (button.classList.contains("ng-hide")) {
++counter;
} else if (counter >= 10) {
console.log("Stopping the timer");
clearInterval(timer);
timer = 0;
return;
}
button.value = `Button ${counter}`;
button.classList.toggle("ng-hide");
}, 500);
.ng-hide {
display: none;
}
<!-- NOTE: `search-model` isnt' a valid attribute for any DOM element. Use the data-* prefix for custom attributes -->
<div search-model="SearchPodModel">
<input type="button" class="ej-button rounded-corners arrow-button search-submit holiday-search ng-hide" value="The Button">
</div>
All of that is very off-the-cuff. Again, you might look for robust libraries, etc.
I have 2 dynamic sub-modals that appear/expand within a parent modal. (parent modals or pre-expanded modal doesn't need the below update).
Within two sub modals, that become expanded from parent modal, I am simply trying to add/invoke a 'back button' and 'close button' in the .both_submodals__heading of those two modals; I am achieving this with the below solution, but I hate the fact I am using a set interval, this also creates console errors when the setInterval is checking for the relevant .both_submodals__heading until it's found.
on(footWrap, 'click', function (event) {
let visible = coordinateWidget.visible;
coordinateWidget.visible = !visible;
var checkExists = setInterval(function() {
const coordWid = document.getElementsByClassName('both_submodals__heading')[0];
if (typeof(coordWid) != 'undefined' && coordWid != null) {
const closeBlock = document.getElementById('closeCoord');
const headerTitleTxt = document.getElementsByClassName('esri-widget__heading')[0];
headerTitleTxt.insertAdjacentHTML('afterend', '<div id="closeCoord">X</div>');
on(closeBlock, 'click', function (event) {
let visible = coordinateWidget.visible;
coordinateWidget.visible = !visible;
});
}
}, 100);
});
Via the comment about mutation observers; I found the below solution to be an alternative... but looks like a ton of code.
(function(win) {
'use strict';
var listeners = [],
doc = win.document,
MutationObserver = win.MutationObserver || win.WebKitMutationObserver,
observer;
function ready(selector, fn) {
// Store the selector and callback to be monitored
listeners.push({
selector: selector,
fn: fn
});
if (!observer) {
// Watch for changes in the document
observer = new MutationObserver(check);
observer.observe(doc.documentElement, {
childList: true,
subtree: true
});
}
// Check if the element is currently in the DOM
check();
}
function check() {
// Check the DOM for elements matching a stored selector
for (var i = 0, len = listeners.length, listener, elements; i < len; i++) {
listener = listeners[i];
// Query for elements matching the specified selector
elements = doc.querySelectorAll(listener.selector);
for (var j = 0, jLen = elements.length, element; j < jLen; j++) {
element = elements[j];
// Make sure the callback isn't invoked with the
// same element more than once
if (!element.ready) {
element.ready = true;
// Invoke the callback with the element
listener.fn.call(element, element);
}
}
}
}
// Expose `ready`
win.ready = ready;
})(this);
It's possible what I'm describing is ill-advised with more standard approaches, and if so that would be a valid answer.
Say one is creating their own custom event, but in order for it to work the on() and off() steps need to perform actions on the element.
In my example scenario I want to create a custom jquery event called 'multiclick' that fires when an element is clicked 1, 2, or 3 times within a 500 ms timespan.
My initial idea would be to extend the functionality of on, off, and trigger and add my own event like so:
(function($)
{
var oldOn = $.fn.on;
var oldOff = $.fn.off;
var oldTrigger = $.fn.trigger;
// events [, selector ] [, data ], handler
// events [, selector ] [, data ]
// Note: this does not implement selector or data for multiclick
$.fn.on = function(events, handler)
{
if (events.split(/\s+/).some(function(event) { return event == 'multiclick'; }))
{
$(this).each(function()
{
if (!Array.isArray($(this).data('multiclick-state')))
{
$(this).data('multiclick-state', []);
}
var clicks = 0;
var clicksTimer = null;
var clickHandler = function(e)
{
clicks++;
if (clicks == 1)
{
clicksTimer = setTimeout(function()
{
handler(e, clicks);
clicks = 0;
}, 500);
}
else if (clicks == 3)
{
clearTimeout(clicksTimer);
handler(e, clicks);
clicks = 0;
}
};
$(this).data('multiclick-state').push(
{
handler: handler,
clickHandler: clickHandler
});
$(this).click(clickHandler);
});
arguments[0] = arguments[0].replace('multiclick', '');
}
return oldOn.apply(this, arguments);
};
// events [, selector ] [, handler ]
// events [, selector ]
// events
// Note: this does not implement selector
$.fn.off = function(events, handler)
{
if (events.split(/\s+/).some(function(event) { return event == 'multiclick'; }))
{
$(this).each(function()
{
if (!Array.isArray($(this).data('multiclick-state')))
{
$(this).data('multiclick-state', []);
}
$(this).data('multiclick-state', $(this).data('multiclick-state').filter(function(state)
{
if (handler == undefined || state.handler === handler)
{
$(this).off('click', state.clickHandler);
return false;
}
return true;
}.bind(this)));
});
arguments[0] = arguments[0].replace('multiclick', '');
}
return oldOff.apply(this, arguments);
};
// eventType [, extraParameters ]
// event, [, extraParameters ]
// Note: I wrote this in case trigger is changed to handle multiple events. Currently it does not. Also I don't support the second overloaded signature
$.fn.trigger = function(events)
{
if (typeof events == 'string' && events.split(/\s+/).some(function(event) { return event == 'multiclick'; }))
{
$(this).each(function()
{
if (!Array.isArray($(this).data('multiclick-state')))
{
$(this).data('multiclick-state', []);
}
// This doesn't support disabling bubbling or stopPropogation, would have to think about it. Very ad-hoc.
$(this).data('multiclick-state').forEach(function(data)
{
data.clickHandler();
});
});
arguments[0] = arguments[0].replace('multiclick', '');
}
return oldTrigger.apply(this, arguments);
};
$.fn.multiclick = function(callback)
{
if (arguments.length == 1)
{
return $(this).on('multiclick', callback);
}
else
{
return $(this).trigger('multiclick');
}
return this;
};
}(jQuery));
I'm not really guaranteeing this is the most complete solution, but it shows the basic approach. Then it would be used like a normal jquery event:
function multiclick(e, clicks)
{
console.log(clicks);
}
// $(document).on('multiclick', multiclick);
$(document).multiclick(multiclick);
//$(document).off('multiclick');
//$(document).off('multiclick', multiclick);
jsfiddle example: https://jsfiddle.net/rhsswfho/1/
This all just seems like a lot of code for something that should be fairly straightforward or even built into jquery with helper functions, yet I find none. Not to mention it's somewhat fragile and adds a performance penalty to all on, off, and trigger calls.
What would be the standard approach to doing this, or the more standard approach for handling such a problem?
It is quite straightforward, like you said. You can use the .on() for any kind of event listening. So you could do something like:
$('body').on('takeMeToDinner', function() {
alert('who will pay the bills?');
});
and then trigger that event via
$('body').trigger('takeMeToDinner');
Jquery isn't designed around adding custom operations to on/off events since it's only implementing the DOM events. (Meaning there's no extra code being ran on a per event type basis). Rather than creating a localized solution on the elements this can be accomplished simply by creating a document or body listener like so:
var clicks = 0;
var lastTarget = null;
var clicksTimer = null;
$('body').click(function(e)
{
if (e.target !== lastTarget)
{
clearTimeout(clicksTimer);
lastTarget = e.target;
clicks = 0;
}
clicks++;
if (clicks == 1)
{
clicksTimer = setTimeout(function()
{
$(e.target).trigger('multiclick', clicks);
clicks = 0;
lastTarget = null;
}, 500);
}
else if (clicks == 3)
{
$(e.target).trigger('multiclick', clicks);
clearTimeout(clicksTimer);
clicks = 0;
lastTarget = null;
}
});
Then used like normal:
$('div').on('multiclick', function(e, clicks)
{
console.log(clicks);
});
$('span').on('multiclick', function(e, clicks)
{
console.log(clicks);
});
Example on jsfiddle: http://jsfiddle.net/f35u0fw2/
What's happening for this specific case is the user can click anywhere and then for elements that are listening they'll get the 'multiclick' event after 1, 2, or 3 clicks as expected.
How to wait until some text on a page appear?
I am using this but it does not work:
function waitText() {
if (document.getElementsByTagName('p')[0].innerHTML == "Some Text"){
alert("text appears");
}else{
setTimeout(function() { waitText() }, 1000);
}
}
I would use MutationObserver instead of that dirty checking if you don't need to support legacy browsers.
var target = document.querySelector('p');
// create an observer instance
var observer = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
[].every.call(mutation.addedNodes, function(node) {
if (node.nodeType === 3 && node.textContent === text) { // optionally you can remove nodeType checking if you pass the text inside some element like div etc.
console.log("text appeared");
observer.disconnect();
return false;
}
return true;
});
});
});
observer.observe(target, { childList: true });
There are a few issues with your code,
Firstly, we don't want to repeatedly set intervals - this will cause a cascade and ultimately freeze the thread which is not fun
Second, getElementByTagName is not a method on document. You either have to index into item 0 of the result of document.getElementsByTagName, or use some other lookup such as document.querySelector
So an example of how you might do it follows
function waitForText(element, text, callback, freq) {
if (!element || !callback || typeof text !== 'string')
throw new TypeError('Bad value');
var interval = window.setInterval(test, freq || 200);
function test() {
if (!element.parentNode) // node detached, don't hold onto this
window.clearInterval(interval);
if (element.textContent === text) {
window.clearInterval(interval);
callback.call(element);
}
}
}
Then
// say you want the first <p> in the DOM tree
var elm = document.querySelector('p');
// attach the condition
waitForText(elm, 'some text', () => console.log('Text appears'));
And in the future..
window.setTimeout(() => elm.textContent = 'some text', 6e3);
// wait 6 seconds..
// callback fires
I attach some functionality to DOM elements and want to be able to clear all references when the element is removed from the DOM so it can be garbage collected,
My initial version to detect the removel of an element was this:
var onremove = function(element, callback) {
var destroy = function() {
callback();
element.removeEventListener('DOMNodeRemovedFromDocument', destroy);
};
element.addEventListener('DOMNodeRemovedFromDocument', destroy);
};
Then I read that mutation events were deprecated in favor of MutationObserver. So I tried to port my code. This is what I came up with:
var isDescendant = function(desc, root) {
return !!desc && (desc === root || isDecendant(desc.parentNode, root));
};
var onremove = function(element, callback) {
var observer = new MutationObserver(function(mutations) {
_.forEach(mutations, function(mutation) {
_.forEach(mutation.removedNodes, function(removed) {
if (isDescendant(element, removed)) {
callback();
// allow garbage collection
observer.disconnect();
observer = undefined;
}
});
});
});
observer.observe(document, {
childList: true,
subtree: true
});
};
This looks overly complicated to me (and not very efficient). Am I missing something or is this really the way this is supposed to work?
Actually... yes, there is a more elegant solution :).
What you added looks good and seems to be well optimized. However there is an easier way to know if the node is attached to the DOM or not.
function onRemove(element, onDetachCallback) {
const observer = new MutationObserver(function () {
function isDetached(el) {
if (el.parentNode === document) {
return false;
} else if (el.parentNode === null) {
return true;
} else {
return isDetached(el.parentNode);
}
}
if (isDetached(element)) {
observer.disconnect();
onDetachCallback();
}
})
observer.observe(document, {
childList: true,
subtree: true
});
}
I believe recursion is unnecessary to solve this problem, and that .contains() on the removednodes list is faster then traversing the entire dom for every arbitrary removal. Alternatively, you can define a custom event and apply that to any element you want to watch for the removal of, here's an example using a mutation observer to create a custom "removed" event.
const removedEvent = new Event("removed");
const observer = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation){
mutation.removedNodes.forEach(function(node) {
node.dispatchEvent(removedEvent);
});
});
});
observer.observe(document.documentElement {
childList: true,
subtree: true,
});
// Usage example:
let element = document.getElementById("anyelement");
// The custom event "removed" listener can be added before or after the element is added to the dom
element.addEventListener("removed", function() {
// Do whatever you need for cleaning up but don't disconnect the observer if you have other elements you need to react to the removal of.
console.info("Element removed from DOM.");
});