javascriptknockout.jsjavascript-databinding

Proper dependecy tracking in a custom binding


What I'm trying to achieve is to visually filter table rows generated by the foreach binding in a way that tr elements of the rows that are filtered out would be hidden instead of removed from the DOM.
This approach significantly improves rendering performance when a user changes filter conditions. This is why I don't want the foreach to be bound to a computed observable array which is updated depending on the filter condition.
I want this solution to be a ready-to-use building block that I can use in other places of the project.

As far as I'm familiar with Knockout, the best way is to implement a custom binding.

The way I intended to use this binding is something like this:

<tbody data-bind="foreach: unfilteredItems, visibilityFilter: itemsFilter">
    <tr>
    ...
    </tr>
</tbody>

where itemsFilter is a function returning boolean depending of whether current row should be visible or not, like this:

    self.itemsFilter = function (item) {
        var filterFromDate = filterFromDate(), // Observable
            filterDriver = self.filterDriver(); // Observable too

        return item && item.Date >= filterFromDate && (!filterDriver || filterDriver === item.DriverKey);
    };

Here is the binding implementation that I have so far:

/*
 * Works in conjunction with the 'foreach' binding and allows to perform fast filtering of generated DOM nodes by
 * hiding\showing them rather than inserting\removing DOM nodes.
*/
ko.bindingHandlers.visibilityFilter = {
    // Ugly thing starts here
    init: function (elem, valueAccessor) {
        var predicate = ko.utils.unwrapObservable(valueAccessor());

        predicate();
    },
    // Ugly thing ends
    update: function (elem, valueAccessor) {
        var predicate = ko.utils.unwrapObservable(valueAccessor()),
            child = ko.virtualElements.firstChild(elem),
            visibleUpdater = ko.bindingHandlers.visible.update,
            isVisible,
            childData,
            trueVaueAccessor = function () { return true; },
            falseVaueAccessor = function () { return false; };

        while (child) {
            if (child.nodeType === Node.ELEMENT_NODE) {
                childData = ko.dataFor(child);

                if (childData) {
                    isVisible = predicate(childData, child);
                    visibleUpdater(child, isVisible ? trueVaueAccessor : falseVaueAccessor);
                }
            }

            child = ko.virtualElements.nextSibling(child);
        }
    }
};
ko.virtualElements.allowedBindings.visibilityFilter = true;

Do you see that ugly init part with predicate invocation without passing an object to it?

Without this, if there is no rows generated by the foreach binding by the first time Knockout calls the update method, itemsFilter filter function wouldn't be called.
Hence, no observables would be read and KO dependency tracking mechanism decides that this binding doesn't depend on any observables in my view model.
And when values of the filter observables (filterFromDate and filterDriver) get changed, the update will never be called again and the whole filtering doesn't work.

How can I improve this implementation (or the whole approach to the problem) in order to not make that ugly call to the filter function which at least makes the function await an undefined value as a paramenter?


Solution

  • You can use a visible binding on the tr and bind it to the result of a function call using $data as the parameter. A little demo below. Whichever value you select is filtered out of the table.

    var vm = {
      rows: ko.observableArray(['One', 'Two', 'Three']),
      selected: ko.observable('One'),
      isVisible: function(row) {
        return row !== vm.selected();
      }
    };
    
    ko.applyBindings(vm);
    <script src="https://cdnjs.cloudflare.com/ajax/libs/knockout/3.2.0/knockout-min.js"></script>
    <select data-bind="options:rows, value:selected"></select>
    <table border="1" data-bind="foreach:rows">
      <tr data-bind="visible:$parent.isVisible($data)">
        <td data-bind="text:$data"></td>
      </tr>
    </table>