Zamiana formularza na obiekt JS (w uniwersalny sposób)

13 May 2012

Wprowadzenie: powiedzmy, że robimy formularz, który ma działać tradycyjnie (w przypadku gdy jest wyłączony JS w przeglądarce) oraz asynchronicznie. W momencie nadawania nazw polom formularza, wygodnie jest używać [kwadratowych][nawiasów]. Dzięki temu po przyjściu do serwera dane są zamienione na kilka zagnieżdzonych w sobie tablic - grupowanie zostaje zachowane (przynajmniej tak to działa w przypadku Apache i PHP). W tablicach, które otrzymamy na serwerze, kluczami będą nazwy, które wpisaliśmy kwadratowych nawiasach.

<form action="/" method="post">

  <input type="text" name="contact[name]" placeholder="Name" />
  <input type="text" name="contact[email]" placeholder="E-mail" />
  <input type="text" name="contact[captcha][input]" placeholder="Captcha" />
  <input type="hidden" name="contact[captcha][id]" value="abc123" />

</form>

W powyższym przykładzie w pozycji o kluczu captcha będzie znajdować sie tablica z dwoma kluczami id oraz input. Wszystko super - tylko co jeśli ten sam formularz chcemy wysłać asynchronicznie, w formacie JSON również z zachowaniem grupowania pól? Dla niecierpliwych, oto moje rozwiązanie tego problemu. A dla wszystkich innych, poniżej zamieszczam bardziej szczegółowy opis.

Rozwiązanie

Wygodnie byłoby przedstawić dane z formularza w formie obiektu. Obiekt ten powinien również posiadać odpowiednio zagnieżdżone pola. Jeśli to zrobimy, później można taki obiekt zamienić wygodnie na JSONowy string za pomocą wbudowanej funkcji JSON.stringify(obiekt). Gdybyśmy chcieli wszystko robić ręcznie wyglądało by to mnie wiecej tak:


// tutaj trzeba jakoś pobrać wartości wpisane w inputy
// oraz ułożyć je w obiekt o odpowiednich polach
var contact = {};
contact.name = "wartosć z formularza...";
contact.email = "wartosć z formularza...";
contact.captcha = {};
contact.captcha.input = "wartosć z formularza...";
contact.captcha.id = "wartosć z formularza...";

var data = JSON.stringify(contact);
// wysłanie danych ajaxem...

Problem polega na tym, że powyższy sposób uzależnia nasz skrypt JS od tego jakie nazwy mają pola formularza. Co w przypadku gdy takich formularzy mamy w danym systemie 42? Po pierwsze, za każdym nowym formularzem będzie trzeba pisać nową funkcje do konwersji formularza na obiekt. Po drugie w przypadku gdy zmieni nam ktoś nazwe pola, będziemy musieli edytować nie tylko formularz ale i skrypt. Każdy szanujący się developer jest zbyt leniwy aby zostawić tak tą sytuacje (:>).

Rozwiązanie lepsze

Problem podzielić można na mniejsze etapy. Pierwszy stanowi wyciągnięcie listy pól z formularza. Kolejny etap polega na utworzeniu obiektu o odpowiednio zagnieżdzonych polach. Aby utworzyć taki obiekt, trzeba w jakiś sposób "zdekodować" nazwy pól występujące w formularzu i zamienić je na notację kropkową obiektu. Wynik będziemy mogli w prosty sposób zamienić na JSONowy string i wysłać do serwera.

Wyciągnięcie pól z formularza

Problem pierwszy został juz dawno rozwiązany przez autorów biblioteki jQuery. Zgodnie z zasadą DRY, postanowiłem wykorzystać istniejące rozwiązanie. Po odnalezieniu elementu formularza, wystarczy na nim uzyć jQuerowej metody serializeArray() aby otrzymać tablicę zawierającą nazwy i aktualne wartości pól.


// znajdujemy formularz
var form = $('#contact-form');
// pobieramy liste pól i wartości
var fields = form.serializeArray();
// sprawdzamy co nam sie udało uzyskać
console.log(fields);

/****
  w konsoli zwraca:

  Object
      name: "contact[name]"
      value: "Michal"
  Object
      name: "contact[email]"
      value: "abc@abc.com"
  Object
      name: "contact[captcha][input]"
      value: ""
  Object
      name: "contact[captcha][id]"
      value: "abc123"

****/

Gdyby nam nie zależało, możnaby juz taki wynik zmienić na JSON i wysłać. Problem tylko w tym, że taki format danych zmusza nas do tego, że po stronie serwera będzie trzeba zaimplementować dwa odrębne sposoby parsowania danych, które przyszły w tablicy POST (inaczej bedzie wyglądała tablica POST requestu zwykłego a inaczej AJAXowego). Gdyby w naszym JSONie zachować takie samo zagnieżdzanie jak w przypadku nazw tablicowych, wtedy po stronie serwera, requesty pochodzące z AJAXa wystarczyłoby przepuścić przez funkcje json_decode(stringJSON, true) (o ile używamy PHP) - aby zamienić dane na tablice asocjacyjną identyczną z tą jaką mamy ze zwykłego requesta. A więc poza dodatkową funkcją po stronie PHP logika w kontrolerze mogłaby pozostać identyczna.

Zamiana nazw pól na właściwości obiektu

Zajmijmy się więc zamianą nazwy danego elementu formularza na odpowiadającą nazwe pola w obiekcie JS. Aby rozbić taki przykładowy string: "contact[captcha][id]" na poszczególne części ["contact", "captcha", "id"], postanowiłem wykorzystać wyrażenia regularne. Oto link do przydatnej strony, która pomogła mi testować różne wyrażenia. Tak mniej wiecej wygląda samo znalezienie pasujących fragmentów w obrębie jednego stringa:


var nazwaPola = "contact[adres][tel][kom]"; // ten string chcemy rozbic
var wyrazenie = /([^\]\[]+)/g; // spędziłem 2 godziny na nauce w. regularnych
var rezultat = [];

while((wynikSzukania = nazwaPola.exec(wyrazenie)))
{
   // zapiszmy pasujący fragment w tablicy
   rezultat.push(wynikSzukania[0]);
}

console.log(rezultat);

/****
  w konsoli zwraca:

  ["contact", "adres", "tel", "kom"]

****/

Utworzenie obiektu o odpowiednio zagnieżdzonych polach

Rozwiązanie tego problemu postanowiłem odrazu zapisać w postaci funkcji. Nasza funkcja będzie otrzymywać trzy argumenty. Pierwszy z nich to obiekt (argument nazwany target). Drugi to tablica z nazwami pól (argument properties), które mamy za zadanie utworzyć w obiekcie target. Na końcu, do ostatniego, najbardziej zagnieżdzonego pola, przypisujemy wartość z formularza (czyli argument trzeci - value). Poniżej znajduje się moja twórczość, która rozwiązuje dokładnie ten problem.


var attachProperties = function(target, properties, value)
{
    var currentTarget = target;
    var propertiesNum = properties.length;
    var lastIndex = propertiesNum - 1;

    for (var i = 0; i < propertiesNum; ++i)
    {
         currentProperty = properties[i];

         if (currentTarget[currentProperty] === undefined)
         {
             currentTarget[currentProperty] = (i === lastIndex) ? value : {};
         }

         currentTarget = currentTarget[currentProperty];
    }
}

// przykład użycia powyższej funkcji mógłby wyglądać tak:

var dane = {};
var pola = ["contact", "telefon", "kom"];
var wartosc = "32423424";

// dodajemy pola do obiektu "dane"
attachProperties(dane, pola, wartosc);
// wypiszmy wszystko co jest w obiekcie
console.log(dane);

/****
   w konsoli zwraca coś w stylu:
   Object
      contact: Object
                  telefon: Object
                             kom: "32423424"
****/

Pomysł polega na tym, że z każdą iteracją zapamiętujemy dotychczasowy obiekt, który został utworzony i jesli jest potrzeba, "doczepiamy" do niego kolejne pola. Jeśli nie ma więcej nazw pól, przypisujemy wartość z formularza. Powyższą funkcje wystarczyłoby wykonać dla każdego pola formularza i jesteśmy w domu.

Złożyć to wszystko do kupy

Powyższe fragmenty ubrałem w funkcje zamieszczone poniżej. Funkcja extractFieldNames zajmuje się rozbiciem stringa (nazwy pola) na tablicę, attachProperties dodaje do wynikowego obiektu odpowiednie pola i wartości. Z kolei nie omawiana jeszcze funkcja convertFormDataToObject iteruje przez pola formularza i używa dwóch poprzednich funkcji do utworzenia wynikowego obiektu. Dodałem także komentarze i adnotacje. Próbowałem utrzymać konwencje zgodną z adnotacjami używanymi wraz z closure compiler - kto wie, może kiedys mi przyjdzie do głowy kompilować ten kod.


<!doctype html>
<html>
  <head>
    <meta charset="utf-8" />
    <title></title>
  </head>
  <body>

   <form id="contact-form" action="/" method="post">

    <input type="text" name="contact[name]" placeholder="Name" value="Michał" />
    <input type="text" name="contact[email]" placeholder="E-mail" value="abc@abc.com" />
    <input type="text" name="contact[captcha][input]" placeholder="Captcha" />
    <input type="hidden" name="contact[captcha][id]" value="abc123" />
    <input type="submit" name="send" value="Contact" class="send">

   </form>

   <script src="jquery-1.7.2.min.js"></script>

   <script>

     $(function() {

      /**
       * Breaks down given fieldName string to pieces. For ex:
       *
       * If fieldName is "contact[person][name]" result is ["person", "name"].
       * If keepFirstElement is true then result is ["contact", "person", "name"].
       * Result may vary if you pass different expression.
       *
       * @param {string} fieldName that will be splited by expression param.
       * @param {regexp} expression used to break down fieldName to pieces.
       * @param {boolean} keepFirstElement if false/null first extracted name part will be ommited.
       * @return {array} array of strings.
       */
       var extractFieldNames = function(fieldName, expression, keepFirstElement)
       {

           expression = expression || /([^\]\[]+)/g;
           keepFirstElement = keepFirstElement || false;

           var elements = [];

           while((searchResult = expression.exec(fieldName)))
           {
              elements.push(searchResult[0]);
           }

           if (!keepFirstElement && elements.length > 0) elements.shift();

             return elements;
       }

      /**
       * This function modifies target object by setting chain of nested fields.
       * Fields will have names as passed in properties array. Value param is assigned to
       * field at the end of chain. For ex:
       *
       * If properties array is ["person", "name"] and value is "abc", target object will be
       * modified in this way: target.person.name = "abc";
       *
       * If field at the end is already defined in target, function won't overwrite it.
       *
       * @param {object} object that this function will modify.
       * @param {array} properties to be createad in target object.
       * @param {*} value that will be assigned to the field at the end of chain.
       */
       var attachProperties = function(target, properties, value)
       {

         var currentTarget = target;
         var propertiesNum = properties.length;
         var lastIndex = propertiesNum - 1;

         for (var i = 0; i < propertiesNum; ++i)
         {
               currentProperty = properties[i];

               if (currentTarget[currentProperty] === undefined)
               {
                   currentTarget[currentProperty] = (i === lastIndex) ? value : {};
               }

               currentTarget = currentTarget[currentProperty];
            }
        }

       /**
        * This function converts form fields and values to object stucture that
        * then can be easely stringyfied with JSON.stringify() method.
        *
        * Form fields shoud be named in [square][brackets] convention.
        * Nesting of fields will be keeped.
        *
        * @param {object} jQuery object that represents form element.
        * @return {object} plain JS object with properties named after form fields.
        */
        var convertFormDataToObject = function(form)
        {

           var currentField = null;
           var currentProperties = null;

           // result of this function
           var data = {};

           // get array of fields that exist in this form
           var fields = form.serializeArray();

           for (var i = 0; i < fields.length; ++i)
           {
               currentField = fields[i];
               // extract field names
               currentProperties = extractFieldNames(currentField.name);
               // add new fields to our data object
               attachProperties(data, currentProperties, currentField.value);
           }

           return data;
        }

        // lets do this...
        var form = $('#contact-form');
        var data = convertFormDataToObject(form);

        console.log(data);

        // send data with ajax...

    });

  </script>

  </body>
</html>

Powyższy przykład umieściłem również tutaj (https://gist.github.com). Mam nadzieję, że komuś się przyda. Teraz dopiero możemy zabierać się za wysyłanie formularza AJAXem :).