Search code examples
javascriptjqueryajaxknockout.jsknockout-mapping-plugin

Knockout Mapping re-rendering everything


I'm using the Knockout mapping plug-in to refresh the UI with JSON retrieved from the server every 3 seconds. The UI consists of some nested foreach bindings. However, it seems that everything in all the foreach bindings are getting completely deleted and re-rendered with every refresh, even when nothing has changed.

var testData = {
    Answers: [],
    Inspectable: {
        Categories: [{
            Id: 1,
            Name: "Test Category",
            Questions: [{
                Id: 1,
                Text: "Test Question",
                Active: true,
                Answers: [{
                    Text: "Test Answer",
                    Id: 1
                }]
            }]
        }]
    }
};

function ViewModel() {

    var self = this;

    this.refreshUrl = $("[data-view=edit]").data("url");

    this.refresh = function(callback) {
        $.get(self.refreshUrl, function(data) {
            //Ignoring actual JSON data for testing
            ko.mapping.fromJS(testData, {}, self);
            if (typeof callback == "function") {
                callback.call(self);
            }
        });
    }

    this.addedQuestion = function() {
        // Gets called for every question every refresh by afterRender
        // Never gets called at all by afterAdd
    }

};

var refreshing = false, handler;
window.viewModel = new ViewModel();

//Initialize the UI after initial AJAX is completed
viewModel.refresh(function() {

    ko.applyBindings(this);

        $(document).on("click", ".add-question", function() {
        if (!refreshing) {
            handler = setInterval(viewModel.refresh, 3000);
            refreshing = true;
        }
    });
});

Does anyone see anything glaringly wrong with this?

EDIT

I edited the script to use a static JavaScript object. It still re-renders every refresh. Also updated to Knockout 2.3.0. Here's the view:

    <!-- ko foreach: Inspectable.Categories -->
        <div class="row row-fluid space-above">
            <h4 class="orange" data-bind="text: Name"></h4>
            <!-- ko foreach: { data: Questions, afterRender: $root.addedQuestion } -->
                <!-- ko if: Active() || ~$.map($root.Answers(), function(a) { return a.Id() == Id() }) -->
                    <div class="question space-above">
                        <p><strong data-bind="text: Text"></strong></p>
                        <div class="answers" data-bind="foreach: Answers">
                            <!-- ko if: $parent.AllowMultiple --><label class="checkbox"><input type="checkbox" data-url="<%= Url.Action("AddOrRemoveAnswer") %>" data-bind="attr: { value: Id, name: 'question-' + $parent.Id() }"/><!-- ko text: Text --><!-- /ko --></label><!-- /ko -->
                            <!-- ko ifnot: $parent.AllowMultiple --><label class="radio"><input type="radio" data-url="<%= Url.Action("AddOrRemoveAnswer") %>" data-bind="attr: { value: Id, name: 'question-' + $parent.Id() }"/><!-- ko text: Text --><!-- /ko --></label><!-- /ko -->
                        </div>
                    </div>
                <!-- /ko -->
            <!-- /ko -->
            <!-- ko if: Questions().length == 0 -->
                <div class="question space-above">
                    <p><strong>No questions in this category.</strong> <a class="add-question" data-bind="attr: { href: '<%= Url.Action("Create", "Questions") %>?categoryId=' + Id() + '&inProgress=true' }" target="_blank">Add some.</a> </p>
                </div>
            <!-- /ko -->
            <!-- ko if: Questions().length > 0 -->
                <div class="question space-above">
                    <a class="add-question" data-bind="text: 'New question for ' + Name(), attr: { href: '<%= Url.Action("Create", "Questions") %>?categoryId=' + Id() + '&inProgress=true' }" target="_blank"></a>
                </div>
            <!-- /ko -->
        </div>
    <!-- /ko -->

Solution

  • The reason your bindings are getting completely deleted and re-rendered with every refresh, even when nothing has changed is because you're calling ko.applyBindings every refresh.

    You do not want ko.applyBindings inside of your refresh function. You want to call ko.applyBindings once and that's it. After that, KO will handle updating the DOM when the data updates (or vice versa). You just want:

    var testDate = { ... };
    function ViewModel () { ... }
    var viewModel = new ViewModel();
    ko.applyBindings(viewModel);
    viewModel.refresh(function () {
      // ko.applyBindings(this); <- Get rid of this here, no bueno
      $(document).on("click", ....);
    });
    

    And that's it. Every time you've been calling refresh, you get data from the server that updates the viewModel. KO in turn will update the DOM as necessary if values update. When you call applyBindings after all that, KO goes through the DOM with a broadsword, updating all of your bindings whether it needs to or not.


    Quick jQuery Tip:

    $.get(...) returns a promise. There are three important functions that a promise returns, .done(), .fail(), and .always(). For your this.refresh() function, return the $.get result like this:

    this.refresh = function () {
      return $.get(...);
    };
    

    Then what ever calls it would do this:

    viewModel.refresh().done(function(data){
      // Callback function
    });
    

    Now you don't have to pass a callback function, check if callback is type of function, or worry about handling the callback function in the event of a failure. It's also universal and you can continue returning the promise down a chain of functions that will all wait til the $.get request is resolved before they perform their function.