• Валидация форм на React JS

    Нам нужны формы. Больше форм.

    Любой разработчик знает это. Каким бы хитрым фреймоворком вы ни пользовались, какие бы неожиданные идеи ни привносила в процесс разработки ваша очередная революционная концепция, как бы ни был устроен поток данных приложения, вам никуда не уйти от форм. Как только ваше приложение требует ввода более-менее сложных данных, старое доброе решение остается самым простым и эффективным, самым понятным пользователю и простым в реализации. И в React Js та же самая, старая как мир история — нам снова нужны формы.

    Первой идеей было взять понятное и простое готовое решение, но, на второй взгляд, оно оказалось плохим: там используются неправильные с точки зрения архитектуры React решения: назначение props уже после создания элемента, жесткая связь между элементами. Как это обычно и бывает, неправильная архитектура ведет к неправильному поведению приложения в базовых сценариях. Представьте себе, например, форму, которая меняется динамически в зависимости от вашего ввода — это же как раз то, зачем React и сделан, именно та задача, с которой он отлично справляется.

    Но не в этом случае. Если назначить props уже после создания элемента (чего делать не стоит — это, к слову, warning), они не сохранятся после пересоздания компонента, когда React решит еще раз запустить render(). Так что жесткая связь лишает главного преимущества React — реактивных view. Правильным решением было бы использовать события — они позволяют общаться элементам, не связывая их напрямую друг с другом. Всё, что для этого нужно — общая шина. Предлагаю своё решение, вы можете сразу посмотреть демо.

    API

    Для уменьшения объема кода, в производных компонентах данные методы должны вызываться в контексте самого компонента (ниже будет пример реализации формы и текстового поля ввода). К сожалению, это делает их зависимыми от внутренней структуры компонента, вызывающего их. С другой стороны, опций немного, так что все их несложно отследить.

    • FormBus.Form.attachForm() — подписка формы на события, необходимо вызвать в componentWillMount компонента формы.
    • FormBus.Form.detachForm() — отписка формы от событий, необходимо вызвать в componentWillUnmount компонента формы.
    • FormBus.Form.validateField() — валидация одного поля формы, возвращает promise, состояние которого изменится на «выполнено» сразу после генерации события field_validation.
    • FormBus.Form.validate() — валидация всех полей формы, возвращает promise, состояние которого изменится на «выполнено» сразу после завершения валидации последнего поля.
    • FormBus.Input.attachField() — подписка поля ввода на события, необходимо вызвать в componentWillMount компонента ввода.
    • FormBus.Input.detachField() — отписка поля ввода от события, необходимо вызвать в componentWillUnmount компонента ввода.
    • FormBus.Input.updateField() — обновить значение поля в модели, необходимо вызвать после изменения введенного значения в поле ввода

    События

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

    • field_validation: {command: “field_validation”, name: “foo”, message: “foo is too bar”} — результат валидации поля
    • mount: {command: “mount”, name: “foo”, value: “bar”} — появление на форме нового поля
    • unmount: {command: “unmount”, name: “foo”} — исчезновение поля с формы
    • update: {command: “update”, name: “foo”, value: “bar”} — изменение значения в поле
    • model_update: {command: “model_update”, model: {foo: “bar”}} — изменение модели формы

    Компонент формы

    Создадим компонент формы. Он будет выполнять роль аггрегатора данных, поступающих из элементов ввода.

    var React = require('react');
    var FormBus = require('components/form-bus');
    
    var Form = React.createClass({
      getDefaultProps: function () {
        return {
          // уникальное название формы, для нескольких форм на одной странице
          // следует задать элементам каждой формы уникальный formns
          formns: 'form_event',
          onSubmit: function (model) {
            // переопределите этот метод при создании родительского класса
          }
        };
      },
      getInitialState: function () {
        return {
          // состояние модели формы
          model: {},
          // ошибки формы
          errors: {},
          // состояние проверки формы
          valid: true
        };
      },
      componentWillMount: function () {
        // схема валидации пустая при загрузке, чтобы форма
        // не валидировала пустые значения при отрисовке
        this.schema = {};
        // подписываемся на события
        FormBus.Form.attachForm.bind(this)();
      },
      componentWillUnmount: function () {
        // отписываемся от событий
        FormBus.Form.detachForm.bind(this)();
      },
      componentDidMount: function () {
        // после загрузки формы, загружаем настоящую схему
        this.schema = this.props.schema();
      },
      submit: function (e) {
        var component = this;
        e.preventDefault();
        // принудительный запуск валидации после отправки формы
        FormBus.Form.validate.bind(this)()
          // после завершения валидации, будет выполнен promise
          .then(function () {
            if (component.state.valid) {
              // если форма валидна, запускаем назначенный обработчик
              component.props.onSubmit(component.state.model);
            }
          });
      },
      render: function () {
        return (
          <form noValidate="novalidate" onSubmit={this.submit}>
            {this.props.children}
            <div className="form-group">
              <div className="col-sm-offset-2">
                <button className="btn btn-primary" type="submit">Сохранить</button>
              </div>
            </div>
          </form>
        );
      }
    });
    
    module.exports = Form;

    Компонент текстового поля

    Создадим также простейший компонент с логикой текстового поля. Основная роль любого компонента ввода: при создании заявить миру о своем существовании, при кончине сообщить о ней, а также держать всех в курсе изменения значения. Всё это достигается вызовом соответствующих методов FormBus.Input в контексте текущего класса. Важно, чтобы у компонента был задан formns в свойствах, а также value в состоянии.

    var React = require('react');
    var FormBus = require('components/form-bus');
    
    var TextInput = React.createClass({
      getDefaultProps: function () {
        return {
          name: 'Field',
          formns: 'form_event'
        };
      },
      getInitialState: function () {
        return {
          // значение задается в родительском классе, либо берется пустое
          value: this.props.value || '',
          error: null
        };
      },
      componentWillMount: function () {
        // подписываемся на события
        FormBus.Input.attachField.bind(this)();
      },
      componentWillUnmount: function () {
        // отписываемся от событий
        FormBus.Input.detachField.bind(this)();
      },
      setValue: function (e) {
        var component = this;
        // сохраняем в состоянии текущее значение пользовательского ввода
        this.setState({
          value: e.currentTarget.value
        }, function () {
          // отправляем событие об изменении значения
          FormBus.Input.updateField.bind(component)();
        });
      },
      render: function () {
        var error = this.state.error ? (
          <span id="name-error" className="error">{this.state.error}</span>
        ) : null;
    
        return (
          <div className="form-group">
            <label className="control-label">
              {this.props.name}
              {error}
            </label>
            <input name={this.props.name} type="text" className="form-control" onChange={this.setValue} value={this.state.value} />
          </div>
        );
      }
    });
    
    module.exports = TextInput;

    Собираем детали вместе

    var React = require('react');
    var Layout = require('ui-components/layouts');
    var Form = require('ui-components/form');
    var TextInput = require('ui-components/text-input');
    var validator = require('validator');
    var dispatcher = require('validator');
    
    var FormScreen = React.createClass({
      // уникальное название для формы
      formns: "form1",
      componentWillMount: function () {
        // подписаться на события формы form1
        dispatcher.on(this.formns, this.listener);
      },
      componentWillUnmount: function () {
        // отписаться от событий формы form1
        dispatcher.off(this.formns, this.listener);
      },
      // схема валидации, где каждому ключу с именем поля соответствует массив
      // простых литералов вида {validate: function (value) {...}, message: "Err"}
      getValidationSchema: function () {
        return {
          name: [
            {
              validate: function (value) {
                return validator.isLength(value, 1, 255);
              },
              message: "Пожалуйста, укажите имя"
            }
          ],
          surname: [
            {
              validate: function (value) {
                return validator.isLength(value, 1, 256);
              },
              message: "Пожалуйста, укажите фамилию"
            }
          ],
          email: [
            {
              validate: function (value) {
                return validator.isLength(value, 1, 256);
              },
              message: "Пожалуйста, укажите email"
            },
            {
              validate: validator.isEmail,
              message: "Пожалуйста, укажите корректный email"
            }
          ]
        }
      },
      submit: function(model) {
        // форма успешно валидирована
      },
      listener: function (event) {
        if (event.command == 'model_update') {
          // обновить состояние модели
          this.setState({model: event.model});
        }
      },
      render: function() {
    
        // поле `фамилия` появится только после заполнения поля `email`
        var surname = (this.state.model && this.state.mode.email) ? (
          <TextInput formns={this.formns} name="surname" title="Фамилия" />
        ) : null;
    
        return (
          <Layout>
            <Form formns={this.formns} onSubmit={this.submit} schema={this.getValidationSchema}>
              <TextInput formns={this.formns} name="name" title="Имя" />
              {surname}
              <TextInput formns={this.formns} name="email" title="Email" />
            </Form>
          </Layout>
        );
      }
    });
    
    module.exports = FormScreen;

    Библиотека

    Использование jquery для промисов и event-emitter для событий не принципиально. Это могут быть любые промисы (например, q) и любая шина событий.

    var dispatcher = require('dispatcher');
    var jquery = require('jquery');
    
    var FormBus = {
      /**
       * Attach form to form bus
       */
      attachForm: function () {
        dispatcher.on(this.props.formns, FormBus.listener.bind(this));
      },
    
      /**
       * Detach form from form bus
       */
      detachForm: function () {
        dispatcher.off(this.props.formns, FormBus.listener.bind(this));
      },
    
      /**
       * Validate form
       */
      validate: function () {
        var promises = [];
        for (var name in this.state.model) {
          promises.push(FormBus.validateField.bind(this)(name));
        }
    
        var promise = jquery.Deferred();
        jquery.when(...promises).then(function() {
          promise.resolve();
        });
    
        return promise;
      },
    
      /**
       * Validate field with current model value
       * @param name
       */
      validateField: function (name) {
        var promise = jquery.Deferred();
        if (this.schema[name]) {
          var isValid = true;
          for (var rule_key in this.schema[name]) {
            if (this.schema[name].hasOwnProperty(rule_key)) {
              var rule = this.schema[name][rule_key];
              isValid = rule.validate(this.state.model[name]);
              if (!isValid) {
                break;
              }
            }
          }
          var errors = this.state.errors;
          if (isValid) {
            delete errors[name];
          } else {
            errors[name] = rule.message;
          }
    
          var errorCount = 0;
          for (var key in this.state.errors) {
            errorCount++;
          }
    
          this.setState({errors: errors, valid: errorCount == 0}, function () {
            dispatcher.emit(this.props.formns, {command: 'field_validation', name: name, message: isValid ? null : rule.message});
            promise.resolve();
          });
    
          return promise;
        }
      },
    
      /**
       * Form events listener
       * @param event
       */
      listener: function (event) {
        var component = this;
        var model = this.state.model;
        var errors = this.state.errors;
        switch (event.command) {
          /**
           * Field mounted
           */
          case 'mount':
            model[event.name] = event.value;
            FormBus.setModel.bind(this)(model, errors);
            break;
    
          /**
           * Field updated
           */
          case 'update':
            model[event.name] = event.value;
            FormBus.setModel.bind(this)(model, errors, function () {
              FormBus.validateField.bind(component)(event.name);
            });
            break;
    
          /**
           * Field unmounted
           */
          case 'unmount':
            delete model[event.name];
            delete errors[event.name];
            FormBus.setModel.bind(this)(model, errors, function () {
              FormBus.validateField.bind(component)(event.name);
            });
            break;
          }
      },
      setModel: function (model, errors, callback) {
        this.setState({model: model, errors: errors}, function() {
          if (callback) {
            callback();
          }
          dispatcher.emit(this.props.formns, {command: 'model_update', model: model});
        });
      }
    };
    
    var InputBus = {
      /**
       * Attach field to form bus
       */
      attachField: function () {
        // subscribe to events
        dispatcher.on(this.props.formns, InputBus.listener.bind(this));
        // emit field creation event
        dispatcher.emit(this.props.formns, {command: 'mount', name: this.props.name, value: this.state.value});
      },
      /**
       * Detach field from form bus
       */
      detachField: function () {
        // unsubscribe from events
        dispatcher.off(this.props.formns, InputBus.listener.bind(this));
        // emit field removal event
        dispatcher.emit(this.props.formns, {command: 'unmount', name: this.props.name, value: this.state.value});
      },
      /**
       * Update field value
       */
      updateField: function () {
        // emit field update event
        dispatcher.emit(this.props.formns, {command: 'update', name: this.props.name, value: this.state.value});
      },
      /**
       * Field events listener
       * @param event
       */
      listener: function (event) {
        switch (event.command) {
        /**
         * Field validation result
         */
        case 'field_validation':
          if (this.props.name == event.name) {
            this.setState({error: event.message});
          }
          break;
        }
      }
    };
    
    module.exports = {
      Form: FormBus,
      Input: InputBus
    };

    Буду рад услышать ваши мысли и критику.