Как мы перестали бояться тикетов на UI
Всем привет.
Прошло уже больше года с тех пор, как мы начали использовать ReactJS в разработке. Наконец пришел момент для того, чтобы поделиться тем, насколько счастливее стала наша компания. В статье я собираюсь рассказать о причинах, которые побудили нас использовать эту библиотеку и о том, как мы это делаем.
Зачем всё это
Мы — маленькая компания, наш штат составляет порядка 50 человек, 20 из которых разработчики. Сейчас у нас 4 команды разработки, в каждой из которых сидит по 5 fullstack разработчика. Но одно дело называть себя fullstack-разработчиком, а другое — действительно хорошо разбираться в тонкостях работы SQL Server’а, ASP.NET, разработке на C#, OOP, DDD, знать HTML, CSS, JS и уметь этим всем разумно пользоваться. Конечно каждый разработчик тяготеет к чему-то своему, но все мы, так или иначе, специалисты именно в разработке на .NET и 90% кода мы пишем на C#.
Наш продукт — система автоматизации маркетинга, — подразумевает большой объем настроек для каждого конкретного клиента. Для того, чтобы наши менеджеры могли заниматься настройкой продукта под клиентов, есть административный сайт, в котором можно заводить рассылки, создавать триггеры и другие механики, кастомизировать сервисы и многое другое. Этот административный сайт содержит много различного нетривиального UI’а, и чем более тонкие моменты мы даём настраивать, чем большее количество фич мы выпускаем в продакшн, тем более интересным UI становится.
Как же мы справлялись с разработкой такого UI’а раньше? Справлялись мы плохо. В основном, отделывались отрисовкой на сервере кусков HTML’а, которые получали ajax’ом. Либо просто на событиях, используя JQuery. Для пользователя это обычно выливалось в постоянные подгрузки, прелоадеры на каждый чих и странные баги. С точки зрения разработчика это были самые настоящие макароны, которых все боялись. Любой тикет на UI на планировании сразу получал оценку L и выливался в тонну батхёрта при написании кода. И, разумеется, было много багов, связанных с таким UI’ем. Происходило это так: в первой реализации допускалась какая-то мелкая ошибка. А при починке неминуемо разваливалось что-то другое, потому что тестов на это чудо не было.
Пример из жизни. Перед вами страница создания операции. Не вдаваясь в подробности по бизнесу скажу только, что операции у нас — это что-то вроде REST-сервисов, которые могут использовать подрядчики наших клиентов. У операции есть ограничения на доступность согласно этапам регистрации потребителей, и для того, чтобы это настраивать, был вот такой контрол:
А вот старый код этого контролла:
Доступность на этапах регистрации
@Html.HiddenFor(m => m.IsAllowedForAllWorkflow, new { Id = "IsAllowedForAllWorkflow" })
Механика регистрации
Этап
@Model.OperationWorkflowAllowances.Each(
@
@item.Item.WorkflowDisplayName
@(item.Item.StageName ?? "Любой")
)
@if (Model.WorkFlows.Any())
{
@Html.DropDownList("WorkflowList", Model.WorkFlows, new Dictionary
{
{ "class", "form-control select2 w470" },
{ "data-placeholder", "Выберите из списка" },
{ "id", "workflowList" },
{ "disabled", "disabled" }
})
@Html.DropDownList("WorkflowStageList", new SelectListItem[0], new Dictionary
{
{ "class", "form-control select2 w470" },
{ "data-placeholder", "Выберите из списка" },
{ "id", "workflowStageList" },
{ "disabled", "disabled"}
})
}
else
{
@: Механики регистрации не зарегистрированы
}
А вот js, который заставлял эту вьюху работать (я не преследовал цель показать код, который можно запустить, я просто показываю, как всё было печально):
function initOperationAllowance(typeSelector)
{
$('#workflowList').prop('disabled', false);
$('#workflowList').trigger('change');
if ($(typeSelector).val() == 'PerformAction') {
$('#exceptAnonymus').html('(кроме анонимных)');
} else {
$('#exceptAnonymus').html('');
}
}
function toggleWorkflowAvailability() {
var element = $("#IsAllowedForAllWorkflow");
$('#operationAllowanceTable tbody tr').remove();
parameters.selectedAllowances = [];
return element.val().toLowerCase() == 'true' ? element.val(false) : element.val(true);
}
function deleteRow(row)
{
var index = getRowIndex(row);
row.remove();
parameters.selectedAllowances.splice(index, 1);
$('#operationAllowanceTable input').each(function () {
var currentIndex = getFieldIndex($(this));
if (currentIndex > index) {
decrementIndex($(this), currentIndex);
}
});
if (parameters.selectedAllowances.length == 0) {
$('#operationAllowanceTable').hide();
}
}
function updateWorkflowSteps(operationType) {
var workflow = $('#workflowList').val();
if (workflow == '') {
$('#isAllowedForAllStagesForCurrentWorkflow')
.prop('checked', false)
.prop('disabled', 'disabled');
refreshOptionList(
$('#workflowStageList'),
[{ Text: 'Выберите из списка', Value: '', Selected: true }]
);
$('#workflowStageList').trigger('change').select2('enable', false);
return;
}
var url = parameters.stagesUrlTemplate + '?workflowName=' + workflow + '&OperationTypeName=' + operationType;
$.getJSON(url, null, function (data) {
$('#isAllowedForAllStagesForCurrentWorkflow')
.prop('checked', false)
.removeProp('disabled');
refreshOptionList($('#workflowStageList'), data);
$('#workflowStageList').trigger('change').select2('enable', true);
});
}
function refreshOptionList(list, data) {
list.find('option').remove();
$.each(data, function (index, itemData) {
var option = new Option(itemData.Text, itemData.Value, null, itemData.Selected);
list[0].add(option);
});
}
function AddRow(data) {
var rowsCount = $('#operationAllowanceTable tr').length;
var index = rowsCount - 1;
var result =
'' : '>') +
'' +
'{DisplayWorkflowName}' +
'' +
'' +
'' +
' ' +
'' +
'' +
'{DisplayStageName}' +
'' +
'' +
' ' +
' ';
for (key in data) {
result = result.replace(new RegExp('{' + key + '}', 'g'), data[key]);
}
$('#operationAllowanceTable').show().append(result);
}
function IsValidForm() {
var result = ValidateList($('#workflowList'), 'Вы не выбрали механику регистрации') &
ValidateListWithCheckBox($('#workflowStageList'), $('#isAllowedForAllStagesForCurrentWorkflow'), 'Вы не выбрали этап механики регистрации');
if (!result)
return false;
var workflowName = $('#workflowList').val();
var stageName = '';
if (!$('#isAllowedForAllStagesForCurrentWorkflow').is(':checked'))
{
stageName = $('#workflowStageList').val();
}
hideError($('#workflowList'));
hideError($('#workflowStageList'));
for (var i = 0; i < parameters.selectedAllowances.length; i++)
{
if (parameters.selectedAllowances[i].workflow == workflowName &&
parameters.selectedAllowances[i].stage == stageName)
{
if (stageName == '')
{
showError($('#workflowList'), 'Доступность на этой механике регистрации уже указана');
}
else
{
showError($('#workflowStageList'), 'Доступность на этом этапе уже указана');
}
result = false;
}
else if (parameters.selectedAllowances[i].workflow == workflowName &&
parameters.selectedAllowances[i].stage == '') {
showError($('#workflowList'), 'Доступность на этой механике регистрации уже указана');
result = false;
}
}
return result;
}
function ValidateList(field, message) {
if (field.val() == "") {
showError(field, message);
return false;
}
hideError(field);
return true;
}
function ValidateListWithCheckBox(field, checkBoxField, message) {
if (!checkBoxField.prop('checked')) {
return ValidateList(field, message);
}
hideError(field);
return true;
}
function showError(field, message) {
if (typeof (message) === 'undefined') {
message = 'Поле обязательно для заполнения';
}
field.addClass('input-validation-error form-control_error');
field.parent('.form-group').find('div.tooltip-error').remove();
field.closest('.form-group').append(
' ');
}
function hideError(field) {
field.removeClass('input-validation-error form-control_error');
field.parent('.form-group').find('div.tooltip-icon_error').remove();
}
function getRowIndex(row) {
return getFieldIndex(row.find('input:first'));
}
function getFieldIndex(field) {
var name = field.prop('name');
var startIndex = name.indexOf('[') + 1;
var endIndex = name.indexOf(']');
return name.substr(startIndex, endIndex - startIndex);
}
function decrementIndex(field, index) {
var name = field.prop('name');
var newIndex = index - 1;
field.prop('name', name.replace('[' + index + ']', '[' + newIndex + ']'));
}
function InitializeWorkflowAllowance(settings) {
$(function() {
parameters.selectedAllowances = settings.selectedAllowances;
initOperationAllowance(parameters.typeSelector);
$('#workflowList').change(function () {
updateWorkflowSteps($(parameters.typeSelector).val());
});
$('#addOperationAllowance').click(function (event) {
event.preventDefault();
if (IsValidForm()) {
var data = {
'StageName': $('#workflowStageList').val(),
'WorkflowName': $('#workflowList').val(),
};
if ($('#isAllowedForAllStagesForCurrentWorkflow').is(':checked')) {
data.DisplayWorkflowName = $('#workflowList option[value=' + data.WorkflowName + ']').text();
data.DisplayStageName = 'Любой';
data.StageName = '';
}
else {
data.DisplayWorkflowName = $('#workflowList option[value=' + data.WorkflowName + ']').text();
data.DisplayStageName = $('#workflowStageList option[value=' + data.StageName + ']').text();
}
AddRow(data);
if (data.StageName == '') {
var indexes = [];
// Нужно удалить уже добавленные этапы
for (var i = 0; i < parameters.selectedAllowances.length; i++) {
if (parameters.selectedAllowances[i].workflow == data.WorkflowName) {
indexes.push(i);
}
}
$("#operationAllowanceTable tbody tr").filter(function (index) {
return $.inArray(index, indexes) > -1;
}).each(function () {
deleteRow($(this));
});
}
parameters.selectedAllowances.push({
workflow: data.WorkflowName,
stage: data.StageName
});
$("#workflowList").val('').trigger('change');
updateWorkflowSteps($(parameters.typeSelector).val());
}
});
$('#isAllowedForAllStagesForCurrentWorkflow').click(function () {
if ($(this).is(":checked")) {
$('#workflowStageList').prop('disabled', 'disabled');
}
else {
$('#workflowStageList').removeProp('disabled');
}
});
$('#operationAllowanceTable').on('click', 'button.removeOperationAllowance', function (event) {
var row = $(this).parent().parent();
setTimeout(function () {
deleteRow(row);
}, 20);
event.preventDefault();
});
});
Новая надежда
В какой-то момент мы поняли, что так жить больше нельзя. После некоторого обсуждения мы пришли к выводу, что нужен человек со стороны, который разбирается во фронт-энде и направит нас на истинный путь. Мы наняли фрилансера, который и предложил нам использовать React. Он не очень много поработал у нас, но успел сделать пару контроллов, чтобы показать, что к чему, и ощущения оказались двоякими. Мне очень понравился React с момента прохождения туториала на официальном сайте, но он понравился не всем. К тому же, хардкорные фронтэндщики любят javascript, но в статически типизированном мире нашей разработки javascript популярностью не пользуется (это если мягко сказать), поэтому все эти webpack’и и grunt’ы, которые нам предлагалось использовать, только пугали нас. В итоге было решено сделать несколько прототипов сложного UI’а, используя разные фреймворки для того, чтобы решить, с каким именно нам нужно иметь дело. Сторонники каждого из фреймворков, из которых мы выбирали, должны были сделать прототип одного и того же контролла, чтобы мы могли сравнить код. Мы сравнивали Angular, React и Knockout. Последний не прошёл даже стадию прототипа, и я даже не помню уже, по какой именно причине. Однако между сторонниками Angular’а и React’а в компании развернулась настоящая гражданская война!
Шутка :) На самом деле у каждого фреймворка было по одному стороннику, всем остальным не нравился ни тот, ни другой. Все мялись и не могли ничего решить. В Angular’е всех раздражала его сложность, а в React’е — стрёмный синтаксис, отсутствие поддержки которого в Visual Studio на тот момент было действительно очень неприятным фактом.
К счастью для нас, нам на помощь пришёл наш начальник (один из владельцев компании), который конечно уже давно не программирует, но держит руку на пульсе. После того, как стало ясно, что прототипы никакого эффекта не дали, и разработка тратит время непонятно на что (в тот момент мы планировали сделать ещё один прототип на много большего размера, чтобы было больше кода для сравнения!), принимать решение пришлось ему. Сейчас, вспоминая, почему его выбор тогда всё-таки пал на React, Саша agornik Горник рассказал мне следующее (я привожу его слова не для холивара, это просто мнение. Орфография, разумеется, сохранена, хотя кое-что я всё-таки поправил):
Было несколько прототипов: реакт, ангуляр и еще что-то вроде. Я посмотрел. Ангуляр не понравился, реакт понравился.
Но [кое-кто] кричал громче всех, а все остальные были как овощи. Пришлось читать и смотреть.
Я увидел что реакт — в продакшене на куче крутых сайтов. FB, Yahoo, WhatsApp и еще что-то там. Явно уже огромный адопшн идет и есть будущее.
А на ангуляре — [ничего хорошего]. Посмотрел на будещее. Увидел что всё что мне не понравилось в прототипе ангуляра хотят в 2.0 усилить.
Я понял что react — это штука для жизни сделанная решаюшая конкретную проблему. А ангуляр — это бородатые теоретики из гугла из мозга придумывают всякие концепции. Как было с GWT или как он там.
Ну и понял что надо волевым решением встать на сторону овощей, иначе победят кричащие, но неправые. Перед тем как это сделать я накидал в канал 33 миллиона пруфов и ссылок, заручился поддержкой [нашего главного архитектора] и постарался сделать так, чтобы никто не забатхертил.
А еще я вспомнил какой был адски важный аргумент. Для реакта был красивый способ делать поэтапно и вкрячивать в существующие страницы, а ангуляр требовал переделывать их целиком, и это тоже корреклирует с [его плохой] архитектурой.
Потом я еще прочитал что на реакте в теории можно UI даже не для веба делать. И всякий там серверный js / react и куда всё это идет. И кароче ваще ни одного аргумента не оставалось не брать.
Я понял что поддержку для студии впилят очень быстро. В итоге всё ровно так и вышло. Я конечно адски доволен этим решением)
Что же получилось?
Пришло время раскрыть карты и показать, как мы теперь варим UI. Конечно же, фронт-эндщики сейчас начнут смеяться, но для нас этот код — настоящая победа, мы им очень довольны :)
Для примера буду использовать страницу создания дополнительных полей. Краткая бизнес-справка: у некоторых сущностей, таких как Потребители, Заказы, Покупки и Продукты, могут быть какие-то связанные данные, специфичные для клиента. Для того, чтобы такие данные хранить, мы используем классическую Entity–attribute–value model. Изначально дополнительные поля для каждого клиента заводили прямо в бд (для того, чтобы сэкономить время разработки), но наконец время нашлось и для UI.
Вот, как выглядит страница добавления дополнительного поля в проекте:
А вот, как выглядит код этой страницы на React’е:
///
module DirectCrm
{
export interface SaveCustomFieldKindComponentProps extends Model
{
}
interface SaveCustomFieldKindComponentState
{
model?: CustomFieldKindValueBackendViewModel;
validationContext: IValidationContext;
}
export class SaveCustomFieldKindComponent extends React.Component
{
private _componentsMap: ComponentsMap;
constructor(props: SaveCustomFieldKindComponentProps)
{
super(props);
this.state = {
model: props.model,
validationContext: createTypedValidationContext(props.validationSummary)
};
this._componentsMap = ComponentsMap.initialize(this.state.model.componentsMap);
}
_setModel = (model: CustomFieldKindValueBackendViewModel) =>
{
this.setState({
model: model
});
}
_handleFieldTypeChange = (newFieldType: string) =>
{
var clone = _.clone(this.state.model);
clone.fieldType = newFieldType;
clone.typedViewModel = {
type: newFieldType,
$type: this._componentsMap[newFieldType].viewModelType
};
this._setModel(clone);
}
_getColumnPrefixOrEmptyString = (entityType: string) =>
{
var entityTypeDto = _.find(this.props.model.entityTypes, et => et.systemName === entityType);
return entityTypeDto && entityTypeDto.prefix || "";
}
_hanleEntityTypeChange = (newEntityType: string) =>
{
var clone = _.clone(this.state.model);
clone.entityType = newEntityType;
var columnPrefix = this._getColumnPrefixOrEmptyString(newEntityType);
clone.columnName = `${columnPrefix}${this.state.model.systemName || ""}`;
this._setModel(clone);
}
_handleSystemNameChange = (newSystemName: string) =>
{
var clone = _.clone(this.state.model);
clone.systemName = newSystemName;
var columnPrefix = this._getColumnPrefixOrEmptyString(this.state.model.entityType);
clone.columnName = `${columnPrefix}${newSystemName || ""}`;
this._setModel(clone);
}
_renderComponent = () =>
{
var entityTypeSelectOptions =
this.state.model.entityTypes.map(et =>
{
return { Text: et.name, Value: et.systemName }
});
var fieldTypeSelectOptions =
Object.keys(this._componentsMap).
map(key =>
{
return {
Text: this._componentsMap[key].name,
Value: key
};
});
var componentInfo = this._componentsMap[this.state.model.fieldType];
var TypedComponent = componentInfo.component;
return (
m.entityType)}>
m.name)}>
viewModel.name)} />
m.systemName)}>
m.fieldType)}>
m.typedViewModel)}
onChange={getPropertySetter(
this.state.model,
this._setModel,
viewModel => viewModel.typedViewModel)}
value={this.state.model.typedViewModel}
constantComponentData={componentInfo.constantComponentData} />
viewModel.isMultiple)}
disabled={false} />
{this._renderShouldBeExportedCheckbox()}
);
}
_getViewModelValue = () =>
{
var clone = _.clone(this.state.model);
clone.componentsMap = null;
clone.entityTypes = null;
return clone;
}
render() {
return (
);
}
_renderShouldBeExportedCheckbox = () =>
{
if (this.state.model.entityType !== "HistoricalCustomer")
return null;
return (
m.shouldBeExported)}>
viewModel.shouldBeExported)}
disabled={false} />
);
}
}
}
TypeScript
«Что это было?» — можете спросить вы, если ожидали увидеть javascript. Это tsx — вариант React’ового jsx’а под TypeScript. Наш UI полностью статически типизирован, никаких «магических строк». Согласитесь, этого можно было ожидать от таких хардкорных бэкэндщиков, как мы :)
Тут нужно сказать несколько слов. У меня нет цели поднимать холивар на тему статически- и динамически-типизированных языков. Просто так сложилось, что у нас в компании никто не любит динамические языки. Мы считаем, что на них нельзя очень сложно написать большой поддерживаемый проект, который рефакторится годами. Ну и просто писать сложно, потому что IntelliSense не работает :) Такое вот у нас убеждение. Можно поспорить, что можно всё покрыть тестами, и тогда это будет возможно и с динамически типизированным языком, но спорить на эту тему мы не будем.
Формат tsx поддерживается студией и новым R#, что является ещё одним очень важным моментом. А ведь год назад в студии (не то что в R#) не было поддержки даже jsx’а, и для разработки на js приходилось иметь ещё один редактор кода (мы использовали Sublime и Atom). В следствие этого половины файлов не хватало в студийном Solution’е, что только добавляло батхёртов. Но не будем об этом, ведь счастье уже наступило.
Нужно заметить, что даже typescript в чистом виде не даёт тот уровень статической типизации, который хотелось бы. Например, если мы хотим установить в модели какое-то свойство (фактически сбиндить UI-контролл на какое-то свойство модели), мы можем написать callback-функцию для каждого такого свойства, что долго, и можем использовать один callback, принимающий имя свойства, что ни разу не статически типизировано. Конкретно эта проблема у нас решена примерно таким кодом (вы можете видеть примеры использования getPropertySetter выше):
///
function getPropertySetter(
viewModel: TViewModel,
viewModelSetter: {(viewModel: TViewModel): void},
propertyExpression: {(viewModel: TViewModel): TProperty}): {(newPropertyValue: TProperty): void}
{
return (newPropertyValue: TProperty) =>
{
var viewModelClone = _.clone(viewModel);
var propertyName = getPropertyNameByPropertyProvider(propertyExpression);
viewModelClone[propertyName] = newPropertyValue;
viewModelSetter(viewModelClone);
};
}
function getPropertyName(obj: TObject, expression: {(obj: TObject): any}): string
{
return getPropertyNameByPropertyProvider(expression);
}
function getPropertyNameByPropertyProvider(propertyProvider: Function): string
{
return /\.([^\.;]+);?\s*\}$/.exec(propertyProvider.toString())[1];
}
Нет никаких сомнений в том, что реализация getPropertyNameByPropertyProvider очень-очень стрёмная (другого слова даже не подберешь). Но другого выбора typescript пока не предоставляет. ExpressionTree и nameof в нём нет, а положительные стороны getPropertySetter перевешивают отрицательные стороны такой реализации. В конце концов, что с ней может случиться? Она может начать тормозить в какой-то момент, и можно будет приписать туда какое-нибудь кэширование, а может к тому времени и какой-нибудь nameof в typescript сделают.
Благодаря такому хаку у нас, например, работает переименование по всему коду и не надо заботиться о том, что что-то где-то развалилось.
В остальном всё работает просто волшебно. Не указал какой-нибудь обязательный prop для компонента? Ошибка компиляции. Передал prop неправильного типа в компонент? Ошибка компиляции. Никаких дурацких PropTypes с их предупреждениями в рантайме. Единственная проблема тут в том, что backend у нас всё-таки на C#, а не на typescript, поэтому каждую модельку, используемую на клиенте, нужно описывать дважды: на сервере и на клиенте. Однако решение этой проблемы существует: мы сами написали прототип генератора типов для typescript из типов на .NET после того, как попробовали opensource’ные решения, которые нас не удовлетворили, но потом прочитали эту статью. Выглядит так, что нужно только применить эту утилиту как-нибудь и посмотреть, как она себя ведёт в боевых условиях. Судя по всему всё уже хорошо.
Отрисовка компонентов
Расскажу более подробно, как мы инициализируем компоненты при открытии страницы и как они взаимодействуют с серверным кодом. Сразу предупрежу, что каплинг довольно высокий, но что поделать.
Для каждого компонента на сервере есть вью-моделька, на которую это компонент сбиндится при POST-запросе. Обычно та же самая вью-моделька используется и для того, чтобы изначально инициализировать компонент. Вот, например, код (C#), который инициализирует вью-модельку страницы дополнительных полей, показанную выше:
public void PrepareForViewing(MvcModelContext mvcModelContext)
{
ComponentsMap = ModelApplicationHostController
.Instance
.Get()
.GetNamedObjectRelatedComponentsMapFor(
customFieldViewModel => customFieldViewModel.PrepareForViewing(mvcModelContext));
EntityTypes = ModelApplicationHostController.NamedObjects
.GetAll()
.Select(
type => new EntityTypeDto
{
Name = type.Name,
SystemName = type.SystemName,
Prefix = type.ColumnPrefix
})
.ToArray();
if (ModelApplicationHostController.NamedObjects.Get().Sku.IsEnabled())
{
EntityTypes =
EntityTypes.Where(
et => et.SystemName != ModelApplicationHostController.NamedObjects
.Get().Purchase.SystemName)
.ToArray();
}
else
{
EntityTypes =
EntityTypes.Where(
et => et.SystemName != ModelApplicationHostController.NamedObjects
.Get().Sku.SystemName)
.ToArray();
}
if (FieldType.IsNullOrEmpty())
{
TypedViewModel = new StringCustomFieldKindTypedViewModel();
FieldType = TypedViewModel.Type;
}
}
Тут инициализируются некоторые свойства и коллекции, которые будут использоваться для заполнения списков.
Чтобы, используя данные этой вью-модели, нарисовать какой-то компонент, написан Extension-метод HtmlHelper. Фактически, в любом месте, где нам нужно отрендерить компонент, мы используем код:
@Html.ReactJsFor("DirectCrm.SaveCustomFieldKindComponent", m => m.Value)
Первым параметром принимается имя компонента, вторым — PropertyExpression — путь во вью-модели страницы, где находятся данные для данного компонента. Вот код этого метода:
public static IHtmlString ReactJsFor(
this HtmlHelper htmlHelper,
string componentName,
Expression> expression,
object initializeObject = null)
{
var validationData = htmlHelper.JsonValidationMessagesFor(expression);
var metadata = ModelMetadata.FromLambdaExpression(expression, htmlHelper.ViewData);
var modelData = JsonConvert.SerializeObject(
metadata.Model,
new JsonSerializerSettings
{
TypeNameHandling = TypeNameHandling.Auto,
TypeNameAssemblyFormat = FormatterAssemblyStyle.Full,
Converters =
{
new StringEnumConverter()
}
});
var initializeData = JsonConvert.SerializeObject(initializeObject);
return new HtmlString(string.Format(
"",
HttpUtility.HtmlEncode(componentName),
HttpUtility.HtmlEncode(htmlHelper.NameFor(expression)),
HttpUtility.HtmlEncode(modelData),
HttpUtility.HtmlEncode(validationData),
HttpUtility.HtmlEncode(initializeData)));
}
Фактически, мы просто рендерим div, у которого в атрибутах находятся данные, необходимые для рендеринга компонента: название компонента, путь в более глобальной модели, данные, которыми будет проинициализирован компонент, серверные валидационные сообщения, а так же какие-либо дополнительные данные для инициализации. Далее при отрисовке страницы за счёт нехитрого в этот div будет срендерен компонент:
function initializeReact(context) {
$('div[data-react-component]', context).each(function () {
var that = this;
var data = $(that).data();
var component = eval(data.reactComponent);
if (data.reactInitialize == null) {
data.reactInitialize = {};
}
var props = $.extend({
model: data.reactModel,
validationSummary: data.reactValidationSummary,
modelName: data.reactModelName
}, data.reactInitialize);
React.render(
React.createElement(component, props),
that
);
});
}
Таким образом рендерятся основные компоненты, которые хранят основное состояние страницы — то есть в большинстве случаев именно у этих компонентов вообще есть state. Вложенные же в них компоненты обычно либо не имеют состояния вообще, либо их состояние не является важным в рамках страницы (как например флаг открытости/закрытости выпадающего меню в select’е).
Binding
Прекрасно, мы нарисовали компонент, но как же данные попадут обратно на сервер?
Всё довольно просто. По крайней мере в первом приближении. Большинство страниц достаточно просты и используют обычный пост формы. У контроллов в компонентах нет имён, и биндинг происходит за счёт того, что при любом изменении состояния основного компонента (а он фактически хранит состояние всей страницы, как я говорил выше), перерендеривается специальный hidden input, содержащий текущее состояние модели, сериализованное в json. Для того, чтобы этот json биндился на наше ASP.NET приложение, был написан специальный ModelBinder.
Начнём с hidden input’а. Каждый компонент страницы содержит в себе следующий компонент:
Его код довольно прост:
class HiddenInputJsonSerializer extends React.Component<{ model: any, name: string }, {}> {
render() {
var json = JSON.stringify(this.props.model);
var name = this.props.name;
return (
);
}
}
При посте формы мы фактически постим одно значение — огромный json с именем, которое оказалось в this.props.modelName —, а это то самое имя, которое мы передали в data-react-model-name при рендеринге (см. выше), то есть текстовый путь в некоторой большой вью-модели до нашей вью-модельки, которая приедет json’ом.
Для того, чтобы этот json сбиндился на вью-модель в приложении, используется следующий код. Для начала, свойства вью-моделей, которые мы хотим получать из json’а, должны быть помечены специальным JsonBindedAttribute. Ниже представлен код родительской вью-модели, в которую вложена вью-модель, которая будет биндиться из json:
© Habrahabr.ru