Knockout vs React: у всех свои недостатки

от автора


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


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *