Автор: Евгений Клименко, 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)
balun92
08.11.2016 20:34Не сочтите за беспричинный «замес», но рендеринг на сервере дольше чем на клиенте… Мне кажется это слегка подозрительным.
bromzh
Дежавю прям. Была же уже эта статья. Но многим она справедливо не понравилась. Код плохой (а ведь это писал senior… какие же там джуны с мидлами), выводы непонятные.