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

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

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

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

Вот как может выглядеть такая страница:


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

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

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


Главный фрагмент страницы (MusicViaKnockout\Index.cshtml) выглядит стандартным для Knockout образом — разметка и привязки (data-bind) DOM-элементов к view-модели:
<div>
    <div class="genres">
        <div style="font-weight: bold">Genres:</div>
        <div data-bind="foreach: genres">
            <div>
<input type="checkbox" data-bind="value: GenreID, checked: $root.genresFound, event: {change: genresChanged}">
                <span class="js-genres" data-bind="text: Genre"></span>
            </div>
        </div>
    </div>
    <div class="selects">
        <div>
            <label>Performers:</label> <select data-bind="options: performers, optionsText: 'Performer', optionsValue: 'PerformerID'"></select>
        </div>
        <div>
            <label>Composers:</label> <select data-bind="options: composers, optionsText: 'Composer', optionsValue: 'ComposerID'"></select>
        </div>
        <div>
            <label>Albums:</label> <select data-bind="options: albums, optionsText: 'Album', optionsValue: 'AlbumID'"></select>
        </div>
        <div>
            <label style="clear: both">Casts:</label> <select data-bind="options: casts, optionsText: 'Cast', optionsValue: 'CastID'"></select>
        </div>
    </div>
</div>
<div class="musicContainer">
    <div class="musics">
        <label>Musics:</label><br />
        <div style="overflow-y: scroll; height: 500px">
            <table data-bind="foreach: musics">
                <tr>
                    <td>
                        <div data-bind="text: Music"></div>
                    </td>
                </tr>
            </table>
        </div>
    </div>
</div>


А вот как выглядит 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:


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

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


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

И, наконец, React c рендерингом на сервере:


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

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

Выводы:


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

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


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

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


  1. bromzh
    08.11.2016 20:21

    Дежавю прям. Была же уже эта статья. Но многим она справедливо не понравилась. Код плохой (а ведь это писал senior… какие же там джуны с мидлами), выводы непонятные.


  1. balun92
    08.11.2016 20:34

    Не сочтите за беспричинный «замес», но рендеринг на сервере дольше чем на клиенте… Мне кажется это слегка подозрительным.