Skip recursion in jQuery.find() for a selector? [duplicate]
Asked Answered
S

8

6

TL;DR: How do I get an action like find(), but block traversal (not full stop, just skip) for a certain selector?

ANSWERS: $(Any).find(Selector).not( $(Any).find(Mask).find(Selector) )

There were many truly great answers, I wish I could some how distribute the bounty points more, maybe I should make some 50 pt bounties in response to some of these ;p I choose Karl-André Gagnon's because this answer managed to make findExclude unrequired in one, slightly long, line. While this uses three find calls and a heavy not filter, in most situations jQuery can use very fast implementation that skips traversal for most find()s.

Especially good answers are listed below:

falsarella: Good improvement on my solution, findExclude(), best in many situatoins

Zbyszek: A filter-based solution similar to falsarella's, also good on efficiency

Justin: A completely different, but manageable and functional solution to the underlaying issues

Each of these have their own unique merits and and are deserving of some mention.

I need to descend into an element fully and compare selectors, returning all matched selectors as an array, but skip descending into the tree when another selector is encountered.

Selection Path Illustration Edit: replacing original code sample with some from my site

This is for a message forum which may have reply message-groups nested inside any message.
Notice, however, we cannot use the message or content classes because the script is also used for other components outside of the forum. Only InterfaceGroup, Interface and controls classes are potentially useful - and preferably just Interface and controls.

Interact with the code and see it in JS Fiddle, thanks Dave A, here Click on the buttons while viewing a JavaScript console to see that the controls class is being bound to one extra time per level of .Interface nesting.

Visual A, Forum Layout Struture:

    <li class="InterfaceGroup">
        <ul class="Interface Message" data-role="MessagePost" >
            <li class="instance"> ... condensed ... </li>
            <li class="InterfaceGroup"> ... condensed ...</li>
        </ul>
        <ul class="Interface Message" data-role="MessagePost" >
            <li class="instance"> ... condensed ... </li>
        </ul>
        <ul class="Interface Message" data-role="MessagePost" >
            <li class="instance"> ... condensed ... </li>
            <li class="InterfaceGroup"> ... condensed ...</li>
        </ul>

    </li>

Inside of each <li class="InterfaceGroup"> there could be any number of repetitions of the same structure (each group is a thread of messages) and/or deeper nesting such as..

    <li class="InterfaceGroup">

        <ul class="Interface Message" data-role="MessagePost" >
            <li class="instance"> ... condensed ... </li>
            <li class="InterfaceGroup">

                <ul class="Interface Message" data-role="MessagePost" >
                    <li class="instance"> ... condensed ... </li>
                    <li class="InterfaceGroup"> ... condensed ...</li>
                </ul>
            </li>
        </ul>
    </li>

Inside of each <li class="instance"> ... </li> there are arbitrary places decided by another team where class="controls" may appear and an event listener should be bound. Though these contain messages, other components structure their markup arbitrarily but will always have .controls inside of .Interface, which are collected into an .InterfaceGroup.A reduced-complexity version of the inner-content (for forum posts) is below for reference.

Visual B, Message Contents with controls class:

<ul class="Interface Message" data-role="MessagePost" >
    <li class="instance">
      <ul class="profile"> ...condensed, nothing clickable...</ul>
      <ul class="contents">
        <li class="heading"><h3>Hi there!</h3></li>
        <li class="body"><article>TEST Message here</article></li>
        <li class="vote controls">
          <button class="up" data-role="VoteUp" ><i class="fa fa-caret-up"> </i><br/>1</button>
          <button class="down" data-role="VoteDown" >0<br/><i class="fa fa-caret-down"> </i></button>
        </li>
        <li class="social controls">
          <button class="reply-btn" data-role="ReplyButton" >Reply</button>
        </li>
      </ul>
    </li>
    <li class="InterfaceGroup" >    <!-- NESTING OCCURRED -->
      <ul class="Interface Message" data-role="MessagePost" >
          <li class="instance">... condensed ... </li>
          <li class="InterfaceGroup" >... condensed ... </li>
      </ul>
   </li>
</ul>

We can only bind to controls that are within an Interface class, instance may or may not exist but Interface will. Events bubble to .controls elements and have a reference to the .Interface which holds them..

So I am trying to $('.Interface').each( bind to any .controls not inside a deeper .Interface )

That's the tricky part, because

  • .Interface .controls will select the same .control multiple times in the .each()
  • .not('.Interface .Interface .controls') cancels out controls in any deeper nesting

How can I do this using jQuery.find() or a similar jQuery method for this?

I have been considering that, perhaps, using children with a not selector could work and could be doing the same thing as find under the hood, but I'm not so sure that it actually is or wont cause horrible performance. Still, an answer recursing .children effectively is acceptable.

UPDATE: Originally I tried to use a psuedo-example for brevity, but hopefully seeing a forum structure will help clarify the issue since they're naturally nested structures. Below I'm also posting partial javascript for reference, line two of the init function is most important.

Reduced JavaScript partial:

var Interface=function()
{
    $elf=this;

    $elf.call=
    {
        init:function(Markup)
        {
            $elf.Interface = Markup;
            $elf.Controls = $(Markup).find('.controls').not('.Interface .controls');
            $elf.Controls.on('click mouseenter mouseleave', function(event){ $elf.call.events(event); });
            return $elf;
        },
        events:function(e)
        {
            var classlist = e.target.className.split(/\s+/), c=0, L=0;
            var role = $(e.target).data('role');

            if(e.type == 'click')
            {
                CurrentControl=$(e.target).closest('[data-role]')[0];
                role = $(CurrentControl).data('role');

                switch(role)
                {
                    case 'ReplyButton':console.log('Reply clicked'); break;
                    case 'VoteUp':console.log('Up vote clicked'); break;
                    case 'VoteDown':console.log('Down vote clicked'); break;
                    default: break;
                }
            }
        }
    }
};

$(document).ready( function()
{
    $('.Interface').each(function(instance, Markup)
    {
        Markup.Interface=new Interface().call.init(Markup);
    });
} );
Shammer answered 11/6, 2014 at 9:35 Comment(7)
Not sure what you mean but if you are looking for a way to iterate over a bunch of elements and then child elements try this: $('.group').each(function() { var $element = $(this).find('.target'); });Henning
Unfortunately no, $('.group').each(function() { var $element = $(this).find('.target').not('.group .target'); }); is about as close as I can get to putting the idea into shorter terms, but is also wrong. I've already tried iterating over them, but I need to DOM traverse into .group looking for .target (just like find does) but skip .group elements during the find.Shammer
ah ok so you don't want the nexted groups in the iteration to not calling them multiple times... got it. You could flag the already processed items with a js-class and then skip it when you hit it twice...?!Henning
@Dominik, the issue there would be the large amount of re-processing; The minimum efficiency I can use would be recursively going through the .children search looking for the target selector in every child element, but not recursing into the group element. even then, I'm not sure if I would write that correctly or if it's performance is comparable to a .find() solution.Shammer
@GaretClaborn: what do you want to do with the selected nodes ? It looks like you want to bind an event listener to the selection, what doesn't work with $(document).on('click', '.controls', function(){ ... }) ?Presto
@Presto seems I never noticed this question. The reason for this is so template designers could define events in plain HTML. Using data-* attributes to describe the intended handler and supporting json data to be sent, they're able to trigger AJAX requests (or js functions from within the context of a given semantic component) to the server without any further JS coding. I wanted all the .controls grouped into an object attached to the closest .Interface, in order to enforce efficient event bubbling and to prevent some ambiguitiesShammer
Kind of a strange loss of context if this question is closed; as the "duplicate" mentioned was answered due to feedback in this bounty. Bounty was set because the duplicate question remained open for over 2 years, then finally closed after I transposed the answer. While the only real difference is accounting for selectors; this question is referenced in that accepted answer. That, along with the various solutions and adaptations this question went through provide some deeper insights - so may want to preserve it to an extent.Shammer
K
8

If you want to exclude element in you find, you can use a not filter. As for example, I've taken you function that exclude element and made it way shorter :

$.fn.findExclude = function( Selector, Mask,){
    return this.find(Selector).not(this.find(Mask).find(Selector))
}

Now, ill be honest with you, I did not fully understand what you want. But, when i took a look at your function, I saw what you were trying to do.

Anyway, take a look at this fiddle, the result is the same as your : http://jsfiddle.net/KX65p/8/

Koph answered 19/6, 2014 at 19:16 Comment(0)
S
4

Well, I really don't want to be answering my own question on a bounty, so if anyone can provide a better or alternative implementation please do..

However, being pressed to complete the project, I ended up working on this quite a bit and came up with a fairly clean jQuery plugin for doing a jQuery.find() style search while excluding child branches from the results as you go.

Usage to work with sets of elements inside nested views:

// Will not look in nested ul's for inputs
$('ul').findExclude('input','ul');

// Will look in nested ul's for inputs unless it runs into class="potato"
$('ul').findExclude('input','.potato');

More complex example found at http://jsfiddle.net/KX65p/3/ where I use this to .each() a nested class and bind elements which occur in each nested view to a class. This let me make components server-side and client-side reflect each other's properties and have cheaper nested event handling.

Implementation:

// Find-like method which masks any descendant
// branches matching the Mask argument.
$.fn.findExclude = function( Selector, Mask, result){

    // Default result to an empty jQuery object if not provided
    result = typeof result !== 'undefined' ?
                result :
                new jQuery();

    // Iterate through all children, except those match Mask
    this.children().each(function(){

        thisObject = jQuery( this );
        if( thisObject.is( Selector ) ) 
            result.push( this );

        // Recursively seek children without Mask
        if( !thisObject.is( Mask ) )
            thisObject.findExclude( Selector, Mask, result );
    });

    return result;
}

(Condensed Version):

$.fn.findExclude = function( selector, mask, result )
{
    result = typeof result !== 'undefined' ? result : new jQuery();
    this.children().each( function(){
        thisObject = jQuery( this );
        if( thisObject.is( selector ) ) 
            result.push( this );
        if( !thisObject.is( mask ) )
            thisObject.findExclude( selector, mask, result );
    });
    return result;
}
Shammer answered 16/6, 2014 at 23:24 Comment(1)
I like it. will study and see if I can come up with any improvements.Shrike
S
2

If I understand you:

understanding your needs better and applying the specific classes you need, I think this is the syntax will work:

var targetsOfTopGroups  = $('.InterfaceGroup .Interface:not(.Interface .Interface):not(.Interface .InterfaceGroup)')

This Fiddle is an attempt to reproduce your scenario. Feel free to play around with it.


I think I found the problem. You were not including the buttons in your not selector

I changed the binding to be

        var Controls = $('.InterfaceGroup .Interface :button:not(.Interface .Interface :button):not(.Interface .InterfaceGroup :button)');

Fiddle

Shrike answered 14/6, 2014 at 0:44 Comment(14)
I will be trying this out shortly, I think this is close but not sure if its all the way there. I am cleaning attributes and some excess out of a section of my actual rendered markup to illustrate a bit better. We need the target selector inside of messages (on a forum) to bind to it's parent message but not the messages above it, but we need to initialize that bind by $(Group).each(). We're also using this script in other non-forum components. Thanks, will post more info shortly after cleaning code up a bit.Shammer
@GaretClaborn take a look at my revised answer and customized fiddle. Is this what you have in mind?Shrike
I've updated your fiddle to include the a few levels of nested messages. The selector you used didn't end up binding properly, but I modified it a bit on line 11 of the JS (moved my js in too). jsfiddle.net/LymyS/2 you can uncomment either of the two lines above it to see in the console that you don't get one callback per button but either none or multiple. (my original issue)Shammer
@GaretClaborn, I'm clearly missing something. When I check which divs have borders, they are all Interface Message below a root InterfaceGroup. Any element that breaks that rule of nesting is NOT outlined. That's what I was shooting for, but it doesn't meet your need. I seem to have misunderstood the requirement.Shrike
If you inspect and view your console log on the fiddle, try clicking on the buttons and testing the different versions of the commented init code on the JS. Any event inside of an element with '.controls' should trigger $elf.event(), the problem is that the first '.Interface' in $('.Interface').each() (in the document ready) is calling .on('click', $elf.call.events); and so are the nested ones. This results in multiple bindings to the same .controls element. I apologize if the way I'm presenting this is rather confusing I could possibly use some advice simplifying the explanation.Shammer
@GaretClaborn, complicated is complicated. It's a challenge for both of us to find common undertsanding. The problem with working thru your buttons is knowing which are supposed to do what. I'll take a closer look. I was hoping if we could select them correctly and set borders around them, then that shows us what we hit and what not. Maybe i should be creating a selector for buttons instead of divs?Shrike
The buttons are just for example, the people making component templates need anything inside a .controls to trigger events. Since some .Interface are parents of others, they bind to the .controls inside their child interfaces which is bad. Filtering .Interface .Interface out however stops the children from binding at all. I put in some HTML comments and formatted a bit jsfiddle.net/LymyS/3Shammer
@GaretClaborn, check my latest Fiddle. I think the problem was we were not including button limitations in the not selectors. Once I filtered incorrect buttons out, it seemed to come together. Please confirm.Shrike
Perhaps this added illustration (added to the question) will help; as mentioned buttons are not the only elements which can go into .controls and we bind our events to .controls (not the elements inside.) With your current selector the event triggers 6 times for any button in the first Interface and not at all for any sub-interfaces. See v6 jsfiddle.net/LymyS/6 where I switch to my original selector. The first set works as they should, each level of nesting causes more redundant events.Shammer
@GaretClaborn, the selector for buttons works perfectly. I did a console.log() on your function Interface gets called 6 times. Seems like I am approaching your dillema globally and you are slicing it up. Thereby, the selector gets called multiple times, binding multiples times. I recomend using my "global" approach once per element type as the right elements are being bound each time you call the Interface. Same logic can be applied multiple times to multiple types of controls.Shrike
Dave, yes, clicking on the first set of buttons results in 6 events firing - which is bad. The other buttons do not fire at all. There should be one event per button fired. Again, the events should not be attached to button elements but the controls, for this project.Shammer
@GaretClaborn, the reason that happens is because your on event is fired 6 times (i logged it). In other words, your Interface function fires 6 times. so each button has 6 of the same event against it. I suspect your approach is so customized, my approach doesnt mesh.Shrike
Dave, the approach you're taking now doesn't produce the action of excluding a branch while searching all descendant elements, which the question is about. Each button is not being bound to currently, only those in the first message. that worked better in the original.. I really appreciate the effort, and thanks, unfortunately this isn't a solution even if done at the global level (aside from breaking the question's main constraint and my project). I am beginning to think this is too difficult for a single find and will indeed require recursing through $(Markup).children in the init function.Shammer
Moved to chat.Shammer
H
2

Maybe something like this would work:

$.fn.findExclude = function (Selector, Mask) {
    var result = new jQuery();
    $(this).each(function () {
        var $selected = $(this);
        $selected.find(Selector).filter(function (index) {
            var $closest = $(this).closest(Mask);
            return $closest.length == 0 || $closest[0] == $selected[0] || $.contains($closest, $selected);
        }).each(function () {
            result.push(this);
        });
    });
    return result;
}

http://jsfiddle.net/JCA23/

Chooses those elements that are either not in mask parent or their closest mask parent is same as root or their closest mask parent is a parent of root.

Her answered 18/6, 2014 at 21:48 Comment(4)
That's exactly what I thought to post, but I think that $(this).closest(Mask)[0] == $selected[0] would already be suficient for the filter condition.Nolpros
Hi, thanks. Your filter solution does seem to work ala the fiddle. On efficiency though, I have a couple questions. Wont this have the effect of traversing the starting element through to the deepest nodes, coming up to the closest mask selector and then returning elements who are not nested inside the mask? Along with the added .contains and .each at the end, it seems there would be more repeated traversals than just diving into the starting element once?Shammer
one other question, would this not sometimes cross into the first Selector's ancestors if it were a descendant of Mask?Shammer
First question: theoretically solution based on recursion and just skipping some subtrees should be better. But in practice jQuery has some inner optimalisation, selector caching etc. so you should check on some big generated tree. Also if it really matter depends on size of DOM tree that you target. If it's not huge and not run very often one then probably won't matter much. Second question: If I understand your question well then that's why I added $.contains($closest, $selected) - so for those which have closest mask parent that is a parent of original selector they should be taken as OK.Her
Y
2

From my understanding, I would bind to the .controls elements and allow the event to bubble up to them. From that, you can get the closest .Interface to get the parent, if needed. This way you are added multiple handlers to the same elements as you go further down the rabbit hole.

While I saw you mention it, I never saw it implemented.

//Attach the event to the controls to minimize amount of binded events
$('.controls').on('click mouseenter mouseleave', function (event) { 
    var target = $(event.target),
        targetInterface = target.closest('.Interface'),
        role = target.data('role');

    if (event.type == 'click') {
        if (role) {
            switch (role) {
                case 'ReplyButton':
                    console.log('Reply clicked');
                    break;
                case 'VoteUp':
                    console.log('Up vote clicked');
                    break;
                case 'VoteDown':
                    console.log('Down vote clicked');
                    break;
                default:
                    break;
            }
        }
    }
});

Here is a fiddle showing what I mean. I did remove your js in favor of a simplified display.

It does seem that my solution may be a over simplification though...


Update 2

So here is a fiddle that defines some common functions that will help achieve what you are looking for...I think. The getInterfaces provides a simplified function to find the interfaces and their controls, assuming all interfaces always have controls.

There are probably fringe cases that will creep up though. I also feel I need to apologize if you have already ventured down this path and I'm just not seeing/understanding!


Update 3

Ok, ok. I think I understand what you want. You want to get the unique interfaces and have a collection of controls that belong to it, that make sense now.

Using this fiddle as the example, we select both the .Interface and the .Interface .controls.

var interfacesAndControls = $('.Interface, .Interface .controls');

This way we have a neat collection of the interfaces and the controls that belong to them in order they appear in the DOM. With this we can loop through the collection and check to see if the current element has the .Interface associated with it. We can also keep a reference to the current interface object we create for it so we can add the controls later.

if (el.hasClass('Interface')){
    currentInterface = new app.Interface(el, [], eventCallback);
    
    interfaces.push(currentInterface);
    
    //We don't need to do anything further with the interface
    return;
};

Now when we don't have the .Interface class associate with the element, we got controls. So let's first modify our Interface object to support adding controls and binding events to the controls as they are being added to the collection.

//The init function was removed and the call to it
self.addControls = function(el){
    //Use the mouseover and mouseout events so event bubbling occurs
    el.on('click mouseover mouseout', self.eventCallback)
    self.controls.push(el);
}

Now all we have to do is add the control to the current interfaces controls.

currentInterface.addControls(el);

After all that, you should get an array of 3 objects (interfaces), that have an array of 2 controls each.

Hopefully, THAT has everything you are looking for!

Yvor answered 19/6, 2014 at 4:6 Comment(6)
thanks for the response. One of the key issues for this implementation has been creating an object out of each Interface. we dont want to cross the bounds of other Interfaces so that we can reflect where server-side instances end up in the markup. Doing $('.controls').closest('.Interface') was a method we initially tried but it creates another difficulty that initializing objects and collecting their controls became a two-step process at best. does that make sense? Where you see $elf.controls, we want each set of controls localized to an object, so we needed findExclude.Shammer
In the question, where I have $elf.Controls = $(Markup).find('.controls').not('.Interface .controls'); this was originally just a plain unfiltered $(Markup).find('.controls'); which of course caused the multiple bindings to the same elements as you noticed. You're right to think that is the big issue, only doing it inside of a $('.Interface').each() constructor (doc ready) proved tricky.Shammer
Is there ever an interface that doesn't have controls?Yvor
@GaretClaborn I have added an update fiddle that will hopefully be closer to what you want.Yvor
Hi Justin, this most recent edit is certainly much closer and you were able to make an object binding the controls to the Interface element. I notice though, if I console.log interfaces after getInterfaces is called, you end up with 1 interface per control, even if they are in the same one. i.e. There are 3 elements with the Interface class but six controls all together. Otherwise this is close to the right end-result, through through a different traversal method.Shammer
@GaretClaborn Check my update. I think I got what you want this time.Yvor
N
2

I think that this is the closest the findExclude can be optimized:

$.fn.findExclude = function (Selector, Mask) {
    var result = $([]);
    $(this).each(function (Idx, Elem) {
        $(Elem).find(Selector).each(function (Idx2, Elem2) {
            if ($(Elem2).closest(Mask)[0] == Elem) {
                result =  result.add(Elem2);
            }
        });
    });
    return result;
}

Also, see its fiddle with added logs with ellapsed time in milliseconds.

I see that you are worried with the performances. So, I've run some tests, and this implementation takes no longer than 2 milliseconds, while your implementation (as the answer you have posted) sometimes takes around 4~7 millisecods.

Nolpros answered 19/6, 2014 at 16:55 Comment(1)
Well that is very interesting. Indeed you get the exactly correct results as far as I can tell. I wonder how it is that going deeper and coming back up the tree is faster than skipping a branch. Regardless, I see this is definitely performing better.Shammer
I
0

Why not taking the problem upside down?

Select all $(.target) elements and then discard them from further treatment if their .$parents(.group) is empty, that would give sonething like:

$('.target').each(function(){
    if (! $(this).parents('.group').length){
        //the jqueryElem is empy, do or do not
    } else {
        //not empty do what you wanted to do
    }
});

Note that don't answer the title but literally gives you "Selector B, inside of a result from Selector A"

Infallible answered 11/6, 2014 at 11:5 Comment(1)
Hey, the main issue here would be that no .controls will have an empty .parents('.group') and often return a length > 1. The second issue is that the constraint of operating on each '.group' (now '.Interface' in the example) is important for the project I'm working on.Shammer
A
0

If your .interface classes had some kind of identifier this would seem to be rather easy. Perhabs you already have such an identifier for other reasons or choose to include one.

http://jsfiddle.net/Dc4dz/

<div class="interface" name="a">
    <div class="control">control</div>
    <div class="branch">
        <div class="control">control</div>
        <div class="interface">
            <div class="branch">
                <div class="control">control</div>
            </div>
        </div>
    </div>
    <div class="interface" name="c">
        <div class="branch">
            <div class="control">control</div>
        </div>
    </div> </div>

$( ".interface[name=c] .control:not(.interface[name=c] .interface .control)" ).css( "background-color", "red" );
$( ".interface[name=a] .control:not(.interface[name=a] .interface .control)" ).css( "background-color", "green" );

Edit: And now Im wondering if you're tackling this problem from the wrong angle.

So I am trying to $('.Interface').each( bind to any .controls not inside a deeper .Interface )

http://jsfiddle.net/Dc4dz/1/

$(".interface").on("click", ".control", function (event) {
    alert($(this).text());
    event.stopPropagation();
});

The event would be triggered on a .control; it would then bubble up to its .closest( ".interface" ) where it would be processed and further propagation be stopped. Isn't that what you described?

Audacious answered 20/6, 2014 at 10:5 Comment(1)
Hi Thaylon, the .Interface elements get an object made and assigned to them. This objects holds all the .controls within a given interface. Finally, any event inside a control bubbles up to what is essentially $(event.target).closest('.controls') Put that all together and you have a locality (Interface) that can store multiple event containers and does not try to bind to any child Interfaces. Note, though, the question is about excluding a branch from a find-style result. I just happen to want to exclude Interfaces inside other Interfaces. Thanks!Shammer

© 2022 - 2024 — McMap. All rights reserved.