Статьи

Создание больших, поддерживаемых и тестируемых приложений Knockout.js

Knockout.js — это популярный JavaScript-фреймворк с открытым исходным кодом (MIT), созданный Стивом Сандерсеном . Его веб-сайт предоставляет отличную информацию и демонстрации о том, как создавать простые приложения, но, к сожалению, этого не происходит для более крупных приложений. Давайте заполнить некоторые из этих пробелов!


AMD — это формат модуля JavaScript, и одна из самых популярных (если не самая) фреймворков — это http://requirejs.org by https://twitter.com/jrburke . Он состоит из двух глобальных функций, называемых require() и define() , хотя require.js также включает в себя начальный файл JavaScript, такой как main.js

1
<script src=»js/require-jquery.min.js» data-main=»js/main»></script>

Существует прежде всего два вида require.js: ванильный файл require.js и один, включающий jQuery ( require-jquery ). Естественно, последний используется преимущественно на веб-сайтах с поддержкой jQuery. После добавления одного из этих файлов на свою страницу вы можете добавить следующий код в файл main.js :

1
2
3
require( [ «https://twitter.com/jrburkeapp» ], function( App ) {
    App.init();
})

Функция require() обычно используется в файле main.js , но вы можете использовать ее для непосредственного включения модуля в любом месте. Он принимает два аргумента: список зависимостей и функцию обратного вызова.

Функция обратного вызова выполняется, когда все зависимости заканчивают загрузку, и аргументы, переданные функции обратного вызова, являются объектами, необходимыми в вышеупомянутом массиве.

Важно отметить, что зависимости загружаются асинхронно. Не все библиотеки совместимы с AMD, но require.js предоставляет механизм, позволяющий подобрать эти типы библиотек для их загрузки.

Для этого кода требуется модуль с именем app , который может выглядеть следующим образом:

1
2
3
4
5
6
7
8
9
define( [ «jquery», «ko» ], function( $, ko ) {
    var App = function(){};
 
    App.prototype.init = function() {
        // INIT ALL TEH THINGS
    };
 
    return new App();
});

Функция define() предназначена для определения модуля . Он принимает три аргумента: имя модуля (который обычно не включается), список зависимостей и функцию обратного вызова. Функция define() позволяет разделить приложение на множество модулей, каждый из которых имеет определенную функцию. Это способствует разъединению и разделению проблем, поскольку каждый модуль имеет свой собственный набор конкретных обязанностей.

Knockout готов для AMD и определяет себя как анонимный модуль. Вам не нужно подбирать это; просто включите это в свои пути. Большинство готовых для AMD плагинов Knockout перечисляют его как «knockout», а не «ko», но вы можете использовать любое значение:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
require.config({
    paths: {
        ko: «vendor/knockout-min»,
        postal: «vendor/postal»,
        underscore: «vendor/underscore-min»,
        amplify: «vendor/amplify»
    },
    shim: {
        underscore: {
            exports: «_»
        },
        amplify: {
            exports: «amplify»
        }
    },
    baseUrl: «/js»
});

Этот код идет вверху main.js Опция paths определяет карту общих модулей, которые загружаются с именем ключа, а не с использованием полного имени файла.

Опция shim использует ключ, определенный в paths и может иметь два специальных ключа, которые называются deps и deps . Ключ exports определяет, что возвращает модуль shimmed, а deps определяет другие модули, от которых может зависеть модуль shimmed. Например, прокладка jQuery Validate может выглядеть следующим образом:

1
2
3
4
5
6
shim: {
    // …
    «jquery-validate»: {
        deps: [ «jquery» ]
    }
}

Распространено включать весь необходимый JavaScript в одностраничное приложение. Таким образом, вы можете определить конфигурацию и начальные требования одностраничного приложения в main.js следующим образом:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
require.config({
    paths: {
        ko: «vendor/knockout-min»,
        postal: «vendor/postal»,
        underscore: «vendor/underscore-min»,
        amplify: «vendor/amplify»
    },
    shim: {
        ko: {
            exports: «ko»
        },
        underscore: {
            exports: «_»
        },
        amplify: {
            exports: «amplify»
        }
    },
    baseUrl: «/js»
});
 
require( [ «https://twitter.com/jrburkeapp» ], function( App ) {
    App.init();
})

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

В оставшейся части статьи предполагается, что вы создаете многостраничное приложение. Я переименую main.js в common.js и common.js необходимый файл require.config в приведенный выше пример в файл. Это чисто для семантики.

Теперь мне потребуются common.js в моих файлах, например:

01
02
03
04
05
06
07
08
09
10
11
<script src=»js/require-jquery.js»></script>
    <script>
        require( [ «./js/common» ], function () {
            //js/common sets the baseUrl to be js/ so
            //can just ask for ‘app/main1’ here instead
            //of ‘js/app/main1’
            require( [ «pages/index» ] );
        });
    </script>
</body>
</html>

require.config функция require.config , требующая главный файл для конкретной страницы. Основной файл pages/index может выглядеть следующим образом:

1
2
3
4
5
6
require( [ «app», «postal», «ko», «viewModels/indexViewModel» ], function( app, postal, ko, IndexViewModel ) {
    window.app = app;
    window.postal = postal;
 
    ko.applyBindings( new IndexViewModel() );
});

Этот модуль page/index теперь отвечает за загрузку всего необходимого кода для страницы index.html . Вы можете добавить другие основные файлы в каталог страниц, которые также отвечают за загрузку их зависимых модулей. Это позволяет разбивать многостраничные приложения на более мелкие части, избегая при этом ненужных включений скриптов (например, включая JavaScript для index.html на странице about.html ).


Давайте напишем пример приложения с использованием этого подхода. Он отобразит список доступных марок пива и позволит нам выбрать ваши любимые, нажав на их названия. Вот структура папок приложения:

"Folder structure"

Давайте сначала посмотрим на HTML-разметку index.html :

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
<section id=»main»>
    <section id=»container»>
        <form class=»search» data-bind=»submit: doSearch»>
            <input type=»text» name=»search» placeholder=»Search» data-bind=»value: search, valueUpdate: ‘afterkeydown'» />
            <ul data-bind=»foreach: beerListFiltered»>
                <li data-bind=»text: name, click: $parent.addToFavorites»></li>
            </ul>
        </form>
 
        <aside id=»favorites»>
            <h3>Favorites</h3>
            <ul data-bind=»foreach: favorites»>
                <li data-bind=»text: name, click: $parent.removeFromFavorites»></li>
            </ul>
        </aside>
    </section>
</section>
 
<!— import(«templates/list.html») —>
 
<script src=»js/require-jquery.js»></script>
<script>
    require( [ «./js/common» ], function (common) {
        //js/common sets the baseUrl to be js/ so
        //can just ask for ‘app/main1’ here instead
        //of ‘js/app/main1’
        require( [ «pages/index» ] );
    });
</script>

Структура нашего приложения использует несколько «страниц» или «сетей» в каталоге pages . Эти отдельные страницы отвечают за инициализацию каждой страницы в приложении.

ViewModels отвечают за настройку привязок Knockout.

В папке ViewModels находится основная логика приложения Knockout.js. Например, IndexViewModel выглядит следующим образом:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// https://github.com/jcreamer898/NetTutsKnockout/blob/master/lib/js/viewModels/indexViewModel.js
define( [
    «ko»,
    «underscore»,
    «postal»,
    «models/beer»,
    «models/baseViewModel»,
    «shared/bus» ], function ( ko, _, postal, Beer, BaseViewModel, bus ) {
 
    var IndexViewModel = function() {
        this.beers = [];
        this.search = «»;
 
        BaseViewModel.apply( this, arguments );
    };
 
    _.extend(IndexViewModel.prototype, BaseViewModel.prototype, {
        initialize: function() { // … },
 
        filterBeers: function() { /* … */ },
 
        parse: function( beers ) { /* … */ },
 
        setupSubscriptions: function() { /* … */ },
 
        addToFavorites: function() { /* … */ },
 
        removeFromFavorites: function() { /* … */ }
    });
 
    return IndexViewModel;
});

IndexViewModel определяет несколько основных зависимостей в верхней части файла, и он наследует BaseViewModel чтобы инициализировать его члены как наблюдаемые объекты knockout.js (мы обсудим это в ближайшее время).

Далее, вместо того, чтобы определять все различные функции ViewModel как члены экземпляра, функция extend() underscore.js расширяет prototype типа данных IndexViewModel .

Наследование — это форма повторного использования кода, позволяющая вам повторно использовать функциональность между аналогичными типами объектов вместо переписывания этой функциональности. Таким образом, полезно определить базовую модель, которую могут наследовать другие модели. В нашем случае нашей базовой моделью является BaseViewModel :

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
var BaseViewModel = function( options ) {
    this._setup( options );
 
    this.initialize.call( this, options );
};
 
_.extend( BaseViewModel.prototype, {
    initialize: function() {},
 
    _setup: function( options ) {
        var prop;
 
        options = options ||
 
        for( prop in this ) {
            if ( this.hasOwnProperty( prop ) ) {
                if ( options[ prop ] ) {
                    this[ prop ] = _.isArray( options[ prop ] ) ?
                        ko.observableArray( options[ prop ] ) :
                        ko.observable( options[ prop ] );
                }
                else {
                    this[ prop ] = _.isArray( this[ prop ] ) ?
                        ko.observableArray( this[ prop ] ) :
                        ko.observable( this[ prop ] );
                }
            }
        }
    }
});
 
return BaseViewModel;

Тип BaseViewModel определяет два метода в своем prototype . Первым является initialize() , который должен быть переопределен в подтипах. Вторым является _setup() , который устанавливает объект для привязки данных.

Метод _setup перебирает свойства объекта. Если свойство является массивом, оно устанавливает свойство как observableArray . Все, кроме массива, становится observable . Он также проверяет любые начальные значения свойств, используя их в качестве значений по умолчанию, если это необходимо. Это одна маленькая абстракция, которая устраняет необходимость постоянно повторять observable и observableArray функции observableArray .

Люди, которые используют Knockout, как правило, предпочитают элементы экземпляра, а не элементы-прототипы, из-за проблем с поддержанием правильного значения this . Ключевое слово this — сложная особенность JavaScript, но оно не так уж и плохо, когда его полностью уродуют.

Из MDN :

«В общем, объект, связанный с this в текущей области видимости, определяется тем, как была вызвана текущая функция, он не может быть установлен присваиванием во время выполнения и может отличаться при каждом вызове функции».

Таким образом, область действия меняется в зависимости от того, как вызывается функция. Это ясно видно в jQuery:

1
2
3
4
var $el = $( «#mySuperButton» );
$el.on( «click», function() {
    // in here, this refers to the button
});

Этот код устанавливает простой обработчик события click для элемента. Обратный вызов является анонимной функцией, и он ничего не делает, пока кто-то не нажмет на элемент. Когда это происходит, область действия внутри функции ссылается на фактический элемент DOM. Имея это в виду, рассмотрим следующий пример:

1
2
3
4
5
6
7
8
9
var someCallbacks = {
    someVariable: «yay I was clicked»,
    mySuperButtonClicked: function() {
        console.log( this.someVariable );
    }
};
 
var $el = $( «#mySuperButton» );
$el.on( «click», someCallbacks.mySuperButtonClicked );

Здесь есть проблема. this.someVariable используемая внутри mySuperButtonClicked() возвращает undefined поскольку в this.someVariable mySuperButtonClicked() this относится к элементу DOM, а не к объекту someCallbacks .

Есть два способа избежать этой проблемы. Первая использует анонимную функцию в качестве обработчика событий, которая в свою очередь вызывает someCallbacks.mySuperButtonClicked() :

1
2
3
$el.on( «click», function() {
    someCallbacks.mySuperButtonClicked.apply();
});

Второе решение использует методы Function.bind() или _.bind() ( Function.bind() недоступен в старых браузерах). Например:

1
$el.on( «click», _.bind( someCallbacks.mySuperButtonClicked, someCallbacks ) );

Любое решение, которое вы выберете, даст тот же конечный результат: mySuperButtonClicked() выполняется в контексте некоторых someCallbacks .

С точки зрения Knockout this проблема может проявиться при работе с привязками — особенно при работе с $root и $parent . Райан Нимейер написал плагин для делегированных событий, который в основном устраняет эту проблему. Он дает вам несколько опций для определения функций, но вы можете использовать атрибут data-click , и плагин обходит вашу цепочку областей действия и вызывает функцию с правильным this .

1
2
3
4
5
6
<form class=»search»>
    <input type=»text» name=»search» placeholder=»Search» data-bind=»value: search» />
    <ul data-bind=»foreach: beerListFiltered»>
        <li data-bind=»text: name, click: $parent.addToFavorites»></li>
    </ul>
</form>

В этом примере $parent.addToFavorites связывается с моделью представления через привязку click . Так как элемент <li /> находится внутри привязки foreach , this внутри $parent.addToFavorites относится к экземпляру пива, на которое щелкнули.

Чтобы обойти это, метод _.bindAll гарантирует, что this сохраняет свое значение. Поэтому добавление следующего в метод initialize() устраняет проблему:

1
2
3
4
5
6
7
8
9
_.extend(IndexViewModel.prototype, BaseViewModel.prototype, {
    initialize: function() {
        this.setupSubscriptions();
 
        this.beerListFiltered = ko.computed( this.filterBeers, this );
 
        _.bindAll( this, «addToFavorites» );
    },
});

Метод _.bindAll() существу создает элемент экземпляра addToFavorites() IndexViewModel объекта IndexViewModel . Этот новый член содержит версию прототипа addToFavorites() которая связана с объектом IndexViewModel .

Эта проблема заключается в том, что некоторые функции, такие как ko.computed() , принимают необязательный второй аргумент. Смотрите строку пять для примера. Это, переданное как второй аргумент, гарантирует, что this правильно ссылается на текущий объект IndexViewModel внутри filterBeers .

Как бы мы протестировали этот код? Давайте сначала посмотрим на addToFavorites() :

1
2
3
4
5
addToFavorites: function( beer ) {
    if( !_.any( this.favorites(), function( b ) { return b.id() === beer.id(); } ) ) {
        this.favorites.push( beer );
    }
}

Если для утверждений мы используем среду тестирования mocha и wait.js , наш модульный тест будет выглядеть следующим образом:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
it( «should add new beers to favorites», function() {
    expect( this.viewModel.favorites().length ).to.be( 0 );
 
    this.viewModel.addToFavorites( new Beer({
        name: «abita amber»,
        id: 3
    }));
 
    // can’t add beer with a duplicate id
    this.viewModel.addToFavorites( new Beer({
        name: «abita amber»,
        id: 3
    }));
 
    expect( this.viewModel.favorites().length ).to.be( 1 );
});

Чтобы увидеть полную настройку модульного тестирования, проверьте репозиторий .

Давайте теперь протестируем filterBeers() . Сначала давайте посмотрим на его код:

01
02
03
04
05
06
07
08
09
10
11
12
filterBeers: function() {
    var filter = this.search().toLowerCase();
 
    if ( !filter ) {
        return this.beers();
    }
    else {
        return ko.utils.arrayFilter( this.beers(), function( item ) {
            return ~item.name().toLowerCase().indexOf( filter );
        });
    }
},

Эта функция использует метод search() , который привязан к value текстового элемента <input /> в DOM. Затем он использует утилиту ko.utils.arrayFilter для поиска и поиска совпадений в списке пива. beerListFiltered привязан к элементу <ul /> в разметке, поэтому список пива можно отфильтровать, просто набрав в текстовом поле.

Функция filterBeers , будучи такой маленькой единицей кода, может быть надлежащим образом протестирована:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
beforeEach(function() {
    this.viewModel = new IndexViewModel();
 
    this.viewModel.beers.push(new Beer({
        name: «budweiser»,
        id: 1
    }));
    this.viewModel.beers.push(new Beer({
        name: «amberbock»,
        id: 2
    }));
});
 
it( «should filter a list of beers», function() {
    expect( _.isFunction( this.viewModel.beerListFiltered ) ).to.be.ok();
 
    this.viewModel.search( «bud» );
 
    expect( this.viewModel.filterBeers().length ).to.be( 1 );
 
    this.viewModel.search( «» );
 
    expect( this.viewModel.filterBeers().length ).to.be( 2 );
});

Во-первых, этот тест проверяет, что beerListFiltered на самом деле является функцией. Затем выполняется запрос путем передачи значения «bud» в this.viewModel.search() . Это должно привести к изменению списка пива, отфильтровывая каждое пиво, которое не соответствует «бутону». Затем для search задается пустая строка, чтобы гарантировать, что beerListFiltered возвращает полный список.


Knockout.js предлагает множество замечательных функций. При создании больших приложений помогает принять многие из принципов, обсуждаемых в этой статье, чтобы код вашего приложения оставался управляемым, тестируемым и обслуживаемым. Ознакомьтесь с полным примером приложения , которое включает в себя несколько дополнительных тем, таких как messaging . Он использует postal.js в качестве шины сообщений для передачи сообщений по всему приложению. Использование обмена сообщениями в приложении JavaScript может помочь отделить части приложения, удалив жесткие ссылки друг на друга. Будьте уверены и посмотрите!