Are lapsed listeners preventable in javascript?

ぃ、小莉子 提交于 2019-12-12 11:27:01

问题


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?


回答1:


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.)




回答2:


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]
  })
}


来源:https://stackoverflow.com/questions/43758217/are-lapsed-listeners-preventable-in-javascript

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!