2

I'm trying to replicate jQuery's element manipulation to a certain extent. Now what I have found to be very useful is the .first() selector. I would like to be able to chain functions like this;
getElement(selector).first().hasClass(className);

Now, there are 2 issues with how far I've gotten (Do note that my code example is minimised, so please, no comments about error-handling.)

var getElement = function(selector, parent) {
  ret           = document.querySelectorAll(selector);
  this.element  = ret;
  this.hasClass = function(className) {
    className.replace('.', '');
    if(this.multiple())
    {
      console.log('Cannot use hasClass function on multiple elements');
      return false;
    }
  };

  this.first = function() {
    this.element = this.element[0];
    return this;
  };
  return this;
};

My current problem

If I call my function;

var $test = getElement('.something'); //result: nodelist with .something divs

If I call for the first element within the result;

$test.first(); //Result: First div, perfect!

However, now if I call $test again, it will replace the elements property with the result of first(), meaning I have "lost" my old values. I don't want to lose them, I only want the first() functions for that specific functionality. Then I want $test to return all elements again. Also, recalling first() will now end up undefined, since there is only 1 element left within this as it has deleted the old elements from within the object.

Another attempt

Now, I've also tried to turn it around a bit by returning the first-child instead of the entire class object;

this.first = function() {
  return this.element[0];
};

However, I will $test.first().hasClass(className); //Returns ERROR, method hasClass undefined

this is because .hasClass exists on the original this, which doesn't get returned anymore since I'm now returning the element.

I have tried to get something out of jQuery's library, though that just confused me more...

I have googled this subject, but with all the 'chaining methods' solutions I'm finding, all of them seem to be overwriting the original values of the object, which is not what I want to happen. One other solution actually required me to re-initiate the object over and over again, which did not seem very efficient to me... Any help is appreciated. I'm assuming I'm going about this completely the wrong way.

-- If you can help me, please do explain why your solution works. I really feel like if I understand this, my understanding of javascript can expand a lot further. I just need to get past this structural(?) issue.

NoobishPro
  • 2,539
  • 1
  • 12
  • 23
  • 2
    Don't return an element (neither does jQuery), wrap it in your own class that has the functions and works on it's own context. Also note, your this is in my opinion very ambiguous there as I don't see you using a `new getElement`, which would indicate your `this` is well, not what you are expecting it to be – Icepickle Jan 24 '18 at 22:28
  • Check out my [answer](https://stackoverflow.com/a/48433222/6313073#48433222) to see a way to accomplish what you want, similar to that of `jQuery` @Babydead. – Angel Politis Jan 24 '18 at 23:22
  • @Icepickle thanks, but I do initiate it as new. However, I used a trick to do that within the function (which I've minimised the code of, for the purpose of posting it here). AngelPolitis Checking your answer out now. – NoobishPro Jan 24 '18 at 23:43
  • @Babydead from your response to Angels answer, it would seem that you are not using new, as you get the window back for `this` – Icepickle Jan 25 '18 at 10:02
  • @icepickle yeah, I got window back if I forgot to do the new thing. Fixed that already, but I didn't understand how or what the trigger for it was ahahaha. Thank you. – NoobishPro Jan 25 '18 at 11:35

6 Answers6

2

this in your outer function refers to the window / global object.

Instead, return the ret variable itself.

In the inner functions (which become the object's methods), this acts the way you expect it to.

Here's an alternative solution, which allows chaining, even after you've called the first method:

var getElement = function(selector, parent) {
  var ret = typeof selector == 'string' ? document.querySelectorAll(selector)
                                        : selector;

  ret.hasClass = function(className) {
    if(!this.classList) {
      console.log('Cannot use hasClass function on multiple elements');
      return false;
    } else {
      return this.classList.contains(className);
    }
  };

  ret.first = function() {
    return new getElement(this[0]);
  };
  
  return ret;
};

console.log(getElement('p').length);                   //2
console.log(getElement('p').first().innerHTML);        //abc
console.log(getElement('p').first().hasClass('test')); //true
console.log(getElement('p').first().hasClass('nope')); //fase
console.log(getElement('p').hasClass('test'));         //false (multiple elements)
<p class="test">
  abc
</p>

<p>
  def
</p>
Rick Hitchcock
  • 35,202
  • 5
  • 48
  • 79
  • This explains why I got the entire window back when debugging it in specific situations, thanks! I will a accept Barmar's answer first since it's been the first answer, but you get an upvote for very good explanation and simply nice work, yeah? Thank you so much! – NoobishPro Jan 24 '18 at 23:55
2

A method like first() should not modify this, it should create a new object and return that. You only use return this; in methods that modify an element rather than returning information derived from the element.

this.first = function() {
    return new getElement(this.element[0]);
};

And note that you have to use new getElement to create an object, not just getElement.

This also requires a change to the constructor, so it can accept either a selector string or an element:

var getElement = function(selector, parent) {
    var ret = typeof selector == "string" ? document.querySelectorAll(selector) : [selector];
    ...
}

You should also consider doing this in proper OO fashion, by putting the methods in a prototype, rather than defining them in every object.

var getElement = function(selector, parent) {
  var ret = typeof selector == "string" ? document.querySelectorAll(selector) : [selector];
  this.element = ret;
};

getElement.prototype.hasClass = function(className) {
  className.replace('.', '');
  if (this.multiple()) {
    console.log('Cannot use hasClass function on multiple elements');
    return false;
  }
};

getElement.prototype.first = function() {
  return new getElement(this.element[0])
};
Barmar
  • 741,623
  • 53
  • 500
  • 612
  • Thank you so much! Very simple explanation and a simple answer as well. -- I did try it with a prototype first, actually. It didn't work out but I'm going to try it again now that I understand it a bit better. Could you give an example of how to do that, maybe? Or have a good link for it? – NoobishPro Jan 24 '18 at 23:58
  • You should also take a look at ES6 `class` syntax. – Barmar Jan 25 '18 at 00:03
  • yeah that's pretty much what I had. It must have not worked because the rest was so wrong. I am going to try this since initiating new functions for every object does seem to be wildly inefficient (logically). I have no experience with ES6 yet, but I will go deeper into it now. Thank you so much! I will replace the this.functions with prototypes tomorrow! – NoobishPro Jan 25 '18 at 00:31
  • I don't think that `new getElement(this.elements[0])` will work. `document.querySelectorAll()` takes a string argument, but it will be passed a DOM node. – Rick Hitchcock Jan 25 '18 at 14:34
  • 1
    @RickHitchcock You are correct. But I have already caught this possibility within my function. `if(typeof selector != 'string') { this.element = selector; }` -- However, it would be neat for Barmar to add this to his answer for any other viewers. – NoobishPro Jan 25 '18 at 14:37
  • Actually, it should be `this.element = [selector];`, because the rest of the code expects `this.element` to be a collection that can be indexed (it probably should be named `elements` to make it clearer). I've updated my answer to show this. – Barmar Jan 25 '18 at 20:10
1

I think the easiest would be to return a new class that contains the nodes you have selected. That would be the easiest solution, as you don't really want to mutate any of your previous selectors.

I made a small example, using some ES6 that makes a few things easier to work with, which also has a $ to initiate the selections being made.

You would notice that first of all, any selection that is made, is just calling the native document.querySelectorAll but returns a new Node class. Both first and last methods also return those elements.

Lastly, hasClass should work on all elements in the current nodes selections, so it will iterate the current node, and check all classes in there, this one returns a simple bool, so you cannot continue with the method chaining there.

Any method you wish to chain, should either:

  • return this object (the current node)
  • return an element of the this object as a new node so any further manipulations can be done there

const $ = (function(global) {
  class Node extends Array {
    constructor( ...nodes ) {
      super();
      nodes.forEach( (node, key) => {
        this[key] = node;
      });
      this.length = nodes.length;
    }
    first() {
      return new Node( this[0] );
    }
    last() {
      return new Node( this[this.length-1] );
    }
    hasClass( ...classes ) {
      const set = classes.reduce( (current, cls) => {
          current[cls] = true;
          return current;
        }, {} );
      for (let el of this) {
        for (let cls of el.classList) {
          if (set[cls]) {
            return true;
          }
        }
      }
      return false;
    }
  }
  global.$ = function( selector ) {
    return new Node( ...document.querySelectorAll( selector ) );
  };
  
  return global.$;
}(window));

let selector = $('.foo');
let first = selector.first(); // expect 1
console.log(first[0].innerHTML);
let last = selector.last();
console.log(last[0].innerHTML); // expect 4

console.log( first.hasClass('foo') ); // expect true
console.log( first.hasClass('bar') ); // expect false
console.log( selector.hasClass('foo') ); // expect true
console.log( selector.hasClass('bar') ); // expect true
<div class="foo">1</div>
<div class="foo">2</div>
<div class="foo bar">3</div>
<div class="foo">4</div>
Angel Politis
  • 10,955
  • 14
  • 48
  • 66
Icepickle
  • 12,689
  • 3
  • 34
  • 48
  • I'm sorry, I honestly do not understand one bit of this code. I'm pretty sure it's an awesome piece of code, but I do not understand it at all... – NoobishPro Jan 24 '18 at 23:57
  • @Babydead it's just an IIFE that creates it's own scope for the declaration of the `Node` class, and the rest is a bit of ES6 and ES7 with the spread operator. The has class might be overly complex but it would account for multiple parameters and checks all the classes that are in the current `Node` – Icepickle Jan 25 '18 at 07:26
1

Here is how I would approach this:

  1. Create a constructor, say Search, tasked to find the elements based on the input. Using a constructor is proper OO Programming and you also have the advantage of defining methods once in the prototype and they can be accessed by all instances.
  2. Ensure that the context (this) is an array-like object, with numeric properties and a length, so that you can easily iterate over every matched element in the traditional way (using for loops, [].forEach etc).
  3. Create a function, say getElement, that will use the constructor and return the result without having to use the new keyword all the time. Since the function returns an instance of our constructor, you can chain the methods you want as you would normally do.
  4. The method first uses the constructor to create a new instance instead of modifying the original, since its role is to return the first element, not delete everything but the first element.
  5. Each time you come up with a new method you want your object to have, you can simply add it to the prototype of the constructor.

Snippet:

;(function () {
  function Search (value) {
    var elements = [];

    /* Check whether the value is a string or an HTML element. */
    if (typeof value == "string") {
      /* Save the selector to the context and use it to get the elements. */
      this.selector = value;
      elements = document.querySelectorAll(value);
    }
    else if (value instanceof Element) elements.push(value);
      
    /* Give a length to the context. */
    this.length = elements.length;

    /* Iterate over every element and inject it to the context. */
    for (var i = 0, l = this.length; i < l; i++) this[i] = elements[i];
  }

  /* The method that returns the first element in a Search instance. */
  Object.defineProperty(Search.prototype, "first", {
    value: function () {
      return new Search(this[0]);
    }
  });
  
  /* The global function that uses the Search constructor to fetch the elements. */
  window.getElement = (value) => new Search(value);
  
  /* Create a reference to the prototype of the constructor for easy access. */
  window.getElement.fn = Search.prototype;
})();

/* Get all elements matching the class, the first one, and the first's plain form. */
console.log(getElement(".cls1"));
console.log(getElement(".cls1").first());
console.log(getElement(".cls1").first()[0]);
/* ----- CSS ----- */
.as-console-wrapper {
  max-height: 100%!important;
}
<!----- HTML ----->
<div id = "a1" class = "cls1"></div>
<div id = "a2" class = "cls1"></div>
<div id = "a3" class = "cls1"></div>

Example:

In this example, I'm adding a new method called hasClass to the prototype of the constructor.

/* The method that returns whether the first element has a given class. */
Object.defineProperty(getElement.fn, "hasClass", {
  value: function (value) {
    return this[0].classList.contains(value);
  }
});

/* Check whether the first element has the 'cls2' class. */
console.log(getElement(".cls1").first().hasClass("cls2"));
<!----- HTML ----->
<script src="//pastebin.com/raw/e0TM5aYC"></script>
<div id = "a1" class = "cls1 cls2"></div>
<div id = "a2" class = "cls1"></div>
<div id = "a3" class = "cls1"></div>
Angel Politis
  • 10,955
  • 14
  • 48
  • 66
  • Hmm you seem to be doing a lot of extra things that I didn't do in my original function (though I also did a lot more in my function). Anyway, why do you do the pushes and for loops when `querySelectorAll` returns a valid array which you can just count the length of? – NoobishPro Jan 24 '18 at 23:54
  • 1
    The pushes happen only when the argument is an element. If it's a selector, then `querySelectorAll` is used. Also, the `for` loop is necessary to transfer the elements from the `nodelist` `querySelectorAll` returns to the `this` object, so that the prototype methods can access them @Babydead. And actually, I'm not doing extra stuff at all. Imagine if you wanted to be able to pass the `document`, or `window` or whatever inside the function; maybe even use a context. I'm being very basic here – Angel Politis Jan 24 '18 at 23:56
  • Thank you for explaning. I think I'll have to dive deeper into your answer when I get the time. It's still a bit complicated for me! – NoobishPro Jan 25 '18 at 00:00
  • You should surely do. I've written the code in the cleanest possible way and commented it, so that you read it and understand every step with ease. I hope it'll prove useful to you in some occasion @Babydead. – Angel Politis Jan 25 '18 at 00:02
  • 1
    it will. I'm going to study it to become better at it. I will try your method on my next class! – NoobishPro Jan 25 '18 at 00:29
-1

You can update getElement so it returns back again when you send it an element.

var getElement = function(selector, parent) {
  var ret = null
  if (typeof selector === "string") {
    ret = document.querySelectorAll(selector);
  } else {
    ret = selector
  }
  this.element = ret;
  this.hasClass = function(className) {
    className.replace('.', '');
    if (this.multiple()) {
      console.log('Cannot use hasClass function on multiple elements');
      return false;
    }
  };

  this.first = function() {
    this.element = getElement(this.element[0]);
    return this;
  };
  return this;
};

var test = getElement(".foo");
console.log(test.first())
console.log(test.first().hasClass)
<div class="foo">1</div>
<div class="foo">2</div>
<div class="foo">3</div>
<div class="foo">4</div>
Angel Politis
  • 10,955
  • 14
  • 48
  • 66
epascarello
  • 204,599
  • 20
  • 195
  • 236
-2

You can use .querySelectorAll(), spread element and Array.prototype.find(), which returns the first match within an array or undefined

const getElement = (selector = "", {prop = "", value = "", first = false} = {}) => {
    const el = [...document.querySelectorAll(selector)];
    if (first) return el.find(({[prop]:match}) => match && match === value)
    else return el;
};

let first = getElement("span", {prop: "className", value: "abc", first: true});
    
console.log(first);

let last = getElement("span");

console.log(all);
<span class="abc">123</span>
<span class="abc">456</span>
guest271314
  • 1
  • 15
  • 104
  • 177
  • This has nothing to do with my question – NoobishPro Jan 24 '18 at 23:44
  • @Babydead The Question is _"Javascript: Chaining on elements like jQuery"_, which is what the code at the Answer does, by utilizing `Array.prototype` methods. What is the issue with the solution to your inquiry at the code at the above Answer? – guest271314 Jan 24 '18 at 23:47
  • @AngelPolitis spread syntax is not an operator [What is SpreadElement in ECMAScript documentation? Is it the same as Spread operator at MDN?](https://stackoverflow.com/questions/37151966/what-is-spreadelement-in-ecmascript-documentation-is-it-the-same-as-spread-oper?) – guest271314 Jan 25 '18 at 00:07
  • Microsoft [disagrees](https://learn.microsoft.com/en-us/scripting/javascript/reference/spread-operator-decrement-dot-dot-dot-javascript). – Angel Politis Jan 25 '18 at 00:09
  • @AngelPolitis That description is not accurate relevant to the specification. – guest271314 Jan 25 '18 at 00:10
  • Never mind, thanks for the link. Felix Kling's answer was very informative. Naming conventions can become confusing at times. – Angel Politis Jan 25 '18 at 00:11