Save several Backbone models at once

2019-03-15 14:31发布

I have a Backbone collection with a load of models.

Whenever a specific attribute is set on a model and it is saved, a load of calculations fire off and the UI rerenders.

But, I want to be able to set attributes on several models at once and only do the saving and rerendering once they are all set. Of course I don't want to make several http requests for one operation and definitely dont want to have to rerender the interface ten times.

I was hoping to find a save method on Backbone.Collection that would work out which models hasChanged(), whack them together as json and send off to the back end. The rerendering could then be triggered by an event on the collection. No such luck.

This seems like a pretty common requirement, so am wondering why Backbone doesn't implement. Does this go against a RESTful architecture, to save several things to a single endpoint? If so, so what? There's no way it's practical to make 1000 requests to persist 1000 small items.

So, is the only solution to augment Backbone.Collection with my own save method that iterates over all its models and builds up the json for all the ones that have changed and sends that off to the back end? or does anyone have a neater solution (or am I just missing something!)?

3条回答
祖国的老花朵
2楼-- · 2019-03-15 15:08

I have ended up augmenting Backbone.Collection with a couple of methods to handle this.

The saveChangeMethod creates a dummy model to be passed to Backbone.sync. All backbone's sync method needs from a model is its url property and toJSON method, so we can easily knock this up.

Internally, a model's toJSON method only returns a copy of it's attributes (to be sent to the server), so we can happily just use a toJSON method that just returns the array of models. Backbone.sync stringifies this, which gives us just the attribute data.

On success, saveChanged fires off events on the collection to be handled once. Have chucked in a bit of code that gets it firing specific events once for each of the attributes that have changed in any of the batch's models.

Backbone.Collection.prototype.saveChanged = function () {
    var me = this,
        changed = me.getChanged(),
        dummy = {
            url: this.url,
            toJSON: function () {
                return changed.models;
            }
        },
        options = {
            success: function (model, resp, xhr) {
                for (var i = 0; i < changed.models.length; i++) {
                    changed.models[i].chnageSilently();
                }
                for (var attr in changed.attributes) {
                    me.trigger("batchchange:" + attr);
                }
                me.trigger("batchsync", changed);
            }
        };
    return Backbone.sync("update", dummy, options);
}

We then just need the getChanged() method on a collection. This returns an object with 2 properties, an array of the changed models and an object flagging which attributes have changed:

Backbone.Collection.prototype.getChanged = function () {
    var models = [],
        changedAttributes = {};
    for (var i = 0; i < this.models.length; i++) {
        if (this.models[i].hasChanged()) {
            _.extend(changedAttributes, this.models[i].changedAttributes());
            models.push(this.models[i]);
        }
    }
    return models.length ? {models: models, attributes: changedAttributes} : null;
}

Although this is slight abuse of the intended use of backbones 'changed model' paradigm, the whole point of batching is that we don't want anything to happen (i.e. any events to fire off) when a model is changed.

We therefore have to pass {silent: true} to the model's set() method, so it makes sense to use backbone's hasChanged() to flag models waiting to be saved. Of course this would be problematic if you were changing models silently for other purposes - collection.saveChanged() would save these too, so it is worth considering setting an alternative flag.

In any case, if we are doing this way, when saving, we need to make sure backbone now thinks the models haven't changed (without triggering their change events), so we need to manually manipulate the model as if it hadn't been changed. The saveChanged() method iterates over our changed models and calls this changeSilently() method on the model, which is basically just Backbone's model.change() method without the triggers:

Backbone.Model.prototype.changeSilently = function () {
    var options = {},
    changing = this._changing;
    this._changing = true;
    for (var attr in this._silent) this._pending[attr] = true;
    this._silent = {};
    if (changing) return this;

    while (!_.isEmpty(this._pending)) {
        this._pending = {};
        for (var attr in this.changed) {
        if (this._pending[attr] || this._silent[attr]) continue;
        delete this.changed[attr];
        }
        this._previousAttributes = _.clone(this.attributes);
    }
    this._changing = false;
    return this;
}

Usage:

model1.set({key: value}, {silent: true});
model2.set({key: value}, {silent: true});
model3.set({key: value}, {silent: true});
collection.saveChanged();

RE. RESTfulness.. It's not quite right to do a PUT to the collection's endpoint to change 'some' of its records. Technically a PUT should replace the entire collection, though until my application ever actually needs to replace an entire collection, I am happy to take the pragmatic approach.

查看更多
看我几分像从前
3楼-- · 2019-03-15 15:09

You can define a new resource to accomplish this kind of behavior, you can call it MyModelBatch.

You need to implement a new resource in you server side that is able to digest an Array of models and execute the proper action: CREATE, UPDATE and DESTROY.

Also you need to implement a Model in your Backbone client side with one attribute which is the Array of Models and a special url that doesn't make use the id.

About the re-render thing I suggest you to try to have one View by each Model so there will be as much renders as Models have changed but they will be detail re-renders without duplication.

查看更多
可以哭但决不认输i
4楼-- · 2019-03-15 15:17

This is what i came up with.

Backbone.Collection.extend({
    saveAll: function(models, key, val, options) {

        var attrs, xhr, wait, that = this;

        var transport = {
            url: this.url,
            models: [],
            toJSON: function () {
                return { models: this.models };
            },
            trigger: function(){
                return that.trigger.apply(that, arguments);
            }
        };

        if(models == null){
            models = this.models;
        }

        // Handle both `"key", value` and `{key: value}` -style arguments.
        if (key == null || typeof key === 'object') {
            attrs = key;
            options = val;
        } else {
            (attrs = {})[key] = val;
        }

        options = _.extend({validate: true}, options);
        wait = options.wait;

        // After a successful server-side save, the client is (optionally)
        // updated with the server-side state.
        if (options.parse === void 0) options.parse = true;

        var triggers = [];

        _.each(models, function(model){

            var attributes = model.attributes;

            // If we're not waiting and attributes exist, save acts as
            // `set(attr).save(null, opts)` with validation. Otherwise, check if
            // the model will be valid when the attributes, if any, are set.
            if (attrs && !wait) {
                if (!model.set(attrs, options)) return false;
            } else {
                if (!model._validate(attrs, options)) return false;
            }

            // Set temporary attributes if `{wait: true}`.
            if (attrs && wait) {
                model.attributes = _.extend({}, attributes, attrs);
            }

            transport.models.push(model.toJSON());

            triggers.push(function(resp){
                if(resp.errors){
                    model.trigger('error', model, resp, options);
                } else {
                    // Ensure attributes are restored during synchronous saves.
                    model.attributes = attributes;
                    var serverAttrs = options.parse ? model.parse(resp, options) : resp;
                    if (wait) serverAttrs = _.extend(attrs || {}, serverAttrs);
                    if (_.isObject(serverAttrs) && !model.set(serverAttrs, options)) {
                        return false;
                    }
                    model.trigger('sync', model, resp, options);
                }
            });

            // Restore attributes.
            if (attrs && wait) model.attributes = attributes;
        });

        var success = options.success;
        options.success = function(resp) {
            _.each(triggers, function(trigger, i){
                trigger.call(options.context, resp[i]);
            });
            if (success) success.call(options.context, models, resp, options);
        };
        return this.sync('create', transport, options);
    }
});
查看更多
登录 后发表回答