Автор: Евгений Клименко, Senior Developer, DataArt

О недостатках использования одного лишь JavaScript и jQuery при разработке клиентской части веб-приложения много говорить не будем — это всегда плохо поддерживаемый спагетти-код, необходимость внедрения в разметку скрытых полей для хранения данных, трудности с тестированием и, как следствие, большое количество дефектов.

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

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


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

Серверный код источника данных рассматривать не будем. Сосредоточимся на клиентском коде (исходный код ASP.Net Web приложения, а также скрипт базы данных можно скачать).

1. Реализация с Knockout


Главный фрагмент страницы (MusicViaKnockout\Index.cshtml) выглядит стандартным для Knockout образом – разметка и привязки (data-bind) DOM-элементов к view-модели:



А вот как выглядит view-модель:
$(function() {

    var musicViewModel = {
        genres: ko.observableArray(),
        genresFound: ko.observableArray(),
        performers: ko.observableArray(),
        composers: ko.observableArray(),
        albums: ko.observableArray(),
        casts: ko.observableArray(),
        musics: ko.observableArray()
    };

    mapServerData = function(data) {
        if (!data) return;
        musicViewModel.genres(data.Filters.Genres);
        musicViewModel.genresFound(data.Filters.GenresFound);
        musicViewModel.performers( data.Filters.Performers);
        musicViewModel.performers.unshift({PerformerID: -1, Performer: 'All'});
        musicViewModel.composers(data.Filters.Composers);
        musicViewModel.composers.unshift({ComposerID: -1, Composer: 'All'});
        musicViewModel.albums(data.Filters.Albums);
        musicViewModel.albums.unshift({AlbumID: -1, Album: 'All'});
        musicViewModel.casts(data.Filters.Casts);
        musicViewModel.casts.unshift({CastID: -1, Cast: 'All'});
        musicViewModel.musics(data.Filters.Musics);
    },

    genresChanged = function (data, event) {
        $.ajax({
            url: "/Music/FilterMusics",
            type: "GET",
            data: {
                genreIDs: ko.toJS(musicViewModel.genresFound),
                "composerID": null,
                "albumID": null,
                "performerID": null,
                "castID": null
            },
            traditional: true,
            timeout: 30000,
            success: function (data) {
                mapServerData(data);
            }
        });
    }

    $.getJSON("/api/MusicWebAPI", function (data) {
        mapServerData(data);
        ko.applyBindings(musicViewModel);
    });
});



Если вы знакомы с Knockout, все очень просто. При загрузке страницы данные считываются вызовом
$.getJSON("/api/MusicWebAPI"
..., после чего они привязываются к view-модели. Отображение данных модели фреймворк берет на себя. Как видите, и код, и разметка просты и лаконичны. Это — неоспоримое достоинство Knockout.

2. Реализация на React


Страница (../Views/Music/Index.cshtml):

@using MusicChoice2.HtmlHelpers
@using Newtonsoft.Json
@using React.Web.Mvc
@model MusicChoice.ViewModels.MusicViewModel

@{
    ViewBag.Title = "title";
}

<script src="@Url.Content("~/Scripts/jquery.min.js")"></script>
<script src="@Url.Content("~/Scripts/underscore-min.js")"></script>
<script src="@Url.Content("~/Scripts/react.js")"></script>
<script src="@Url.Content("~/Scripts/react-dom.js")"></script>
<script src="@Url.Content("~/Scripts/MusicChoice/MusicFilters.jsx")"></script>
<script src="@Url.Content("~/Scripts/MusicChoice/MusicChoice.js")"></script>
<link rel="stylesheet" href="../../Styles/music.css">

<div>
    @Html.React("MusicFilters", new
    {
        albums = Model.Filters.Albums,
        casts = Model.Filters.Casts,
        composers = Model.Filters.Composers,
        genres = Model.Filters.Genres,
        performers = Model.Filters.Performers,
        genresFound = Model.Filters.GenresFound,
        musics = Model.Filters.Musics,
        albumID = Model.Filters.AlbumID,
        castID = Model.Filters.CastID,
        composerID = Model.Filters.ComposerID,
        performerID = Model.Filters.PerformerID,

        onFilterChanged = "music.onFilterChanged"
    }, 
    "containerId")
    
    @Html.ReactInitJavaScriptAndAssignIds(new[] { "music.filters" })
</div>


Здесь главное явление — MVC helper (@Html.React(«MusicFilters»…), наполняющий свойства React-компонента данными сервера.

Вот как выглядит сам React-компонент:


var MusicFilters = React.createClass({
    getDefaultProps: function() {
        return {
            defaultLoadParameters : {}
        }
    },

    getInitialState: function() {
        return {
              performers: this.props.performers
            , genres: this.props.genres
            , genresFound: this.props.genresFound
            , musics: this.props.musics
            , albums: this.props.albums
            , composers: this.props.composers
            , casts: this.props.casts
            , selected: {
                  performerID: this.props.performerID
                , musicID: this.props.musicID
                , albumID: this.props.albumID
                , composerID: this.props.composerID
                , castID: this.props.castID
            }
        }
    },

    render: function() {
        var onFilterChanged = this.onFilterChanged;
        var musicFilters = this;
        var genresContains = function(genreID) {
            var contains = false;
            musicFilters.state.genresFound.forEach(function (g) {
                if (g == genreID) {
                    contains = true;
                }
            });
            return contains;
        };
        var onGenreChanged = this.onGenreChanged;

        var renderDropDown = function(title, id, values, selectedValue, optionIndex, optionValue, selectedParameter, labelStyle) {
            return  (values !== undefined && values !== null)? <div>
                <label style={labelStyle}>{title + ':'}</label>
                <select id={id} value={selectedValue || -1} onChange={onFilterChanged.bind(musicFilters, selectedParameter)}>
                    <option key={-1} value={-1}>All</option>
                    {
                        values.map(function (val, idx) {
                                return <option key={idx} value={val[optionIndex]}>{val[optionValue]}</option>
                            })
                        }
                </select>
            </div>:<div>No data</div>
        };

        return (this.state.genres !== undefined && this.state.genres !== null)?
        <div>
            <div>
                <div>
                    <div className="genres" id="genres">
                        <div style={{"font-weight":"bold"}}>Genres:</div>
                        {
                            this.state.genres.map(function (val, idx) {
                                return <div key={idx}>
                                    <input checked={genresContains(val["GenreID"])} type="checkbox"  value={val["GenreID"]||null} onChange={onGenreChanged.bind(musicFilters)}/>
                                    <span className="js-genres">{val["Genre"]}</span>
                                </div>
                                })
                            }
                    </div>
                    <div className="selects">
                        {
                            renderDropDown("Performers", "performers", this.state.performers, musicFilters.state.selected.performerID,  "PerformerID", "Performer",  "performerID" )
                            }
                        {
                            renderDropDown("Composers", "composers", this.state.composers, musicFilters.state.selected.composerID, "ComposerID", "Composer",  "composerID" )
                            }
                        {
                            renderDropDown("Albums", "albums", this.state.albums, musicFilters.state.selected.albumID, "AlbumID", "Album",  "albumID" )
                            }
                        {
                            renderDropDown("Casts", "casts", this.state.casts, musicFilters.state.selected.castID, "CastID", "Cast", "castID", {clear:"both"})
                            }
                    </div>
                </div>

                <div className="musicContainer">
                    <div className="musics">
                        <label>Musics:</label><br/>
                        <div style={{"overflow-y": "scroll", "height": "500px"}}>
                                <table>
                                    <tbody>
                                    {
                                        this.state.musics.map(function (val, idx) {
                                                return <tr><td>{val["Music"]}</td></tr>;
                                            })
                                    }
                                    </tbody>
                                </table>
                        </div>
                    </div>
                </div>
            </div>
        </div>:
        <div>No data</div>;
    },

// end of lifetime methods
//////
    raiseEvent: function(eventHandler, eventArgs) {
        if (eventHandler) {
            if (typeof(eventHandler) === 'string') {
                eval(eventHandler(this, eventArgs));
            }
            else {
                eventHandler(this, eventArgs);
            }
        }
    },

    onFilterChanged: function(selectedParameter, e) {
        var selected = _.clone(this.state.selected);
        selected[selectedParameter] = parseInt($(e.currentTarget).val()) || null;
        this.setState(
            {selected: selected}
            , this.raiseEvent.bind(this, eval(this.props.onFilterChanged), selected)
        );
    },

    onGenreChanged: function() {
        var genresFound = [];
        $("#genres").find("input:checked").map(function() {
            genresFound.push(parseInt($(this).val()));
        });
        var stateCopy = music.deepCopy(this.state);
        stateCopy.genresFound = genresFound;
        this.setState(stateCopy);
        music.reLoadDetailsAndFilters(
            {genreIDs: genresFound,
                performerID: this.state.selected.performerID,
                albumID: this.state.selected.albumID,
                composerID: this.state.selected.composerID,
                castID: this.state.selected.castID});
    },

    areNullablesEqual: function(first, second) {
        return first === second || (first === null && second === null);
    }

});


Как видите, React оказался гораздо многословнее. Главным образом, из-за функции render. Это естественно, потому что React возлагает отрисовку содержимого на программиста.

Естественно, я склонился в сторону Knockout. Однако заметил одну странность — это, на первый взгляд, малозаметный временной интервал между отображением группы элементов, состоящих из первого чекбокса и пустых дропдаунов, и остального содержимого. Т. е. сначала пользователь видит набор пустых элементов:


И только спустя некоторое время эти элементы наконец будут заполнены данными:




Отмечу, что количество отфильтрованных записей составило порядка 300.

Я увеличил выборку до 15 000 и описанная задержка стала отчетливой:


На той же выборке данных (15 000 записей) страница с React-компонентом ведет себя идеально:


Вот как выглядят соответствующие метрики Knockout:

Время отрисовки страницы — 4.15 сек.

React с рендерингом на клиенте:

Время отрисовки — 2.53 с.

React c рендерингом на сервере:

Время отрисовки — 2.54 с.

Однако обратите внимание на длинную синюю полоску перед рендерингом — эта линия порядка 3 секунд — время, необходимое для формирования разметки на сервере.

Выводы



1) Самым быстрым из рассмотренных вариантов решения задачи оказался React с рендерингом на клиенте. Время отрисовки содержимого страницы с 15 000 записей не превысило 3 секунд. Решение на Knockout потребовало 4 секунды. С учетом того, что 40% пользователей уходят со страницы, которая грузится более 3 секунд, React может оказаться безальтернативным вариантом.

2) Рендеринг на Knockout может сопровождаться неприятными задержками в отображении элементов страницы, особенно в случае больших объемов данных.

3) В данных условиях серверный рендеринг на React сопровождается недопустимо долгим скрытым формированием разметки на сервере.

Общие рекомендации



1) Если объем данных, обрабатываемых на клиенте невелик, и задержки в отрисовке элементов страницы не заметны конечному пользователю, применяйте Knockout. Вы получите лаконичный, прекрасно читаемый код.

2) Если приходится обрабатывать большой объем данных на клиенте, если качество и скорость отображения имеют критическое значение, применяйте React c рендерингом на клиенте.
Поделиться с друзьями
-->

Комментарии (7)


  1. indestructable
    02.11.2016 23:36
    +2

    mapServerData

    Я правильно понял, что это неявно объявленная глобальная переменная?
    Код, конечно, с душком, имхо.


  1. youlose
    02.11.2016 23:42
    +3

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


  1. bromzh
    02.11.2016 23:50
    +2

    Классный код пишет этот senior developer… Интересно, что же тогда выдают мидлы и джуны в этой компании.


  1. PerlPower
    03.11.2016 00:07
    +1

    Ну теперь-то я точно перейду с Knockout на React!


  1. wheercool
    03.11.2016 00:48

    Однако заметил одну странность — это, на первый взгляд, малозаметный временной интервал между отображением группы элементов, состоящих из первого чекбокса и пустых дропдаунов, и остального содержимого.

    Ничего странного, это браузер отображает пустой шаблон, до обработки data-bind атрибутов.
    Чтобы этого избежать knockout использует костыль в виде script тега. Называется это «template» binding


  1. justboris
    03.11.2016 01:37

    Как видите, React оказался гораздо многословнее.

    То, что вы так написали так много кода, необязательно означает многословность фреймоворка.


    Во-первых, надо убрать читы, типа неиспользуемого метода areNullablesEqual и странного перекидывания переменных туда-сюда.


    Во-вторых много строк занимает обход реакт-компонентов с помощью jQuery (как это вообще?) вместо простого обновления state.


    Во-третьих, неплохо если зарефакторить код, то можно значительно упростить метод render и получить намного более читаемый компонент


    https://gist.github.com/just-boris/d7ff0d2be53b7cfa81759c2b37221779


    Даже после этого, от кода все равно пахнет энтерпрайзом. Если бы можно было еще и упростить структуру данных от сервера и избавиться от этих бесконечных optionIndex и optionValue, то было бы еще компактнее.


  1. SerafimArts
    03.11.2016 02:18

    Только вы не учитываете, что VM на нокауте может выглядеть, например так:

    ```js
    class MyVm {
    // data-bind=«text: some»
    some = ko.observable(42);

    // data-bind=«click: any»
    any() {}
    }

    // ko.applyBindings(new MyVm, ...)
    ```

    Что позволяет писать код на несколько порядков чище и красивее, представленного в примере.

    P.S. Простите ребят, не могу оформить код по техническим причинам, но думаю и в таком варианте пример читаем.