Хабрахабр

VueJs + MVC минимум кода максимум функциональности

Паттерн MVVC наверное один из наиболее удобных архитектурных паттернов. Добрый день.
Я много лет использовал WPF. Когда я на новом месте работы я увидел использование MVC на практике, то был удивлен запутанностью и одновременно отсутствием элементарной Юзабилити. Я предполагал что MVC почти то же самое. Нет красных рамок подсвечивающих поле в котором ошибка, а просто выводится Alert со списком ошибок. Больше всего раздражает то, что валидация происходит только при перегрузки формы. Кнопка сохранить всегда активна. Если ошибок много, то приходится исправлять часть ошибок и жать сохранить, что бы повторить валидацию. Модель, представление и контроллер сильно связаны поэтому протестировать все это великолепие весьма сложно.
Как с этим бороться ?? Связанные списки правда реализованы через js, но сложно и запутанно. Изучать Js хотим только в минимально необходимом размере, а изучать можно бесконечно (по себе знаю).
Кроме этого фреймворки Reart,Angular,Vue заточены под написание сложной логики на клиенте, что на мой WPF-ный взгляд не правильно. Кому интересно прошу под кат.
Полноценное использование Reart,Angular,Vue и переход на SinglePageApplicatrion в принципе не возможно в рамках данного проекта:
1) Много кода написано, принято и ни кто не даст переделывать.
2) Мы в программисты С#. View должно всего лишь отображать состояние модели не более того.
Исходя из вышесказанного я постарался найти подход позволяющий с минимум кода на js получить максимум функциональности. Вся логика должна быть в одном месте и это бизнес объект и(или) класс модели. VueJs подключается через cdn. В первую очередь минимуме пользовательского кода, то есть кода который нужно писать для вывода и обновления конкретного поля.
Предлагаемая мной связка VueJs+ MVC обеспечивает подход к написанию форм частично напоминающий WPF.
При вызове формы страница загружается целиком. На сервере через механизм Entity происходит валидация и на клиент возвращаются невалидные поля и признак что состояние модели изменилось по отношению к базе данных.
MVC модель не используется, так как в данном примере она лишняя. При каждом изменении формы Vue отправляет на сервер все изменения. Ссылка на github с примером в конце статьи.
Создаем новый пустой контроллер MVC 5. Функция ViewModel в WPF-ном понимании здесь размазана между vue и контроллером.
Итак поехали.
В качестве базы данных я использовал учебную базу данных Northwind которую скачал с одним из примеров Devextreem.
Создание приложения, подключение Entity и создание DbContext я оставлю за кадром. В нем пока один метод. Назовем его OrdersController.

public ActionResult Index()

Добавим еще один

public ActionResult Edit() { return View(); }

Web. Теперь надо перейти в папку Views/Orders и добавить две страницы Index.cshtml и Edit.cshtml
Важное замечание, что бы cshtml страница работала без модели надо обязательно добавить в начало страницы inherits System. WebViewPage
Предполагается, что Index.cshtml содержит таблицу из которой по выделенной строке будет осуществляться переход на страницу редактирования. Mvc. Пока создадим просто ссылки которые будут вести на страницу редактирования.

@inherits System.Web.Mvc.WebViewPage
<table > @foreach (var item in ViewBag.Orders) { <tr><td><a href="Edit?id=@item.OrderID">@item.OrderID</a></td></tr> }
</table>

Теперь я хочу реализовать редактирование существующего объекта.
Первое, что необходимо сделать, это описать метод в контроллере который бы по идентификатору возвращал бы на клиент Json описание объекта.

[HttpGet] public ActionResult GetById(int id) { var order = _db.Orders.Find(id);//Получили объект string orderStr = JsonConvert.SerializeObject(order);//Сериализовали его return Content(orderStr, "application/json");//отправили }

Проверить, что все работает можно набрав в браузере (номер порта естественно ваш) http://localhost:63164/Orders/GetById?id=10501
Вы должны получить в браузере что то вроде

{ "OrderID": 10501, "CustomerID": "BLAUS", "EmployeeID": 9, "OrderDate": "1997-04-09T00:00:00", "RequiredDate": "1997-05-07T00:00:00", "ShippedDate": "1997-04-16T00:00:00", "ShipVia": 3, "Freight": 8.85, "ShipName": "Blauer See Delikatessen", "ShipAddress": "Forsterstr. 57", "ShipCity": "Mannheim", "ShipRegion": null, "ShipPostalCode": "68306", "ShipCountry": "Germany"
}

Однако оставим тестирование за рамками данной статьи Ну и (или) написав простейший тест.

[Test] public void OrderControllerGetByIdTest() { var bdContext = new Northwind(); var id = bdContext.Orders.First().OrderID; //получил первый существующий идентификатор var orderController = new OrdersController(); var json = orderController.GetById(id) as ContentResult; var res = JsonConvert.DeserializeObject(json.Content,typeof(Order)) as Order; Assert.AreEqual(id, res.OrderID); }

Далее необходимо создать Vue форму.

@inherits System.Web.Mvc.WebViewPage
<!DOCTYPE html>
<html>
<head> <meta charset="utf-8" /> <title>редактирование </title> <script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
</head>
<body> <div id="app"> <h1>Aвто генерация формы</h1> <table > <tr v-for="(item,i) in order"> @*создание ряда по каждому свойству объекта ордер*@ <td> {{i}}</td> <td> <input type="text" v-model="order[i]"/> </td> </tr> </table> </div> <script> new Vue({ el: "#app", data: { order: { OrderID: 10501, CustomerID: "BLAUS", EmployeeID: 9, OrderDate: "1997-04-09T00:00:00", RequiredDate: "1997-05-07T00:00:00", ShippedDate: "1997-04-16T00:00:00", ShipVia: 3, Freight: 8.85, ShipName: "Blauer See Delikatessen", ShipAddress: "Forsterstr. 57", ShipCity: "Mannheim", ShipRegion: null, ShipPostalCode: "68306", ShipCountry: "Germany" } } }); </script>
</body>
</html>

Если все сделано правильно, то в браузере должен отобразиться прототип будущей формы.

Но данные в модели пока статические и первое что нужно сделать дальше, это реализовать загрузку данных из базы через только что написанный метод.
Для этого добавим метод fetchOrder() и будем вызывать его в секции mounted: Как мы видим Vue отобразил все поля ровно так, как было модели.

new Vue({ el: "#app", data: { id: @ViewBag.Id, order: { OrderID: 0, CustomerID: "", EmployeeID: 0, OrderDate: "", RequiredDate: "", ShippedDate: "", ShipVia: 0, Freight: 0, ShipName: "0", ShipAddress: "", ShipCity: "", ShipRegion: null, ShipPostalCode: "", ShipCountry: "" }, }, methods: { //читаем объект fetchOrder() { var path = "../Orders/GetById?key=" + this.id; console.log(path); this.fetchJson(path, json => this.order = json); }, //обертка над стандартной функцией fetch fetchJson(path, collback) { try { fetch(path, { mode: 'cors' }) .then(response => response.json()) .then(function(json) { collback(json); } ); } catch (ex) { alert(ex); } } }, mounted: function() { this.fetchOrder(); } });

Ну и так как идентификатор объекта теперь должен приходить из контроллера, то в контроллере надо передавать идентификатор в динамический объект ViewBag, что бы его можно было получить во View.

public ActionResult SimpleEdit(int id = 0) { ViewBag.Id = id; return View(); }

Предлагаю для начал разобраться как работать с связанными списками. Этого достаточно что бы данные начитывались при загрузке.
Настало время кастомизировать форму.
Что бы не перегружать статью я не стал вывел минимум полей.

<table > <tr> <td>Стоимость перевозки</td> <td > <input type="number" v-model="order.Freight" /> </td> </tr> <tr> <td>Старана приписки корабля</td> <td> <input type="text" v-model="order.ShipCountry" /> </td> </tr> <tr> <td>Город корабля</td> <td> <input type="text" v-model="order.ShipCity" /> </td> </tr> <tr> <td>Адрес корабля</td> <td> <input type="text" v-model="order.ShipAddress" /> </td> </tr> </table>

Как видите все довольно просто.Вся фильтрация осуществляется с помощью Linq. Поля ShipCountry и ShipAddress лучшие кандидаты на связанные списки
Вот методы контроллера.

/// <summary> /// Список доступных городов c учетом региона и страны /// если регион или страна не заданы , то все города /// </summary> /// <param name="country"></param> /// <param name="region"></param> /// <returns></returns> [HttpGet] public ActionResult AvaiableCityList( string country,string region=null) { var avaiableCity = _db.Orders.Where(c => ((c.ShipRegion == region) || region == null)&& (c.ShipCountry == country) || country == null).Select(a => a.ShipCity).Distinct(); var jsonStr = JsonConvert.SerializeObject(avaiableCity); return Content(jsonStr, "application/json"); } /// <summary> /// Список доступных стран c учетом региона /// если регион не задан, то все страны /// </summary> /// <param name="region"></param> /// <returns></returns> [HttpGet] public ActionResult AvaiableCountrys(string region=null) { var resList = _db.Orders.Where(c => (c.ShipRegion == region)||region==null).Select(c => c.ShipCountry).Distinct(); var json = JsonConvert.SerializeObject(resList); return Content(json, "application/json"); }

Это позволяет перечитывать список городов только при изменении страны. А вот во View кода прибавилось значительно больше.
Кроме собственно функций начитки стран и городов приходится добавить watch который следит за изменениями объекта, к сожалению старое значение сложного объекта vue не сохраняет поэтому нужно сохранять его в ручную, для чего я придумал метод saveOldOrderValue: пока я сохраняю в нем только страну. В примере я показал только одноуровневый связанный список ( по этому принципу не сложно сделать вложенность любого уровня). В остальном код то же думаю понятен.

@inherits System.Web.Mvc.WebViewPage
<!DOCTYPE html>
<html>
<head> <meta charset="utf-8" /> <title>редактирование </title> <script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
</head>
<body> <div id="app"> <table> <tr> <td>Cтоимость перевозки</td> <td> <input type="number" v-model="order.Freight" /> </td> </tr> <tr> <td>Старана приписки корабля</td> <td> <select v-model="order.ShipCountry" class="input"> <option v-for="item in AvaialbeCountrys" :v-key="item">{{item}} </option> </select> </td> </tr> <tr> <td>Город корабля</td> <td> <select v-model="order.ShipCity" > <option v-for="city in AvaialbeCitys" :v-key="city">{{city}} </option> </select> </td> </tr> <tr> <td>Адрес корабля</td> <td> <input type="text" v-model="order.ShipAddress" /> </td> </tr> </table> </div> <script> new Vue({ el: "#app", data: { id: @ViewBag.Id, order: { OrderID: 0, CustomerID: "", EmployeeID: 0, OrderDate: "", RequiredDate: "", ShippedDate: "", ShipVia: 0, Freight: 0, ShipName: "0", ShipAddress: "", ShipCity: "", ShipRegion: null, ShipPostalCode: "", ShipCountry: "" }, oldOrder: { ShipCountry: "" }, AvaialbeCitys: [], AvaialbeCountrys: [] }, methods: { //читаем объект fetchOrder() { var path = "../Orders/GetById?Id=" + this.id; this.fetchJson(path, json => this.order = json); }, fetchCityList() { //город зависит от выбраной страны var country = this.order.ShipCountry; if (country == null || country === "") { country = ''; } var path = "../Orders/AvaiableCityList?country=" + country; this.fetchJson(path, json => {this.AvaialbeCitys = json;}); }, fetchCountrys() { var path = "../Orders/AvaiableCountrys"; this.fetchJson(path,jsonResult => {this.AvaialbeCountrys = jsonResult;}); }, //обертка над стандартной функцией fetch fetchJson(path, collback) { try { fetch(path, { mode: 'cors' }) .then(response => response.json()) .then(function(json) { collback(json); } ); } catch (ex) { alert(ex); } }, saveOldOrderValue:function(){ this.oldOrder.ShipCountry = this.order.ShipCountry; } }, watch: { order: { handler: function (after) { if (this.oldOrder.ShipCountry !== after.ShipCountry)//Только если изменилась страна { this.fetchCityList();//Перечитываю список городов с учетом выбранной страны } this.saveOldOrderValue(); }, deep: true } }, mounted: function () { this.fetchCountrys();//начитываю список стран //начитывать список городов здесь излишне, он начитается когда начитается объект this.fetchOrder();//читаю объект this.saveOldOrderValue();//запоминаю старое значение } }); </script>
</body>
</html>

С точки зрения оптимизации скорости выполнения конечно надо сделать валидацию на клиенте. Отдельная тема Валидация. Кода при этом минимум, сама валидация происходит достаточно быстро и к тому же асинхронно. Но это приведет к дублированию кода, поэтому я показываю пример с валидацией на уровне Entity (Как собственно и должно быть в идеале). Это место для дальнейшей оптимизации.
Методы обработки валидации в контроле у меня получились вот такие. Как показала практика даже при весьма медленном интернете все работает более чем нормально.
Небольшие проблемы возникают только, если надо набрать достаточно большой текст в текстовом поле, а скорость набора текста этак символов 260 в минуту.

[HttpGet] public ActionResult Validate(int id, string json) { var order = _db.Orders.Find(id); JsonConvert.PopulateObject(json, order); var errorsD = GetErrorsJsArrey(); return Content(errorsD.ToString(), "application/json"); } private String GetErrorsAndChanged() { var changed= _db.ChangeTracker.HasChanges(); var errors = _db.GetValidationErrors(); return GetErrorsAndChanged(errors,changed); } private static string GetErrorsAndChanged(IEnumerable<DbEntityValidationResult> errors,bool changed) { dynamic dynamic = new ExpandoObject(); dynamic.IsChanged = changed;//Создание свойства IsChanged var errProperty = new Dictionary<string, object>();//Создание массива с будущими свойствами ошибки dynamic.Errors = new DynObject(errProperty);//Создание объекта у которого свойства задаются в массиве foreach (DbEntityValidationResult validationError in errors)//Заполнение массива ошибками { foreach (DbValidationError err in validationError.ValidationErrors)//Заполнение массива ошибками { errProperty.Add(err.PropertyName,err.ErrorMessage); } } var json = JsonConvert.SerializeObject(dynamic); return json; }

И еще использую класс DynObject

public sealed class DynObject : DynamicObject { private readonly Dictionary<string, object> _properties; public DynObject(Dictionary<string, object> properties) { _properties = properties; } public override IEnumerable<string> GetDynamicMemberNames() { return _properties.Keys; } public override bool TryGetMember(GetMemberBinder binder, out object result) { if (_properties.ContainsKey(binder.Name)) { result = _properties[binder.Name]; return true; } else { result = null; return false; } } public override bool TrySetMember(SetMemberBinder binder, object value) { if (_properties.ContainsKey(binder.Name)) { _properties[binder.Name] = value; return true; } else { return false; } } }

В результате работы метода на клиент json объект со свойствами IsChanded и Errors. Довольно многословно, но данный код пишется один раз на все приложение и не требует донастройки под конкретный объект или поле. Самое время сейчас в нашем описании Entity объекта Order добавить несколько атрибутов валидации. Эти свойства естественно нужно создать в нашем Vue и заполнять их при каждом изменении объекта.
Что бы получить ошибки валидации нужно эту валидацию где то задать.

[MinLength(10)] [StringLength(60)] public string ShipAddress { get; set; } [CheckCityAttribute("Поле ShipCity обязательно для заполнения")] public string ShipCity { get; set; }

MinLength и StringLength стандартные атрибуты, а вот для ShipCity я создал кастомный атрибут

/// <summary> /// Custom Attribute Example /// </summary> [AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] public class CheckCityAttribute : ValidationAttribute { public CheckCityAttribute(string message) { this.ErrorMessage = message; } protected override ValidationResult IsValid(object value, ValidationContext validationContext) { ValidationResult result = ValidationResult.Success; string[] memberNames = new string[] { validationContext.MemberName }; string val = value?.ToString(); Northwind _db = new Northwind(); Order order = (Order)validationContext.ObjectInstance; bool exsist = _db.Orders.FirstOrDefault(o => o.ShipCity == val && o.ShipCountry == order.ShipCountry)!=null; if (!exsist) { result = new ValidationResult(string.Format(this.ErrorMessage,order.ShipCity , val), memberNames); } return result; } }

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

@inherits System.Web.Mvc.WebViewPage
<!DOCTYPE html>
<html>
<head> <meta charset="utf-8" /> <title>редактирование id=@ViewBag.Id</title> <script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script> <link rel="stylesheet" type="text/css" href="~/Content/vueError.css" />
</head>
<body> <div id="app"> <table> <tr> <td>Cтоимость перевозки</td> <td class="tooltip"> <input type="number" v-model="order.Freight" v-bind:class="{error:!errors.Freight==''} " class="input" /> <span v-if="errors.Freight!==''" class="tooltiptext">{{errors.Freight}}</span> </td> </tr> <tr> <td>Старана приписки корабля</td> <td> <select v-model="order.ShipCountry" class="input"> <option v-for="item in AvaialbeCountrys" :v-key="item">{{item}} </option> </select> </td> </tr> <tr> <td>Город корабля</td> <td class="tooltip"> <select v-model="order.ShipCity" v-bind:class="{error:!errors.ShipCity==''}" class="input"> <option v-for="city in AvaialbeCitys" :v-key="city">{{city}} </option> </select> <span v-if="!errors.ShipCity==''" class="tooltiptext">{{errors.ShipCity}}</span> </td> </tr> <tr> <td>Адрес корабля</td> <td class="tooltip"> <input type="text" v-model="order.ShipAddress" v-bind:class="{error:!errors.ShipAddress=='' }" class="input" /> <span v-if="!errors.ShipAddress==''" class="tooltiptext">{{errors.ShipAddress}}</span> </td> </tr> <tr> <td> </td> <td> <button v-on:click="Save()" :disabled="IsChanged===false" class="alignRight">Save</button> </td> </tr> </table> </div> <script> new Vue({ el: "#app", data: { id: @ViewBag.Id, order: { OrderID: 0, CustomerID: "", EmployeeID: 0, OrderDate: "", RequiredDate: "", ShippedDate: "", ShipVia: 0, Freight: 0, ShipName: "0", ShipAddress: "", ShipCity: "", ShipRegion: null, ShipPostalCode: "", ShipCountry: "" }, oldOrder: { ShipCountry: "" },
errors: { OrderID: null, CustomerID: null, EmployeeID: null, OrderDate: null, RequiredDate: null, ShippedDate: null, ShipVia: null, Freight: null, ShipName: null, ShipAddress: null, ShipCity: null, ShipRegion: null, ShipPostalCode: null, ShipCountry: null }, IsChanged: false, AvaialbeCitys: [], AvaialbeCountrys: [] }, methods: { //читаем объект fetchOrder() { var path = "../Orders/GetById?Id=" + this.id; this.fetchJson(path, json => this.order = json); }, fetchCityList() { //город зависит от выбраной страны var country = this.order.ShipCountry; if (country == null || country === "") { country = ''; } var path = "../Orders/AvaiableCityList?country=" + country; this.fetchJson(path, json => {this.AvaialbeCitys = json;}); }, fetchCountrys() { var path = "../Orders/AvaiableCountrys"; this.fetchJson(path,jsonResult => {this.AvaialbeCountrys = jsonResult;}); }, //обертка над стандартной функцией fetch Validate() {this.Action("Validate");}, Save() {this.Action("Save");}, Action(action) { var myJSON = JSON.stringify(this.order); var path = "../Orders/" + action + "?id=" + this.id + "&json=" + myJSON; this.fetchJson(path, jsonResult => { this.errors = jsonResult.Errors; this.IsChanged = jsonResult.IsChanged; }); }, fetchJson(path, collback) { try { fetch(path, { mode: 'cors' }) .then(response => response.json()) .then(function(json) { collback(json); } ); } catch (ex) { alert(ex); } }, saveOldOrderValue:function(){ this.oldOrder.ShipCountry = this.order.ShipCountry; } }, watch: { order: { handler: function (after) { if (this.oldOrder.ShipCountry !== after.ShipCountry)//Только если изменилась страна { this.fetchCityList();//Перечитываю список городов с учетом выбранной страны } this.saveOldOrderValue(); this.Validate(); }, deep: true } }, mounted: function () { this.fetchCountrys();//начитываю список стран //начитывать список городов здесь излишне, он начитается когда начитается объект this.fetchOrder();//читаю объект this.saveOldOrderValue();//запоминаю старое значение } }); </script>
</body>
</html>

Tак выглядит CSS

.tooltip { position: relative; display: inline-block; border-bottom: 1px dotted black;
} .tooltip .tooltiptext { visibility: hidden; width: 120px; background-color: #555; color: #fff; text-align: center; border-radius: 6px; padding: 5px 0; position: absolute; z-index: 1; bottom: 125%; left: 50%; margin-left: -60px; opacity: 0; transition: opacity 0.3s;
} .tooltip .tooltiptext::after { content: ""; position: absolute; top: 100%; left: 50%; margin-left: -5px; border-width: 5px; border-style: solid; border-color: #555 transparent transparent transparent;
} .tooltip:hover .tooltiptext { visibility: visible; opacity: 1;
}
.error { color: red; border-color: red; border-style: double;
}
.input { width: 200px ;
}
.alignRight { float: right
}

А вот так результат работы.

Что бы разобраться как работает валидация давайте внимательно посмотрим на разметку описывающую одно поле

<td class="tooltip"> <input type="number" **v-model="order.Freight" v-bind:class="{error:!errors.Freight==''} " **class="input" /> <span v-if="errors.Freight!==''" class="tooltiptext">{{errors.Freight}}</span> </td>

Freight==''} тут vue подключает по условию css класс.
А вот эта за всплывающее окно показываемое когда курсор мыши над над элементом Здесь 2 важных ключевых момента:
Эта часть разметки подключает стиль ответственный за красную рамку вокруг элемента v-bind:class="{error:!errors.

<span v-if="errors.Freight!==''" class="tooltiptext">{{errors.Freight}}</span>

В ближайших планах разобраться с тестированием Vue. кроме этого элемент родительский элемент должен содержать атрибут class="tooltip".
Кроме этого, тут добавлена кнопка сохранить настроенная что, бы быть доступной только если сохранение возможно.
Вот собственно и все что я хотел рассказать.
Разработка с сводится к расположению полей на форме и настройке валидации.
C# часть кода отлично тестируется.

Буду очень признателен за конструктивную критику.
Вот ссылка на исходный код
https://github.com/SergiyShest/vue-Working

Теги
Показать больше

Похожие статьи

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

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

Кнопка «Наверх»
Закрыть