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.
Related
I wanna be able to observe the body element for changes in its class attribute.
I found this code online:
var elemToObserve = document.getElementById('your_elem_id');
var prevClassState = elemToObserve.classList.contains('your_class');
var observer = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
if(mutation.attributeName == "class"){
var currentClassState = mutation.target.classList.contains('your_class');
if(prevClassState !== currentClassState) {
prevClassState = currentClassState;
if(currentClassState)
console.log("class added!");
else
console.log("class removed!");
}
}
});
});
observer.observe(elemToObserve, {attributes: true});
And I didn't really understand what's going on with the prevClassState. Thanks in advance.
MutationObserver itself detects mutations but in your case it does not really tell whether specific class added or removed.
In your example the logic is simple. Before you attach mutation listener to the element you find out if the element already has such class name. After that when mutation happens (either added or removed) you again follow the same logic and check if the class name is there or not. If the element had a value and after mutation not there - its clear that class has been added. To keep this approach working you have to reset the first variable by checking class name again and assign the state to it.
I thought it would be more convenient to write implementation for it.
You have classObserver function with three parameters: the element you observing, observable class name and a callback function which provides you state of the class alongside mutation object and observer.
Also, there is an Object prototype and you can use to chain it with the element itself.
Check out the examples below.
/**
* #param {Element} targetElement
* #param {String} observableClassName
* #param {Function} callback
*/
function classObserver(targetElement, observableClassName, callback){
const ref = {prevStateHasClass: targetElement.classList.contains(observableClassName)};
return new MutationObserver(function(mutations, observer) {
// Use traditional 'for loops' for IE 11
for(const mutation of mutations) {
if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
const currentStateHasClass = mutation.target.classList.contains(observableClassName);
if(ref.prevStateHasClass !== currentStateHasClass){
ref.prevStateHasClass = currentStateHasClass;
const classAdded = currentStateHasClass;
callback.call(classAdded, classAdded, mutation, observer);
}
}
}
}).observe(targetElement, { attributes: true });
};
const button1 = document.getElementById('btn-1');
// for demo, mutate classList
button1.onclick = () => button1.classList.toggle('my-class-observe');
// usage
classObserver(button1, 'my-class-observe', function(classAdded, mutation, observer){
// You canalso use `this` instead of `classAdded`
console.log('Change detected. Class ', classAdded? 'added': 'removed');
// console.log(classAdded, mutation, observer);
// do disconnect if needed
// observer.disconnect();
});
Object.prototype.observeClassMutation = function(observableClassName, callback){
return classObserver(this, observableClassName, callback);
};
const button2 = document.getElementById('btn-2');
// Just for demo
button2.onclick = () => button2.classList.toggle('target-class-2-observe');
// usage
button2.observeClassMutation('target-class-2-observe', function(classAdded, mutation, observer){
console.log('Change detected. Class ', classAdded? 'added': 'removed');
});
<button id="btn-1" class="one two">Click me</button>
<button id="btn-2" class="one two target-class-2-observe">Click me - proto</button>
`
class UpdateClass {
constructor(){
this.callbacks=[];
}
UpdateClass() {
document.querySelector('body').classList.add('new-class');
document.querySelector('button.red').addEventListener('click', () => {
this.callbacks.forEach(callback => callback('red'))
});
document.querySelector('button.green').addEventListener('click', () => {
this.callbacks.forEach(callback => callback('green'))
});
this.callbacks.forEach(callback => callback('new-class'))
}
subscribeToClassChange(callback){
this.callbacks.push(callback);
}
}
// start of module where you want to observe the change
const updateClassObj = new UpdateClass();
updateClassObj.subscribeToClassChange(handleClassChange);
updateClassObj.UpdateClass();
function handleClassChange(className) {
// runs every time there is a class change
console.log(className)
}
.red {
color: red;
}
.green {
color: green
}
<body>
<button class='red'>update class to red</button>
<button class='green'>update class to green</button>
</body>
`I would suggest implementing a method to subscribe to the class change from where the class being updated.
class UpdateClass {
constructor(){
const callbacks=[];
}
UpdateClass() {
document.querySelector('body').classList.add('new-class');
this.callbacks.forEach(callback => callback('new-class'))
}
subscribeToClassChange(callback){
this.callbacks.push(callback);
}
}
// start of module where you want to observe the change
const updateClassObj = new UpdateClass();
updateClassObj.subscribe(handleClassChange));
updateClassObj.UpdateClass();
function handleClassChange(className) {
// runs every time there is a class change
console.log(className)
}
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 !
My question is really "Is the lapsed listener problem preventable in javascript?" but apparently the word "problem" causes a problem.
The wikipedia page says the lapsed listener problem can be solved by the subject holding weak references to the observers. I've implemented that before in Java and it works nicely, and I thought I'd implement it in Javascript, but now I don't see how. Does javascript even have weak references? I see there are WeakSet and WeakMap which have "Weak" in their names, but they don't seem to be helpful for this, as far as I can see.
Here's a jsfiddle showing a typical case of the problem.
The html:
<div id="theCurrentValueDiv">current value: false</div>
<button id="thePlusButton">+</button>
The javascript:
'use strict';
console.log("starting");
let createListenableValue = function(initialValue) {
let value = initialValue;
let listeners = [];
return {
// Get the current value.
get: function() {
return value;
},
// Set the value to newValue, and call listener()
// for each listener that has been added using addListener().
set: function(newValue) {
value = newValue;
for (let listener of listeners) {
listener();
}
},
// Add a listener that set(newValue) will call with no args
// after setting value to newValue.
addListener: function(listener) {
listeners.push(listener);
console.log("and now there "+(listeners.length==1?"is":"are")+" "+listeners.length+" listener"+(listeners.length===1?"":"s"));
},
};
}; // createListenable
let theListenableValue = createListenableValue(false);
theListenableValue.addListener(function() {
console.log(" label got value change to "+theListenableValue.get());
document.getElementById("theCurrentValueDiv").innerHTML = "current value: "+theListenableValue.get();
});
let nextControllerId = 0;
let thePlusButton = document.getElementById("thePlusButton");
thePlusButton.addEventListener('click', function() {
let thisControllerId = nextControllerId++;
let anotherDiv = document.createElement('div');
anotherDiv.innerHTML = '<button>x</button><input type="checkbox"> controller '+thisControllerId;
let [xButton, valueCheckbox] = anotherDiv.children;
valueCheckbox.checked = theListenableValue.get();
valueCheckbox.addEventListener('change', function() {
theListenableValue.set(valueCheckbox.checked);
});
theListenableValue.addListener(function() {
console.log(" controller "+thisControllerId+" got value change to "+theListenableValue.get());
valueCheckbox.checked = theListenableValue.get();
});
xButton.addEventListener('click', function() {
anotherDiv.parentNode.removeChild(anotherDiv);
// Oh no! Our listener on theListenableValue has now lapsed;
// it will keep getting called and updating the checkbox that is no longer
// in the DOM, and it will keep the checkbox object from ever being GCed.
});
document.body.insertBefore(anotherDiv, thePlusButton);
});
In this fiddle, the observable state is a boolean value, and you can add and remove checkboxes that view and control it, all kept in sync by listeners on it.
The problem is that when you remove one of the controllers, its listener doesn't go away: the listener keeps getting called and updating the controller checkbox and prevents the checkbox from being GCed, even though the checkbox is no longer in the DOM and is otherwise GCable. You can see this happening in the javascript console since the listener callback prints a message to the console.
What I'd like instead is for the controller DOM node and its associated value listener to become GCable when I remove the node from the DOM. Conceptually, the DOM node should own the listener, and the observable should hold a weak reference to the listener. Is there a clean way to accomplish that?
I know I can fix the problem in my fiddle by making the x button explicitly remove the listener along with the DOM subtree, but that doesn't help in the case that some other code in the app later removes part of the DOM containing my controller node, e.g. by executing document.body.innerHTML = ''. I'd like set things up so that, when that happens, all the DOM nodes and listeners I created get released and become GCable. Is there a way?
Custom_elements offer a solution to the lapsed listener problem. They are supported in Chrome and Safari and (as of Aug 2018), are soon to be supported on Firefox and Edge.
I did a jsfiddle with HTML:
<div id="theCurrentValue">current value: false</div>
<button id="thePlusButton">+</button>
And a slightly modified listenableValue, which now has the ability to remove a listener:
"use strict";
function createListenableValue(initialValue) {
let value = initialValue;
const listeners = [];
return {
get() { // Get the current value.
return value;
},
set(newValue) { // Set the value to newValue, and call all listeners.
value = newValue;
for (const listener of listeners) {
listener();
}
},
addListener(listener) { // Add a listener function to call on set()
listeners.push(listener);
console.log("add: listener count now: " + listeners.length);
return () => { // Function to undo the addListener
const index = listeners.indexOf(listener);
if (index !== -1) {
listeners.splice(index, 1);
}
console.log("remove: listener count now: " + listeners.length);
};
}
};
};
const listenableValue = createListenableValue(false);
listenableValue.addListener(() => {
console.log("label got value change to " + listenableValue.get());
document.getElementById("theCurrentValue").innerHTML
= "current value: " + listenableValue.get();
});
let nextControllerId = 0;
We can now define a custom HTML element <my-control>:
customElements.define("my-control", class extends HTMLElement {
constructor() {
super();
}
connectedCallback() {
const n = nextControllerId++;
console.log("Custom element " + n + " added to page.");
this.innerHTML =
"<button>x</button><input type=\"checkbox\"> controller "
+ n;
this.style.display = "block";
const [xButton, valueCheckbox] = this.children;
xButton.addEventListener("click", () => {
this.parentNode.removeChild(this);
});
valueCheckbox.checked = listenableValue.get();
valueCheckbox.addEventListener("change", () => {
listenableValue.set(valueCheckbox.checked);
});
this._removeListener = listenableValue.addListener(() => {
console.log("controller " + n + " got value change to "
+ listenableValue.get());
valueCheckbox.checked = listenableValue.get();
});
}
disconnectedCallback() {
console.log("Custom element removed from page.");
this._removeListener();
}
});
The key point here is that disconnectedCallback() is guaranteed to be called when the <my-control> is removed from the DOM whatever reason. We use it to remove the listener.
You can now add the first <my-control> with:
const plusButton = document.getElementById("thePlusButton");
plusButton.addEventListener("click", () => {
const myControl = document.createElement("my-control");
document.body.insertBefore(myControl, plusButton);
});
(This answer occurred to me while I was watching this video, where the speaker explains other reasons why custom elements could be useful.)
You can use mutation observers which
provides the ability to watch for changes being made to the DOM tree. It is designed as a replacement for the older Mutation Events feature which was part of the DOM3 Events specification.
An example of how this can be used can be found in the code for on-load
if (window && window.MutationObserver) {
var observer = new MutationObserver(function (mutations) {
if (Object.keys(watch).length < 1) return
for (var i = 0; i < mutations.length; i++) {
if (mutations[i].attributeName === KEY_ATTR) {
eachAttr(mutations[i], turnon, turnoff)
continue
}
eachMutation(mutations[i].removedNodes, function (index, el) {
if (!document.documentElement.contains(el)) turnoff(index, el)
})
eachMutation(mutations[i].addedNodes, function (index, el) {
if (document.documentElement.contains(el)) turnon(index, el)
})
}
})
observer.observe(document.documentElement, {
childList: true,
subtree: true,
attributes: true,
attributeOldValue: true,
attributeFilter: [KEY_ATTR]
})
}
I have a MutationObserver that I'm using like so—
var config = {
attributes: false,
childList: true,
characterData: false,
subtree: true
};
var observer = new MutationObserver(function(mutations) {
//need to call function1() only when nodes are ADDED, not removed
});
var startObserving = function () {
target = document.getElementsByClassName("message-container")[0];
observer.observe(target, config);
}
I need to both add and remove elements to/from the container that the MutationObserver is watching, but I only want to execute function1() when nodes are added. Is there a way to do this? I've been reading the MDN article but can't think of a way to do this. Any help would be appreciated!
You should be able to check the addedNodes property on each of the mutations object to determine if elements were added. You’ll probably also want to validate that the type is childList.
Check out the MutationRecord page on the MDN.
Something like
var observer = new MutationObserver(function(mutations) {
var hasUpdates = false;
for (var index = 0; index < mutations.length; index++) {
var mutation = mutations[index];
if (mutation.type === 'childList' && mutation.addedNodes.length) {
hasUpdates = true;
break;
}
}
if (hasUpdates) {
function1();
}
});
I prefer do this using observer options childList and subtree. Then observer filtering changes
const observer = new MutationObserver((mutations) => {
// do something
});
observer.observe(node, {
childList: true, // detecting childList changes
subtree: true // detecing in childs
});
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.");
});