Автор: Евгений Клименко, 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-компонента данными сервера.
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 рендерингом на клиенте.
ссылка на оригинал статьи https://habrahabr.ru/post/314746/
Добавить комментарий