[javascriptMVC] <翻譯+心得> Jmvc導覽(2) - $.Model

- - posted in javascriptmvc | Comments

Model

javascriptMVC的model和相關連plugin提供許多組織model資料的工具如驗證(validation),關聯(association),列表(list)還有其他更多。但核心功能專注在service encapsulation(服務包裝),型態轉換(type converstion),還有事件關聯(event)上。

可以這麼說:沒有跟伺服器溝通的必要就不需用到model。
相反地請將所有跟伺服器資料交換的動作交給model用一致的介面來處理。

屬性和觀察可能性 – Attributes and Observables

model層一個絕對的重要性在於存取物件資料的屬性(property), 和監聽model個體的改變。這就是觀察者模式而且正是MVC的重要環節 – view監聽model的改變。

幸運的是,JavascriptMVC使得任何資料都可以容易的被觀察(obsevable)。一個好的例子是翻頁機制。一個頁面通常有許多的翻譯控制項,例如,一個控制項提供前一頁另一個提供後一頁。另一個控制項可能提供顯示這一頁的部份資料(例如顯示1-20項)。所有的頁面控制項會需要同樣的資料:

  • offset – 要顯示的第一個項目的位置
  • limit – 總共要顯示多少項目
  • count – 全部項目的數量

We can model this data with JavaScriptMVC’s $.Model like:

var paginate = new $.Model({
    offset: 0,
    limit: 20,
    count: 200
});

The paginate variable is now observable. We can pass it to pagination controls that can read from, write to, and listen for property changes. You can read properties like normal or using the model.attr(NAME) method:

assertEqual( paginate.offset, 0 );
assertEqual( paginate.attr('limit') , 20 );

If we clicked the next button, we need to increment the offset. Change property values with model.attr(NAME, VALUE). The following moves the offset to the next page:

paginate.attr('offset',20);  

When paginate’s state is changed by one control, the other controls need to be notified. You can bind to a specific attribute change with model.bind(ATTR, success( ev, newVal ) ) and update the control:

paginate.bind('offset', function(ev, newVal){
    $('#details').text( 'Showing items ' + (newVal+1 )+ '-' + this.count )
})

You can also listen to any attribute change by binding to the ‘updated.attr’ event:

paginate.bind('updated.attr', function(ev, newVal){
    $('#details').text( 'Showing items ' + (newVal+1 )+ '-' + this.count )
})

The following is a next-previous jQuery plugin that accepts paginate data:

$.fn.nextPrev = function(paginate){
    this.delegate('.next','click', function(){
        var nextOffset = paginate.offset+paginate.limit;
        if( nextOffset < paginate.count){
            paginate.attr('offset', nextOffset );
        }
    })
    this.delegate('.prev','click', function(){
        var nextOffset = paginate.offset-paginate.limit;
        if( 0 < paginate.offset ){
            paginate.attr('offset', Math.max(0, nextOffset) );
        }
    });
    var self = this;
    paginate.bind('updated.attr', function(){
        var next = self.find('.next'),
        prev = self.find('.prev');
        if( this.offset == 0 ){
            prev.removeClass('enabled');
        } else { 
            prev.removeClass('disabled');
        }
        if( this.offset > this.count - this.limit ){
            next.removeClass('enabled');
        } else { 
            next.removeClass('disabled');
        }
    })
};

There are a few problems with this plugin. First, if the control is removed from the page, it is not unbinding itself from paginate. We’ll address this when we discuss controllers.

Second, the logic protecting a negative offset or offset above the total count is done in the plugin. This logic should be done in the model. To fix this problem, we’ll need to add additional constraints to limit what values limit, offset, and count can be. We’ll need to create a pagination class.

Extending Model

JavaScriptMVC’s model inherits from $.Class. Thus, you create a model class by inheriting from $.Model(NAME, [STATIC,] PROTOTYPE):

$.Model('Paginate',{
    staticProperty: 'foo'
},{
    prototypeProperty: 'bar'
})

There are a few ways to make the Paginate model more useful. First, by adding setter methods, we can limit what values count and offset can be set to.

Setters

Settter methods are model prototype methods that are named setNAME. They get called with the val passed to model.attr(NAME, val) and a success and error callback. Typically, the method should return the value that should be set on the model instance or call error with an error message. Success is used for asynchronous setters.

The following paginate model uses setters to prevent negative counts the offset from exceeding the count by adding setCount and setOffset instance methods.

$.Model('Paginate',{
    setCount : function(newCount, success, error){
        return newCount < 0 ? 0 : newCount;
    },
    setOffset : function(newOffset, success, error){
        return newOffset < 0 ? 0 : Math.min(newOffset, !isNaN(this.count - 1) ? this.count : Infinity )
    }
})

Now the nextPrev plugin can set offset with reckless abandon:

this.delegate('.next','click', function(){
    paginate.attr('offset', paginate.offset+paginate.limit);
})
this.delegate('.prev','click', function(){
    paginate.attr('offset', paginate.offset-paginate.limit );
});

Defaults

We can add default values to Paginate instances by setting the static defaults property. When a new paginate instance is created, if no value is provided, it initializes with the default value.

$.Model('Paginate',{
    defaults : {
        count: Infinity,
        offset: 0,
        limit: 100
    }
},{
    setCount : function(newCount, success, error){ ... },
    setOffset : function(newOffset, success, error){ ... }
})

var paginate = new Paginate({count: 500});
assertEqual(paginate.limit, 100);
assertEqual(paginate.count, 500);

This is getting sexy, but the Paginate model can make it even easier to move to the next and previous page and know if it’s possible by adding helper methods.

Helper methods

Helper methods are prototype methods that help set or get useful data on model instances. The following, completed, Paginate model includes a next and prev method that will move to the next and previous page if possible. It also provides a canNext and canPrev method that returns if the instance can move to the next page or not.

$.Model('Paginate',{
    defaults : {
        count: Infinity,
        offset: 0,
        limit: 100
    }
},{
    setCount : function( newCount ){
        return Math.max(0, newCount  );
    },
    setOffset : function( newOffset ){
        return Math.max( 0 , Math.min(newOffset, this.count ) )
    },
    next : function(){
        this.attr('offset', this.offset+this.limit);
    },
    prev : function(){
        this.attr('offset', this.offset - this.limit )
    },
    canNext : function(){
        return this.offset > this.count - this.limit
    },
    canPrev : function(){
        return this.offset > 0
    }
})

Thus, our jQuery widget becomes much more refined:

$.fn.nextPrev = function(paginate){
    this.delegate('.next','click', function(){
        paginate.attr('offset', paginate.offset+paginate.limit);
    })
    this.delegate('.prev','click', function(){
        paginate.attr('offset', paginate.offset-paginate.limit );
    });
    var self = this;
    paginate.bind('updated.attr', function(){
        self.find('.prev')[paginate.canPrev() ? 'addClass' : 'removeClass']('enabled')
        self.find('.next')[paginate.canNext() ? 'addClass' : 'removeClass']('enabled');
    })
};

Service Encapsulation

We’ve just seen how $.Model is useful for modeling client side state. However, for most applications, the critical data is on the server, not on the client. The client needs to create, retrieve, update and delete (CRUD) data on the server. Maintaining the duality of data on the client and server is tricky business. $.Model is used to simplify this problem.

$.Model is extremely flexible. It can be made to work with all sorts of services types and data types. This book covers only how $.Model works with the most common and popular type of service and data type: Representational State Transfer (REST) and JSON.

A REST service uses urls and the HTTP verbs POST, GET, PUT, DELETE to create, retrieve, update, and delete data respectively. For example, a tasks service that allowed you to create, retrieve, update and delete tasks might look like:

ACTIONVERBURLBODYRESPONSE
Create a taskPOST/tasksname=do the dishes
{
    "id"       : 2,
        "name"     : "do the dishes",
        "acl"      : "rw" ,
        "createdAt": 1303173531164 // April 18 2011
}
Get a taskGET/task/2
{
    "id"       : 2,
        "name"     : "do the dishes",
        "acl"      : "rw" ,
        "createdAt": 1303173531164 // April 18 2011
}
Get tasksGET/tasks
[{
    "id"       : 1,
        "name"     : "take out trash",
        "acl"      : "r",
        "createdAt": 1303000731164 // April 16 2011
},
{
    "id"       : 2,
    "name"     : "do the dishes",
    "acl"      : "rw" ,
    "createdAt": 1303173531164 // April 18 2011
}]
Update a taskPUT/task/2name=take out recycling
{
    "id"       : 2,
    "name"     : "take out recycling",
    "acl"      : "rw" ,
    "createdAt": 1303173531164 // April 18 2011
}
Delete a taskDELETE/task/2
{}

TODO: We can label the urls

The following connects to task services, letting us create, retrieve, update and delete tasks from the server:

$.Model("Task",{
    create  : "POST /tasks.json",
    findOne : "GET /tasks/{id}.json",
    findAll : "GET /tasks.json",
    update  : "PUT /tasks/{id}.json",
    destroy : "DELETE /tasks/{id}.json"
},{ });

The following table details how to use the task model to CRUD tasks.

rst change the attributes of a model instance with attr. Then call save().

Save takes the same arguments and returns the same deferred as the create task case.

ACTIONCODEDESCRIPTION
Create a task
new Task({ name: 'do the dishes'}).save( 
            success( task, data ), 
            error( jqXHR) 
            ) -> taskDeferred

To create an instance of a model on the server, first create an instance with new Model(attributes). Then call save().

Save checks if the task has an id. In this case it does not so save makes a create request with the task’s attributes. Save takes two parameters:

  • success - a function that gets called if the save is successful. Success gets called with the task instance and the data returned by the server.
  • error - a function that gets called if there is an error with the request. It gets called with jQuery’s wrapped XHR object.
Save returns a deferred that resolves to the created task.
Get a task
Task.findOne(params, 
        success( task ), 
        error( jqXHR) 
        ) -> taskDeferred
Retrieves a single task from the server. It takes three parameters:
  • params - data to pass to the server. Typically an id like: {id: 2}.
  • success - a function that gets called if the request is succesful. Success gets called with the task instance.
  • error - a function that gets called if there is an error with the request.
findOne returns a deferred that resolves to the task.
Get tasks
Destroy a task
task.destroy( 
            success( task, data ), 
            error( jqXHR) 
            ) -> taskDeferred

Destroys a task on the server. Destroy takes two parameters:

  • success - a function that gets called if the save is successful. Success gets called with the task instance and the data returned by the server.
  • error - a function that gets called if there is an error with the request.
Destroy returns a deferred that resolves to the destroyed task.
The <code>Task</code> model has essentially become a contract to our services!

Type Conversion

Did you notice how the server responded with createdAt values as numbers like 1303173531164. This number is actually April 18th, 2011. Instead of getting a number back from task.createdAt, it would be much more useful if it returns a JavaScript date created with new Date(1303173531164). We could do this with a setCreatedAt setter. But, if we have lots of date types, this will quickly get repetitive.

To make this easy, $.Model lets you define the type of an attribute and a converter function for those types. Set the type of attributes on the static attributes object and converter methods on the static convert object.

$.Model('Task',{
    attributes : {
        createdAt : 'date'
    },
    convert : {
        date : function(date){
        return typeof date == 'number' ? new Date(date) : date;
        }
    }
},{});

Task now converts createdAt to a Date type. To list the year of each task, write:

Task.findAll({}, function(tasks){
    $.each(tasks, function(){
        console.log( "Year = "+this.createdAt.fullYear() )
    })
});

CRUD Events

Model publishes events when an instance has been created, updated, or destroyed. You can listen to these events globally on the Model or on an individual model instance. Use MODEL.bind(EVENT, callback( ev, instance ) ) to listen for created, updated, or destroyed events.

Lets say we wanted to know when a task is created and add it to the page. After it’s been added to the page, we’ll listen for updates on that task to make sure we are showing its name correctly. We can do that like:

Task.bind('created', function(ev, task){
    var el = $('<li>').html(todo.name);
    el.appendTo($('#todos'));

    task.bind('updated', function(){
        el.html(this.name)
    }).bind('destroyed', function(){
        el.remove()
    })
})

Comments