Knock Me Out

Мысли, идеи и обсуждения Knockout.js

Служебные функции в KnockoutJS

| Комментарии

Когда работаешь с KnockoutJS, возникает потребность в использовании множества однообразных операций, которые необходимо выполнять на обьектах модели представления. Внутри, Knockout есть множество служебных функций которые библиотека использует для своих нужд, однако большинство из этих функций могут быть полезными и для тех кто использует библиотеку. Я хочу на примере показать несколько моих любимых функций.

Обработка данных полученных с сервера

Одна из первых задач с которой обычно приходится сталкиваться - это преобразование данных, которые получены с сервера, в удобный формат для работы Knockout. Предположим, мы получаем JSON строку с сервера, которая не преобразовывается автоматически в JavaScript объект.

1
var JSONdataFromServer = '[{"name":"Peach","category":"Fruits","price":1},{"name":"Plum","category":"Fruits","price":0.75},{"name":"Donut","category":"Bread","price":1.5},{"name":"Milk","category":"Dairy","price":4.50}]';

В Knockout есть служебная функция ko.utils.parseJSON которая будет пытаться сделать JSON.parse если это возможно или вернется к оценке его как функцию строки для старых браузеров. Итак, мы можем преобразовать JSON строку в объект следующим образом:

1
var dataFromServer = ko.utils.parseJson(JSONdataFromServer);

Теперь у нас есть JavaScript объект, но чтобы сделать что то рабочее в Knockout, нам возможно понадобится преобразовать свойства в observables and и возможно добавить некоторые вычислимые observables. One option for doing this is Knockout’s mapping plugin. По умолчанию, все массивы преобразуются в observableArrays and и все другие свойства в observables. Также есть события для контроля создания в более содержательных примерах. Тем не менее, для достаточно простых сценариев, это достаточно легко сделать отображение:

1
2
3
4
5
6
7
8
9
10
11
12
13
function Item(name, category, price) {
    this.name = ko.observable(name);
    this.category = ko.observable(category);
    this.price = ko.observable(price);
    this.priceWithTax = ko.dependentObservable(function() {
        return (this.price() * 1.05).toFixed(2);
    }, this);
}

//do some basic mapping (without mapping plugin)
var mappedData = ko.utils.arrayMap(dataFromServer, function(item) {
    return new Item(item.name, item.category, item.price);
});

Итак, у нас есть конструктор для типа Item, который создает наши observables и добавляет вычислимые observable для показа цены с учетом налога. Мы создаем отображаемый массив объектов используя ko.utils.arrayMap, которая вызывает функцию для каждого элемента массива and и добавляет результат функции в новый массив, который будет возвращен.

Работа с массивами в вашей модели представления

Теперь у нас есть массив элементов, который готов к работе Knockout. В процессе работы с массивами возникает множество однотипных ситуаций в которых могут быть полезны служебные функции.

Обход массива

Suppose we want to create a computed observable at the root view model level to track the grand total for all items. We will need to sum the priceWithTax value for all items. We could easily write a for loop to do this (or if we are using jQuery, $.each), but Knockout provides a utility function ko.utils.arrayForEach for this purpose. We can use this function to get our total by doing something like:

1
2
3
4
5
6
7
8
9
10
viewModel.total = ko.computed(function() {
    var total = 0;
    ko.utils.arrayForEach(this.items(), function(item) {
        var value = parseFloat(item.priceWithTax());
        if (!isNaN(value)) {
            total += value;
        }
    });
    return total.toFixed(2);
}, viewModel);

Фильтрация массива

Далее, мы хотим дать пользователю возможность фильтровать список элементов по имени. We could create a computed observable that returns the matching subset of the original array of items. Knockout’s ko.utils.arrayFilter provides an implementation of this functionality that allows us to pass in an array and control which items are included based on the result of the function executed on each item. For example, suppose we bind a textbox to a filter observable and use it to get our filtered items:

1
2
3
4
5
6
7
8
9
10
11
//filter the items using the filter text
viewModel.filteredItems = ko.computed(function() {
    var filter = this.filter().toLowerCase();
    if (!filter) {
        return this.items();
    } else {
        return ko.utils.arrayFilter(this.items(), function(item) {
            return ko.utils.stringStartsWith(item.name().toLowerCase(), filter);
        });
    }
}, viewModel);

We pass our array of items into ko.utils.arrayFilter and return true only when the item’s name starts with the value of the filter observable (ko.utils.stringStartsWith provides an easy way to do this). Now we can bind our display to filteredItems and it will react to changes in the filter textbox. We would most likely want the display to update on each keystroke, so on our input field we can specify the binding like:

note: ko.utils.stringStartsWith is not exported in the minified KO file`. The code is simple enough though to replicate.

1

Filter: data-bind="value: filter, valueUpdate: 'afterkeydown'" />

Поиск элемента в массиве

Besides being able to filter the display, let’s say that we also want to be able to enter a search term and highlight the first matching entry by name. Knockout provides ko.utils.arrayFirst that will execute a function against each item in our array and return the first item where the function evaluates to true. Similar to the filteredItems computed observable, we can create one that returns the first match from our search field:

1
2
3
4
5
6
7
8
9
10
11
//identify the first matching item by name
viewModel.firstMatch = ko.computed(function() {
    var search = this.search().toLowerCase();
    if (!search) {
        return null;
    } else {
        return ko.utils.arrayFirst(this.filteredItems(), function(item) {
            return ko.utils.stringStartsWith(item.name().toLowerCase(), search);
        });
    }
}, viewModel);

Now we can use viewModel.firstMatch in our template to compare it against the item ($data) that we are sending through our template and style the matching row appropriately.

Flattening an array

Suppose we needed an array that contains all of the categories currently being used in our items. Knockout’s ko.utils.arrayMap that we used earlier is a nice way to take an array of objects and generate a flattened structure.

1
2
3
4
5
6
7
//get a list of used categories
viewModel.justCategories = ko.computed(function() {
    var categories = ko.utils.arrayMap(this.items(), function(item) {
        return item.category();
    });
    return categories.sort();
}, viewModel);

Our justCategories computed observable now contains an array of the used categories.

Получения всех уникальных значений в массиве

While we now have a list of categories in justCategories, what we might really want is a list of the unique categories represented in our items. Knockout’s ko.utils.arrayGetDistinctValues takes in an array and returns an array that contains only the unique values.

1
2
3
4
//get a unique list of used categories
viewModel.uniqueCategories = ko.dependentObservable(function() {
    return ko.utils.arrayGetDistinctValues(viewModel.justCategories()).sort();
}, viewModel);

Сравнение двух массивов

We have a list of the available categories and we have a list of the unique categories that are being used. Suppose that we want to provide a list of the categories that are missing from our data. Knockout’s ko.utils.compareArrays provides functionality to compare two arrays and indicate which items are different. For example:

1
2
3
4
5
6
7
8
9
10
11
12
13
//find any unused categories
viewModel.missingCategories = ko.dependentObservable(function() {
    //find out the categories that are missing from uniqueNames
    var differences = ko.utils.compareArrays(viewModel.categories, viewModel.uniqueCategories());
    //return a flat list of differences
    var results = [];
    ko.utils.arrayForEach(differences, function(difference) {
        if (difference.status === "deleted") {
            results.push(difference.value);
        }
    });
    return results;
}, viewModel)

The result of ko.utils.compareArrays is an array that contains items with a status property (added, deleted, or retained) and a value property holding the original item.

Отправка данных на сервер

At some point, most applications will need to post data back to the server to persist changes to storage. Our view model is likely not quite in a suitable format for use on the server.

Converting our view model using ko.toJS or ko.toJSON

I hesitate to call ko.toJS and ko.toJSON utilities, because they seem to be a necessity as soon as you need to package up some or all of your view model for transporting it back to the server. Usually a first attempt at doing this would involve calling something like JSON.stringify(viewModel). After seeing the result, you are immediately reminded that observables are actually functions and that JSON does not contain functions, so the observables are ignored by JSON serializers.

Luckily, Knockout includes these helper functions to facilitate transforming all of your observables and computed observables into normal properties on a JavaScript object.

ko.toJS – this function creates a copy of the object that you pass to it with all observables and computed observables converted into normal properties that are set to the current value.

ko.toJSON – this function first does ko.toJS on your object and then converts that object to a JSON string representation that is suitable for transferring back to the server. Note: this uses the browser’s native JSON.stringify() function, which is not available in some older browsers. One way to overcome this is by referencing a script from here.

Removing properties from our converted array

We can use ko.toJS to turn our observables into a plain object, but we may need to do some additional tweaking before it is ready to send to the server. A common scenario is that your view model contains various computed observables for display that are not expected by your server-side code. Again ko.utils.arrayMap is useful to trim the fat off of your objects:

1
2
3
4
5
var items = ko.toJS(this.items);
var mappedItems = ko.utils.arrayMap(items, function(item) {
    delete item.priceWithTax;
    return item;
});

Now our items are in a proper format for posting to our server.

Knockout contains a number of utility functions that are useful for manipulating your view model. These were the ones that I find most useful. Check out the Knockout source to see all of the available utility functions.

Here is a completed sample demonstrating these utility functions:

Link to full sample on jsFiddle.net

Comments