Knockout: Add "else" functionality to "if" binding

Created on 10 May 2013  Â·  26Comments  Â·  Source: knockout/knockout

Example usage:

<!-- ko if: allowSorting -->
    <th data-bind="text: headerText, click: sortByColumn"></th>
<!-- else -->
    <th data-bind="text: headerText"></th>
<!-- /ko -->

This would also work for ifnot because it shares the same code.

average api nice-to-have feature

Most helpful comment

Why does this not exist already?

All 26 comments

Here, I'm proposing a simple "else" functionality, not "else if". Previous discussion at #283.

:heart:

+1

:+1:

+1

Standalone implementation (compatible with Knockout 3.0.0pre or 2.2+ with Freedom plugin):

function cloneNodes(nodesArray, shouldCleanNodes) {
    for (var i = 0, j = nodesArray.length, newNodesArray = []; i < j; i++) {
        var clonedNode = nodesArray[i].cloneNode(true);
        newNodesArray.push(shouldCleanNodes ? ko.cleanNode(clonedNode) : clonedNode);
    }
    return newNodesArray;
};

ko.bindingHandlers['if'] = {
    init: function(element, valueAccessor, allBindings, viewModel, bindingContext) {
        var trueNodes = [], falseNodes = [], isFirstRender = true, wasLastTrue;

        function saveTemplates() {
            var target = trueNodes;
            ko.utils.arrayForEach(ko.virtualElements.childNodes(element), function(node) {
                if (node.nodeType == 8 && (node.text ? /<!--\s*else\s*-->/.test(node.text) : /\s*else\s*/.test(node.nodeValue))) {
                    target = falseNodes;
                } else {
                    target.push(node);
                }
            });
            trueNodes = cloneNodes(trueNodes, true /* shouldCleanNodes */);
            falseNodes = cloneNodes(falseNodes, true /* shouldCleanNodes */);
        }

        ko.computed(function() {
            var dataValue = ko.utils.unwrapObservable(valueAccessor()),
                isTrue = !!dataValue,    // Cast value to boolean
                renderNodes = isTrue ? trueNodes : falseNodes;  // Set before templates are saved so that the initial bind is to the original nodes

            if (isFirstRender || isTrue !== wasLastTrue) {
                if (isFirstRender) {
                    saveTemplates();
                } else {
                    renderNodes = cloneNodes(renderNodes);
                }

                ko.virtualElements.setDomNodeChildren(element, renderNodes);
                ko.applyBindingsToDescendants(bindingContext, element);

                wasLastTrue = isTrue;
            }
            isFirstRender = false;

        }, null, { disposeWhenNodeIsRemoved: element });

        return { controlsDescendantBindings: true };
    }
};

:+1:

@mbest Thanks so much for writing this. I did have trouble with nested if/else, but it seems to work after I made the following modifications:

ko.bindingHandlers['if'] = (function () {
    var cloneNodes = function (nodesArray, shouldCleanNodes) {
        for (var i = 0, j = nodesArray.length, newNodesArray = []; i < j; i++) {
            var clonedNode = nodesArray[i].cloneNode(true);
            newNodesArray.push(shouldCleanNodes ? ko.cleanNode(clonedNode) : clonedNode);
        }
        return newNodesArray;
    };

    return {
        init: function (element, valueAccessor, allBindings, viewModel, bindingContext) {
            var trueNodes = [], falseNodes = [], isFirstRender = true, wasLastTrue;

            function saveTemplates() {
                var depth = 0;
                var target = trueNodes;
                ko.utils.arrayForEach(ko.virtualElements.childNodes(element), function (node) {
                    if (node.nodeType === 8) {
                        if (node.text) {
                            if (/<!--\s*ko\b/.test(node.text)) {
                                depth += 1;
                            } else if (/<!--\s*\/ko\b/.test(node.text)) {
                                depth -= 1;
                            } else if (depth === 0 && /<!--\s*else\s*-->/.test(node.text)) {
                                target = falseNodes;
                                return;
                            }
                        } else {
                            if (/^\s*ko\b/.test(node.nodeValue)) {
                                depth += 1;
                            } else if (/^\s*\/ko\b/.test(node.nodeValue)) {
                                depth -= 1;
                            } else if (depth === 0 && /\s*else\s*/.test(node.nodeValue)) {
                                target = falseNodes;
                                return;
                            }
                        }
                    }

                    target.push(node);
                });
                trueNodes = cloneNodes(trueNodes, true /* shouldCleanNodes */);
                falseNodes = cloneNodes(falseNodes, true /* shouldCleanNodes */);
            }

            ko.computed(function () {
                var dataValue = ko.utils.unwrapObservable(valueAccessor()),
                    isTrue = !!dataValue,    // Cast value to boolean
                    renderNodes = isTrue ? trueNodes : falseNodes;  // Set before templates are saved so that the initial bind is to the original nodes

                if (isFirstRender || isTrue !== wasLastTrue) {
                    if (isFirstRender) {
                        saveTemplates();
                    } else {
                        renderNodes = cloneNodes(renderNodes);
                    }

                    ko.virtualElements.setDomNodeChildren(element, renderNodes);
                    ko.applyBindingsToDescendants(bindingContext, element);

                    wasLastTrue = isTrue;
                }
                isFirstRender = false;

            }, null, { disposeWhenNodeIsRemoved: element });

            return { controlsDescendantBindings: true };
        }
    };
}());

@coryandrew1988 - I'm glad you found it useful!

Are there plans to add if/else/elseif functionality to knockout?

Is there a plugin out there? I am all up for modularity, but frankly having if without else, and requiring a plugin to get that functionality is pushing it a bit too far (imagine if you had to import a module in python to do if/else).

Is there any plan to release this as standard with knockout? It really would be a great feature.

With a preprocessor like knockout.punches it might work with something like:

{{ #if: X }}
{{ else }}
{{ /if }}

where {{ else }} is converted to {{ /if }}{{ ifnot: X }}. One just has to make sure {{ /if }} is a substitute for or translated to {{ /ifnot }}.

One could also have an <!-- ko else --> preprocessor, without punches, but the idea is easier to illustrate with the punches syntax I think.

This obviously has no performance benefit in that X has to be evaluated twice, but there are several potential ways to optimize it in future — and in any case it is no slower than the current solution (notwithstanding the switches plugin).

Hmm. I am mulling how one mind deal with the following using the above:

{{ #if: X }}
   {{ #if: Y }}
   {{ /if }}
{{ else }}
{{ /if }}

The problem is determining the element to which the else attaches; one would seem to have to look through peer nodes. Mulling. :)

EDIT The a jsFiddle: Knockout Else Binding now appears to work as expected. It's just an alternative or complement to altering the if binding as @mbest proposed above.

I have also added foreach / else that shows the content of the else block when the loop is empty. It should not be too hard to add a general template binding as well (with foreach/if bindings).


EDIT Rather than mulling through the below, you can get an idea of where I was headed with this in a jsFiddle: Knockout Else Binding. Still needs some work re. virtual elements, but the idea is pretty straightforward.


I believe, it makes sense to account for the following scenarios:

<!--  A.  Two element nodes   -->
<div data-bind='if: X'></div>
<div data-bind='else'></div>

<!--  B.  A leading comment node    -->
<!-- ko if: X -->
<!-- /ko -->
<div data-bind='else'></div>

<!--  C. A trailing comment node    -->
<div data-bind='if: X'></div>
<!-- ko else -->
<!-- /ko -->

<!--  D.  Adjacent comment nodes    -->
<!-- ko if: X -->
<!-- /ko -->
<!-- ko else -->
<!-- /ko -->

One should be able to substitute ifnot for if, and perhaps also in the future an if argument to the template binding.`

This technique would have the advantage of working for heterogeneous comment / element nodes, and not requiring any changes to the builtin nodes.

The <!-- ko if: X --><!-- else --><!-- /ko --> syntax does not (yet?) work as @mbest illustrated it.

Just food for thought.

Looking forward to a future when components may access and use their initial contents (v3.3?), this could be implemented without ambiguity like this:

<ko-switch params="case: viewModel">
  <case value="1">
    <div>One!</div>
  </case>
  <case value="2">
    <div>Two!</div>
  </case>
</ko-switch>

I'm showing a switch, but if - else would work the same way (basically it's a switch on a boolean value).

Less verbose variations are possible with a little bit of imagination and different conventions:

<ko-switch params="case: viewModel">
    <div data-case="1">One!</div>
    <div data-case="2">Two!</div>
</ko-switch>

@jods4 You can essentially accomplish this now with the knockout-switch-case plugin. Not to take away from the usefulness of accessing the initial contents of components' nodes, but the switch/case itself can be done cleanly with the plugin, to some extent.

Cheers

@brianmhunt I know!

My remark was made in the context of the discussion about what the syntax for this ought to be. I find most of the propositions above unintuitive, inconsistent and sometimes ambiguous (with respect to nested ifs).

Here's a variation:

<!-- ko ifelse: condition -->
  <div data-true>True</div>
  <div data-false>False</div>
<!-- /ko -->

I was using switch rather than if before because in the end if is just a special case for switch on a boolean value. As such I'm not sure about the usefulness of an if-else binding if we include a more useful switch binding in the core KO.

I created a plugin knockout-else that solves the _else_ problem in the way I described in my comments above. With some unit tests for good measure.

@mbest, could this plugin design resolve this issue for the use case(s) you had been thinking of?

The one use case it does not solve is the <div data-bind='if: x'><!-- else --></div> (i.e. splitting an if), though I have some thoughts on how one might do that in the plugin, but would be very grateful for your thoughts and insight, if you think it is a key goal to this.

@jods4 I agree a switch binding could be very handy, though I think there remains a valid use-case for the if - else (whether the way I have done it or otherwise). Since switch is a bit off-topic here, would you be able to start another issue (and link this discussion) on how the knockout-switch you are thinking of would differ from knockout-switch-case? I think that would be very helpful.

Cheers.

I have since added "inline" else/else-if bindings to knockout-else i.e. the following ought to work as expected:

<div data-bind='if:x'>
  X
  <!-- elseif:y -->
  Y
  <!-- else -->
  Z
</div>

+1

Why does this not exist already?

For a programmer, if...else should be inherently a built-in feature.

This is in tko now, as both the else binding (like knockout-else) plus <!-- else --> inside of an if/ifnot/unless.

@jods4

I couldn't agree with you more on the intuitiveness of your design. Very clear on what it is your looking for in the markup.

@brianmhunt are there any chances that this library is included in the ko standard?

+1

Was this page helpful?
0 / 5 - 0 ratings

Related issues

priyank-eschool picture priyank-eschool  Â·  7Comments

nkosi23 picture nkosi23  Â·  5Comments

ricardobrandao picture ricardobrandao  Â·  8Comments

IPWright83 picture IPWright83  Â·  7Comments

brunolau picture brunolau  Â·  8Comments