1
\$\begingroup\$

I have cleared all my scripting from the use of JQuery, but sometimes I miss a few handy extensions. Now I'm playing with the idea to "extend" document.querySelectorAll with some handy stuff, using the proxy Object. Here's a snippet to demostrate that idea.

The advantage seems that there's no juggling around with this or extending NodeList.prototype etc., the disadvantage may be that using proxy this way can be 'expensive'. Or maybe it's just a bad idea after all.

Thanks in advance for your review.

// -------------------
// the idea to review
// -------------------
const $ = (selector, root = document) => {
  const extensions = {
    toggleClass: (el, className) => el.classList.toggle(className),
    addClass: (el, classNames) => {
      classNames = !(classNames.constructor instanceof Array) 
        ? [classNames] : classNames;
      classNames.forEach(cn => el.classList.add(cn));
    },
    removeClass: (el, classNames) => {
      classNames = !(classNames.constructor instanceof Array) 
        ? [classNames] : classNames;
      classNames.forEach(cn => el.classList.remove(cn));
    },
    attr: (el, key, value) => el.setAttribute(key, value),
    text: (el, value) => el.textContent = value,
    toggleAttr: (el, key, value) =>el.getAttribute(key) 
          ? el.removeAttribute(key) : el.setAttribute(key, value),
    each: (el, callBack) =>
      callBack && callBack instanceof Function && callBack(el),
  };
  const proxyTrap = {
    get: (obj, key) => {
      if (key === "first") {
        return obj[0];
      }
      if (key in obj[0] && obj[0][key] instanceof Function ||
        key in extensions && extensions[key] instanceof Function) {
        obj[key] = (...args) => {
          obj.forEach(el => key in extensions ?
            extensions[key].apply(null, [el].concat(args)) :
            el[key].apply(el, args));
          return toProxy(obj); // <-- chainable, but expensive?
        };
      }
      return obj[key];
    }
  }
  const toProxy = obj => new Proxy(obj, proxyTrap);
  return toProxy(root.querySelectorAll(selector));
};

// ------------
// useless demo
// ------------
if (document.documentMode) {
  alert("Internet explorer is not supported, sorry");
}
const log = (...txt) => {
  const logElem = $("#log")[0];
  logElem.textContent += txt.join("\n") + "\n";
  logElem.scrollTop = logElem.scrollHeight;
};
const createSpan = () => {
  const span = document.createElement("span");
  span.appendChild(document.createTextNode(" HI, i'm added tot this <p>"));
  return span;
}
// native forEach
$("p[data-addtxt]").forEach(el => el.appendChild(createSpan()));

// extensions each/text/toggleClass
$("span")
  .each(el => el.style.backgroundColor = "#eee")
  .text(" --- backgroundColor and text added from $([...]).each")
  .toggleClass("green");

document.addEventListener("click", evt => {
  const origin = evt.target;

  if (origin.nodeName === "BUTTON") {
    if (origin.id == "nothing") {
      // extension toggleAttr
      $("p").toggleAttr("data-nada", "Nothing");
      log("Toggled data-nada");
    }
    if (origin.id === "blue") {
      // native setAttribute
      const isBlue = origin.style.color === "blue";
      $("[data-maybeblue]").setAttribute("style", 
          isBlue ? "color:back" : "color:blue");
      origin.textContent = isBlue ? "Make me blue" : "Make me black";
      log(`it's ${isBlue ? "black" : "blue"}`);
    }
    if (!origin.id) {
      // extension toggleClass
      $("p").toggleClass("red"); // extension toggleClass
      log(`Toggled color by class`);
      origin.textContent = `Toggle all <p> ${
            $("p").first.classList.contains("red") ? "black" : "red"}`;
    }
  }
});
body {
  font: 12px/15px normal verdana, arial;
  margin: 0.5rem;
}

.red {
  color: red;
}

.green {
  color: green;
}

[data-nada]:before {
  content: attr(data-nada)' ';
  color: green;
  font-weight: bold;
}

#log {
  max-height: 200px;
  overflow: auto;
}

button {
  margin: 0.4rem 0;
}
<p>Hi</p>
<p>There</p>
<p>How are you?</p>
<p data-addtxt>text1</p>
<p data-addtxt>text2</p>
<button>Toggle all &lt;p> red</button> (extension toggleClass)<br>
<button id="nothing">Toggle all &lt;p> attribute</button> (extension toggleAttr)<br>
<button id="blue" data-maybeblue>Make me blue</button> (native setAttribute)
<pre id="log" data-maybeblue></pre>

\$\endgroup\$
  • \$\begingroup\$ Please do not update the code in your question to incorporate feedback from answers, doing so goes against the Question + Answer style of Code Review. This is not a forum where you should keep the most updated version in your question. Please see what you may and may not do after receiving answers. \$\endgroup\$ – Mast May 19 at 8:14
1
\$\begingroup\$

Proxies are weird and slow. Their main useful purpose in real code is when a bad script that you don't have control over is calling your code in an undesirable manner, and you need to intercept property access / function calls / etc so as to run custom code rather than simply returning the value or calling the function. (I suppose proxies are also arguably useful in esoteric Javascript trivia quizzes.)

But they're there as a workaround for a bad situation, not as a solution-of-choice. Their behavior is somewhat unintuitive and hard-to-understand at a glance.

If you want to include both your custom methods and built-in methods, consider creating a class which has your custom methods, and then iterate over the built-in methods and assign them as methods to the class too:

class CustomCollection {
  constructor(selector, root = document) {
    const elements = root.querySelectorAll(selector);
    Object.assign(this, elements);
    this.length = elements.length;
  }
  _iterate(callback, ...possibleArgs) {
    for (let i = 0; i < this.length; i++) {
      callback(this[i]);
    }
    return this;
  }
  toggleClass(className) {
    this._iterate(el => el.classList.toggle(className));
  }
  // etc - other custom methods that reference _iterate
}
// Assign methods on Element.prototype to CustomCollection.prototype:
const descriptors = Object.getOwnPropertyDescriptors(Element.prototype);
for (const [key, { value }] of Object.entries(descriptors)) {
  // Only assign plain functions (don't invoke getters)
  if (typeof value !== 'function') {
    continue;
  }
  CustomCollection.prototype[key] = function(...args) {
    for (let i = 0; i < this.length; i++) {
      value.apply(this[i], args);
    }
    return this;
  };
}

A class is arguably the right tool for the job here because you want to create collections of persistent elements (data) associated with methods which operate on those elements. So, don't be afraid of using this - in a class method, it'll refer to the current instance, which is just what you'll need in order to reference the elements on the instance.

It's quite possible to write this without classes and this, but I think the class approach is most understandable at a glance.

Other notes:

In order to emulate the iteration over collections (and like jQuery does it), I assigned the elements of the collection to the instance in the constructor, as you can see above. Since all the methods need to iterate over the selected elements, call a function whose first argument is the element, and finally return the collection itself (so that the methods are chainable), I made a _iterate method for that.

Regarding

addClass: (el, classNames) => {
  classNames = !(classNames.constructor instanceof Array) 
    ? [classNames] : classNames;
  classNames.forEach(cn => el.classList.add(cn));
},

This logic is incorrect. To check whether an expression is an array, the standard method to use is Array.isArray:

const classNamesArr = Array.isArray(classNames) ? classNames : [classNames];

The constructor property will be a function, which will never be an instance of an array:

const something = [];
console.log(something.constructor instanceof Array);

Also, regarding the above, It's good to avoid reassigning variables when not necessary - code is more readable when you know, at a particular point where a variable is declared, the variable will always contain that value where it's declared. (see linting rule no-param-reassign) That's why I declared the classNamesArr rather than reassigning the classNames parameter.

You have

span.appendChild(document.createTextNode(" HI, i'm added tot this <p>"));

When you want to set the text content of an element which starts out empty, it's less verbose to simply assign to the textContent property:

span.textContent = " HI, i'm added tot this <p>";

Live snippet:

const $ = (() => {
  // Create a persistent CustomCollection class in this closure:
  class CustomCollection {
    constructor(selector, root = document, elements = root.querySelectorAll(selector)) {
      Object.assign(this, elements);
      this.length = elements.length;
    }
    _iterate(callback, ...possibleArgs) {
      for (let i = 0; i < this.length; i++) {
        callback(this[i]);
      }
      return this;
    }
    toggleClass(className) {
      this._iterate(el => el.classList.toggle(className));
    }
    addClass(classNames) {
      const classNamesArr = Array.isArray(classNames) ? classNames : [classNames];
      return this._iterate((el) => {
        classNamesArr.forEach(cn => el.classList.add(cn));
      });
    }
    removeClass(classNames) {
      const classNamesArr = Array.isArray(classNames) ? classNames : [classNames];
      return this._iterate((el) => {
        classNamesArr.forEach(cn => el.classList.delete(cn));
      });
    }
    attr(key, value) {
      return this._iterate(el => el.setAttribute(key, value));
    }
    text(value) {
      return this._iterate(el => el.textContent = value);
    }
    toggleAttr(key, value) {
      return this._iterate((el) => {
        el[el.hasAttribute(key) ? 'removeAttribute' : 'setAttribute'](key, value);
      });
    }
    each(callback) {
      return this._iterate(callback)
    }
    // duplicate method with new name - kinda weird
    forEach(callback) {
      return this._iterate(callback)
    }
    get first() {
      return this[0];
    }
  }
  // Assign functions on Element.prototype to CustomCollection.prototype:
  const descriptors = Object.getOwnPropertyDescriptors(Element.prototype);
  for (const [key, { value }] of Object.entries(descriptors)) {
    // Only assign plain functions (don't invoke getters)
    if (typeof value !== 'function') {
      continue;
    }
    CustomCollection.prototype[key] = function(...args) {
      for (let i = 0; i < this.length; i++) {
        value.apply(this[i], args);
      }
      return this;
    };
  }
  return (...args) => new CustomCollection(...args);
})();


if (document.documentMode) {
  alert("Internet explorer is not supported, sorry");
}
const log = (...txt) => {
  const logElem = $("#log")[0];
  logElem.textContent += txt.join("\n") + "\n";
  logElem.scrollTop = logElem.scrollHeight;
};
const createSpan = () => {
  const span = document.createElement("span");
  span.textContent = " HI, i'm added tot this <p>";
  return span;
}
// native forEach
$("p[data-addtxt]").forEach(el => el.appendChild(createSpan()));

// extensions each/text/toggleClass
$("span")
  .each(el => el.style.backgroundColor = "#eee")
  .text(" --- backgroundColor added from $([...]).each")
  .toggleClass("green");

document.addEventListener("click", evt => {
  const origin = evt.target;

  if (origin.nodeName === "BUTTON") {
    if (origin.id == "nothing") {
      // extension toggleAttr
      $("p").toggleAttr("data-nada", "Nothing");
      log("Toggled data-nada");
    }
    if (origin.id === "blue") {
      // native setAttribute
      const isBlue = origin.style.color === "blue";
      $("[data-maybeblue]").setAttribute("style", isBlue ? "color:back" : "color:blue");
      origin.textContent = isBlue ? "Make me blue" : "Make me black";
      log(`it's ${isBlue ? "black" : "blue"}`);
    }
    if (!origin.id) {
      // extension toggleClass
      $("p").toggleClass("red"); // extension toggleClass
      log(`Toggled color by class`);
      origin.textContent = `Toggle all <p> ${
            $("p").first.classList.contains("red") ? "black" : "red"}`;
    }
  }
});
body {
  font: 12px/15px normal verdana, arial;
  margin: 0.5rem;
}

.red {
  color: red;
}

.green {
  color: green;
}

[data-nada]:before {
  content: attr(data-nada)' ';
  color: green;
  font-weight: bold;
}

#log {
  max-height: 200px;
  overflow: auto;
}

button {
  margin: 0.4rem 0;
}
<p>Hi</p>
<p>There</p>
<p>How are you?</p>
<p data-addtxt>text1</p>
<p data-addtxt>text2</p>
<button>Toggle all &lt;p> red</button> (extension toggleClass)<br>
<button id="nothing">Toggle all &lt;p> attribute</button> (extension toggleAttr)<br>
<button id="blue" data-maybeblue>Make me blue</button> (native setAttribute)
<pre id="log" data-maybeblue></pre>

| improve this answer | |
\$\endgroup\$
  • \$\begingroup\$ Hi @CertainPerformance, thanks for your comprehensive answer, +1 for that. It's a bit opinionated imho. Not everyone thinks proxies are weird (slow should be tested). The idea of my code was in particalar to avoid this etc. Call me old fashioned ( I started using javascript - well Mochascript in the days - in 1995), but I never use the class sugar of modern js, or better said, never felt the need to use it. Still, your code is certainly a viable alternative. \$\endgroup\$ – KooiInc May 19 at 6:31
  • \$\begingroup\$ The constructor instanceof-code was a remnant of older code. I'll correct it. Array.isArray is a perfect suggestion here. Concerning your code: el.classList.delete would throw a TypeError, it should be el.classList.remove. \$\endgroup\$ – KooiInc May 19 at 6:37
0
\$\begingroup\$

Let me add my own review after some tinkering with my and CertainPerformances code. I think it may be viable to use a proxy, but still am not sure if it has consequences for performance. It's pretty simple and concise to code. One consequence is that it's a restricting solution: you can't use it (the same way as jQuery does) for non existing elements (e.g. $(".IDoNotExist").each(el => {}) would throw). That may also be a blessing if you like such restrictiveness.

The CustomCollection-class offered by CertainPerformance is excellent and also very usable. I hold a bit of a grudge to the class sugar that is added to Ecmascript, probably because (having programmed JS/ES from the start of it) I always liked the elegance an simplicity of its prototypal nature of it and didn't feel the need for classes.

So here's a more 'classic' approach for extending a NodeList like jQuery.

if (document.documentMode) {
  alert("Internet explorer is not supported, sorry");
  throw new InternalError("No IE here");
}

const $ = (() => {
  const extensions = {
    toggleClass(className) {
      this.forEach(el => el.classList.toggle(className));
    },
    addClass(classNames) {
      this.forEach(el => {
        (Array.isArray(classNames) ? classNames : [classNames])
        .forEach(cn => el.classList.add(cn));
      });
    },
    removeClass(classNames) {
      this.forEach(el => {
        (Array.isArray(classNames) ? classNames : [classNames])
        .forEach(cn => el.classList.remove(cn));
      });
    },
    attr(key, value) {
      this.setAttribute(key, value);
    },
    text(value) {
      this.forEach(el => el.textContent = value);
    },
    html(value) {
      this.forEach(el => el.innerHTML = value);
    },
    toggleAttr(key, value) {
      this.forEach(el => {
        el[el.hasAttribute(key) ? 'removeAttribute' : 'setAttribute'](key, value);
      });
    },
    each(callback) {
      this.forEach(callback);
    },
  };

  function ExtendedNodeListCollection(selector, root = document) {
    this.collection = root.querySelectorAll(selector);
    this.first = this.collection[0];

    if (ExtendedNodeListCollection.prototype.isSet === undefined) {

      Object.entries(Object.getOwnPropertyDescriptors(Element.prototype))
        .forEach(([key, {
          value
        }]) => {
          if (value instanceof Function) {
            ExtendedNodeListCollection.prototype[key] = function(...args) {
              this.collection.forEach(elem => value.apply(elem, args));
              return this;
            }
          };
        });

      Object.entries(Object.getOwnPropertyDescriptors(NodeList.prototype))
        .forEach(([key, {
          value
        }]) => {
          if (value instanceof Function) {
            ExtendedNodeListCollection.prototype[key] = function(callBack) {
              this.collection[key](callBack);
              return this;
            };
          }
        });

      Object.entries(extensions)
        .forEach(([key, value]) => {
          ExtendedNodeListCollection.prototype[key] = function(...args) {
            value.apply(this, args);
            return this;
          }
        });

      ExtendedNodeListCollection.prototype.isSet = true;
    }
  }

  return (...args) => new ExtendedNodeListCollection(...args);
})();

//-----DEMO
const logElem = $("#log").first;
const log = (...txt) => {
  logElem.textContent += txt.join("\n") + "\n";
  logElem.scrollTop = logElem.scrollHeight;
};

log(`${new Date().toLocaleString()} $ initialized`);

// native forEach
$("p[data-addtxt]").forEach(el => el.appendChild(document.createElement("span")));

// extensions each/text/toggleClass
$("span")
  .each(el => el.style.backgroundColor = "#eee")
  .text(" --- backgroundColor and text added from $([...]).each")
  .toggleClass("green");

// will silently be eaten, no error
$(".notExisting").toggleClass("red"); 

document.addEventListener("click", evt => {
  const origin = evt.target;

  if (origin.nodeName === "BUTTON") {
    if (origin.id == "nothing") {
      // extension toggleAttr
      $("p").toggleAttr("data-nada", "Nothing");
      log("Toggled data-nada");
    }
    if (origin.id === "blue") {
      // native setAttribute
      const isBlue = origin.style.color === "blue";
      $("[data-maybeblue]").setAttribute("style", isBlue 
        ? "color:back" 
        : "color:blue");
      origin.textContent = isBlue ? "Make us blue" : "Make us black";
      log(`We are ${isBlue ? "black" : "blue"}`);
    }
    if (!origin.id) {
      // extension toggleClass
      $("p").toggleClass("red"); // extension toggleClass
      log(`Toggled color by class`);
      origin.textContent = `Toggle all <p> ${
          $("p").first.classList.contains("red") ? "black" : "red"}`;
    }
  }
});
body {
  font: 12px/15px normal verdana, arial;
  margin: 2rem;
}

.red {
  color: red;
}

.green {
  color: green;
}

[data-nada]:before {
  content: attr(data-nada)' ';
  color: green;
  font-weight: bold;
}

#log {
  max-height: 200px;
  overflow: auto;
}

button {
  margin: 0.4rem 0;
}
<p>Hi</p>
<p>There</p>
<p>How are you?</p>
<p data-addtxt>text1</p>
<p data-addtxt>text2</p>
<button>Toggle all &lt;p> red</button> (extension toggleClass)<br>
<button id="nothing">Toggle all &lt;p> attribute</button> (extension toggleAttr)<br>
<button id="blue" data-maybeblue>Make us blue</button> (native setAttribute)
<pre id="log" data-maybeblue></pre>

| improve this answer | |
\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.