Рекурсивное сохранение вложенностей с помощью $.Deferred

image
Приветствую хабр, довелось мне недавно писать сервис опросов. В админке этого сервиса была форма с вопросами и вложенными в них примечаниями. И нужно было мне при сохранении вопроса, сохранять все открытые на редактирование вложенности, в чем мне безумно помог jQuery $.Deferred, об этом я и хочу рассказать вам в этой статье.

Допустим у нас есть такая структура вопросов и примечаний к ним, как указана на скриншоте справа, её мы и будем разбирать. Я не дизайнер, стилизировал как смог, чисто для этой статьи, так что извиняйте.

Пойдем по порядку.
Сначала объясню какие были условия.

Есть вопросы, внутри них могут быть примечания. При нажатии редактировать или сохранить — с сервера возвращается вёрстка вопроса/примечания и заменяется в шаблоне. Задача в том чтобы не потерять изменения примечаний при сохранении вопроса, если одновременно редактировались и вопрос и вложенное примечание.

Есть несколько вариантов решения этой проблемы, наверняка можно было отправлять всё разом на сервер а там уже разбирать, но это потребовало бы изменения структуры.
Мне понравился вариант с отложенным сохранением родительского вопроса, если имеются на сохранение некие дочерние элементы. Такое поведение бывает нужно в самых разных ситуациях, и даже в моей недолгой практике это потребовалось уже несколько раз.

Для нетерпеливых в самом конце статьи есть ссылки на демо.

Первый вопрос, никаких вложенностей.



Вёрстка выглядит так
<ul class="questions">
    <li>
        <div class="question" id="id1">
            <span class="text">Первый вопрос, без вложенностей</span>
            <span class="edit">edit</span>
        </div>
    </li>
</ul>


При нажатии «edit» — передаем на сервер id вопроса и получаем вёрстку в «режиме редактирования», заменяем и выглядеть это будет так:

вёрстка в режиме редактирования:
<ul class="questions">
    <li>
        <div class="question" id="id1">
            <input type="text" name="title" value="Первый вопрос, без вложенностей">
            <span class="save">save</span>
        </div>
    </li>
</ul>


Изменяем текст, нажимаем «save» — передаём id вопроса, измененный текст и получаем вёрстку в «обычном режиме» (на предпоследнем скриншоте).

В простейшем виде логика сохранения вопроса может выглядеть вот так:
$('.questions').on('click', '.save', function() {
    var $li = $(this).closest('li');
    saveData($li);
});

function saveData($li) {
    var $item = $li.children('.question'),
        id = $item.id,
        $input = $item.children('input');

    $.ajax({
        type: 'POST',
        url: '/save',
        dataType: 'json',
        data: $input.serialize(),
        success: function(response) {
            if ( ! response.errors) {
                $li.replaceWith(response.data);
            }
        }
    });
}


Здесь всё легко, еще раз хочу акцентировать внимание на том, что после сохранения сервер возвращает нам вёрстку всего вопроса.

Второй вопрос, есть вложенности первого уровня.



вёрстка:
<ul class="questions">
	<li>
	    <div class="question" id="id2">
	    	<span class="text">Вопрос второй с вложенностями</span>
	    	<span class="edit">edit</span>
	    </div>
	    <ul>
	        <li>
	            <div class="note" id="id1">
		            <span class="text">1е примечание</span>
		            <span class="edit">edit</span>
	            </div>
	        </li>
	        <li>
	            <div class="note" id="id2">
	            	<span class="text">2е примечание</span>
	            	<span class="edit">edit</span>
	            </div>
	        </li>
	    </ul>
	</li>
</ul>


Здесь уже интереснее, ведь при сохранении/редактировании вопроса — вёрстка вопроса заменяется, а значит заменяются и все вложенности, т.е. примечания. А что если во время сохранения вопроса также редактировались примечания?
Вот что я имею ввиду

Если пользователь не нажмет «save» на примечании, а сразу нажмет на «save» вопроса — правки в примечании не сохраняться, вёрстка просто заменится на ту что вернется с сервера. Получается при сохранении вопроса нам нужно смотреть нету ли открытых на редактирование примечаний, и если есть — сначала сохранять их, а затем уже сохранять вопрос. Тобишь нам нужно отслеживать момент когда сохранились все вложенные примечания, именно в этом месте нам и и поможет $.Deferred.

Сначала распишу как это работает в теории по примеру изображенному на картинке выше, затем пройдемся по коду.
При нажатии «save» вопроса — в $.ajax методе beforeSend смотрим нету ли открытых на редактирование вложенностей, если есть — прерываем сохранение вопроса, создаем $.Deferred объект и подписываемся на завершение сохранения всех вложенностей, по завершению — снова запускаем сохранение вопроса.

Посмотреть код:
$('.question').on('click', '.save', function() {
    saveData.call(this);
});

function saveData() {
	// this указывает на кнопку сохранения (может быть как вопрос, так и примечание)
    var self = this,
    	// соберем нужные элементы сохраняемого вопроса/примечания
        $button = $(this),
        $item = $button.closest('div'),
        $li = $item.closest('li'),
        // а также все данные необходимые для сохранения
        id = $item.attr('id').replace(/[^0-9.]/g, ""),
        inputs = $item.find(':input'),
        type = $item.attr('class');
    // создаём деферред обьект и возвращаем его (чтоб отследить выполнение)
    return $.Deferred(function() {
        var def = this;
        $.ajax({
            type: 'POST',
            url: '/save',
            dataType: 'json',
            data: inputs.serialize() + '&id=' + id + '&type=' + type,
            beforeSend: function(xhr){
            	// ищем открытые на редактирование вложенные примечания, кроме тех у которых есть класс .ignore
            	// .ignore мы будем навешивать на те примечания, которые по какимто причинам не удалось сохранить
                // (а если не удалось сохранить значит вёрстка не заменится мы попадём в бесконечный цикл
                // когда вернемся сохранять сам вопрос)
                var $inner_notes = $li.find('ul .save').not('.ignore');
                // если редактируемые влоеженности есть..
                if($inner_notes.length) {
                    // создаем массив для примечаний что нужно будет сохранить
                    var deferreds = [];
                    $inner_notes.each(function() {
                    	// добавляем в массив примечания, передавая на выполнение эту же функцию но с
                        // контекстом this кнопки .save этого примечания
                        deferreds.push(saveData.call(this));
                    });
                    // Подписываемся на завершение сохранения всех примечаний
                    $.when.apply(null, deferreds).always(function() {
                        // как только закончили с примечаниями - наконецто вызываем сохранение вопроса.
                        // self хранилась в замыкании и всё еще указывает на .save самого вопроса
                        saveData.call(self);
                    });
                    // прерываем сохранение вопроса
                    xhr.abort();
                }
            },
            success: function(response){
                if ( ! response.errors) {
                    // заменяем вёрстку всего вопроса, включая вложенности
                    $li.replaceWith(response.data);
                } else {
                    // если сохранение не удалось, игнорируем этот элемент в последнем проходе
                    $button.addClass('ignore');
                }
            },
            error: function() {
            	// если сохранение не удалось, игнорируем этот элемент в последнем проходе
                $button.addClass('ignore');
            }
        }).complete(function() {
            // вне зависимости от успешности аякс ответа - отмечаем деферред как resolve()
            def.resolve();
        });
    });
}


Наверняка здесь есть много «WTF? моментов», поэтому распишу подробнее что я имел ввиду.

  1. Чему равен this внутри saveData?
    Ответ:
    this всегда равен элементу save сохраняемого вопроса/примечания.
    Просто мне так было удобнее ориентироваться в элементах.
    // внутри функции
    saveData: function() {
        var self = this,
            $button = $(this),
            $item = $button.closest('div'),
            $li = $item.closest('li');
    }
    
    // при вызове функции указываем контекст
    $('.question').on('click', '.save', function() {
        saveData.call(this);
    });
    // также здесь
    var $inner_notes = $li.find('ul .save').not('.ignore');
    $inner_notes .each(function() {
        deferreds.push(saveData.call(this));
    });
    // и здесь
    saveData: function() {
        var self = this;
        ....
        $.when.apply(null, deferreds).always(function() {
            saveData.call(self);
        });
    }
    


  2. Как мы отслеживаем завершение сохранения всех вложенностей?
    Ответ:
    С помощью деферреда.
    var deferreds = [];
    $children_notes.each(function() {
        deferreds.push(app.saveData.call(this));
    });
    $.when.apply(null, deferreds).always(function() {
        saveData.call(self);
    });
    

    • Создаём массив, в него добавляем вызов функции сохранения (эту же функцию) но в контексте всех вложенных примечаний открытых на редактирование.
    • $.when( func1, func2 ).done( func3 )
      Синтаксис говорит сам за себя, при выполнении func1 и func2 — запустить func3.
      У нас массив функций, поэтому передаём их с помощью .apply().
    • Здесь deferred начинает выполнять все функции что мы ему передали и ждет пока они выполняться.
      Каждая вложенная функция известит о своем завершении с помощью .resolve()
      saveData: function() {
          ...
          return $.Deferred(function() {
              var def = this;
              ...
              $.ajax({...}).complete(function() {
                  def.resolve();
              });
          }
      }
      

    • Как только все из них выполняться, запустится func3, тобишь в нашем случае .always(function() { saveData.call(self); }), где self указывает на .save элемент вопроса.


  3. Зачем делать return $.Deferred(function() { }); ?
    Ответ:
    Деферред работает таким образом, что если одновременно подписаться на несколько аякс запросов и один из них выполниться неудачно (вернёт error) — дальнейшая цепочка вызовов функций в деферред оборвется и все оставшиеся функции/запросы выполнены не будут. В аякс встроен свой деферред и поменять его поведение мы не можем, но, мы можем обернуть аякс запрос в некую обёртку и по завершении несмотря на успешность запроса — принудительно извещать об успешном выполнении.
    return $.Deferred(function() {
        var def = this;
        $.ajax({...}).complete(function() {
            def.resolve();
        });
    });
    


  4. Почему возвращаем именно $.Deferred(function() { }); ?
    Ответ:
    $.Deferred можно создавать двумя способами…
    // Обе функции идентичны
    function someFunc(){
    	// создаем
        var def = $.Deferred();
        setTimeout(function(){
        	// извещаем о выполнении
        	def.resolve();
    	}, 1000);
    	// подписываемся
        return def.promise();
    }
    function someFunc(){
    	// создаем и подписываемся
        return $.Deferred(function() {
        	var def = this;	
        	setTimeout(function(){
        		// извещаем о выполнении
    	    	def.resolve();
    		}, 1000);
        })/* .promise() */; // по сути происходит это, но мы можем .promise() опустить, deferred это сделает за нас.
    }
    //someFunc.done(function() {});
    

    … но если бы я сделал по первому способу, аякс пришлось бы вынести в отдельную функцию, а мне не хотелось, так что это чисто дело эстетики.

  5. Зачем класс .ignore?
    Ответ:
    Короткий ответ: чтоб не попасть в вечный цикл.
    Расширенный ответ: легче будет объяснить пройдясь построчно, как это делает код.
    Предположим у нас открыта на редактирование вопрос и 2 внутренних примечания, нажимаем сохранить вопрос.
    1. После нажатия .save по вопросу — ищем .save для всех вложенных примечаний. Нашли 2, остановили сохранение вопроса.
      beforeSend: function(xhr){
          xhr.abort();
      }
      

    2. Успешно сохранили первое примечание, его вёрстка заменилась.
    3. При сохранении второго примечания произошла какая-то ошибка, вёрстка не заменилась, кнопка .save осталась.
    4. Поскольку в массиве на выполнение примечаний больше не осталось, вызывается сохранение вопроса
      $.when.apply(null, deferreds).always(function() {
          saveData.call(self);
      });
      

    5. Снова попадаем в beforeSend и проверяем на наличие не сохраненных вложенностей
      var $inner_notes = $li.find('ul .save')/*.not('.ignore')*/; // предположим .not() нету
      if($inner_notes.length) {}
      

    6. Поскольку одно из примечаний не сохранилось — мы его находим, и тут происходит одно из двух.
      • Либо во второй раз по каким-то причинам примечание сохранится удачно, после чего удачно сохранится вопрос.
      • Либо снова неудача и мы попадаем в бесконечный цикл.

      Благодаря классу .ignore мы можем обезопасить себя от таких случаев.
      Не удалось сохранить примечание? Что-ж, се ля ви, нам то главное вопрос сохранить.




Третий вопрос, многоуровневая вложенность.



Вёрстка:
<ul class="questions">
    <li>
        <div class="question" id="id3">
            <span class="text">Вопрос c многоуровневыми вложенностями</span>
            <span class="edit">edit</span>
        </div>
        <ul>
            <li>
                <div class="note" id="id1">
                    <span class="text">1е примечание</span>
                    <span class="edit">edit</span>
                </div>
            </li>
            <li>
                <div class="note" id="id2">
                    <span class="text">2е примечание</span>
                    <span class="edit">edit</span>
                </div>
                <ul>
                    <li>
                        <div class="note" id="id4">
                            <span class="text">4е примечание</span>
                            <span class="edit">edit</span>
                        </div>
                        <ul>
                            <li>
                                <div class="note" id="id7">
                                    <span class="text">7е примечание</span>
                                    <span class="edit">edit</span>
                                </div>
                            </li>
                            <li>
                                <div class="note" id="id8">
                                    <span class="text">8е примечание</span>
                                    <span class="edit">edit</span>
                                </div>
                            </li>
                        </ul>
                    </li>
                    <li>
                        <div class="note" id="id5">
                            <span class="text">5е примечание</span>
                            <span class="edit">edit</span>
                        </div>
                        <ul>
                            <li>
                                <div class="note" id="id6">
                                    <span class="text">6е примечание</span>
                                    <span class="edit">edit</span>
                                </div>
                            </li>
                        </ul>
                    </li>
                </ul>
            </li>
            <li>
                <div class="note" id="id3">
                    <span class="text">3е примечание</span>
                    <span class="edit">edit</span>
                </div>
            </li>
        </ul>
    </li>
</ul>


Казалось бы здесь код станет совсем страшным, но на самом деле прежняя реализация уже практически подходит для этого. Нужно добавить всего лишь одну функцию.
Дело в том, что..
var $inner_notes = $li.find('ul .save').not('.ignore')

… соберёт примечания на всех уровнях вложенности, и если родительское примечание сохраниться раньше дочернего, дочернее не успеет сохраниться. Проблема повторяется. Все что нам нужно сделать — заставить каждое родительское примечание вести себя как вопрос по отношению к дочерним элементам.
Т.е. если мы сохраняем примечание, и в beforeSend обнаруживается что внутри него есть еще не сохраненные примечание — приостанавливаем сохранение родительского примечания и ждем пока выполняться все дочерние.
При большом количестве вложенностей получается такая себе глубокая рекурсия.

Скажем, наш пользователь совсем обезумел и решил сохранить вопрос когда у него открыто на редактирование такая ветка.

Мы не можем сразу сохранить «2е примечание», т.к. тогда потеряются правки всех остальных примечаний.
Значит мы должны идти снизу вверх. Как вы считаете какая будет правильная последовательность сохранения примечаний, чтобы ничего не упустить? 8,6, затем 4, затем 2 и вопрос.
Но нажимаем то мы сохранить по вопросу, а значит нам нужна функция которая будет находить ближайшие дочерние элементы, такой себе .closest() метод, но наоборот.

Реализация и применение функции
// от предыдущего примера изменился только beforeSend метод, добавился вызов ф-ции getClosestChildrens
saveData: function() {
    ....
    beforeSend: function(xhr){
        var $inner_notes = $li.find('ul .save').not('.ignore'),
            // фильтруем вложенности и оставляем только ближайшие дочерние редактируемые элементы
            $children_notes = getClosestChildrens($inner_notes);

        if($children_notes.length) {
            var deferreds = [];
            $children_notes.each(function() {
                deferreds.push(app.saveData.call(this));
            });
            // запускаем сохранение вложенностей и подписываемся на завершение их сохранения
            $.when.apply(null, deferreds).always(function() {
                // вызываем запрос на сохранение родительского элемента
                // теперь это может быть как вопрос, так и примечание.
                // self берётся из замыкания и всегда указывает на родителя.
                app.saveData.call(self);
            });
            // прерываем сохранение вопроса/примечания
            xhr.abort();
        }
    },
    ....
}

// функция нахождения ближайших редактируемых примечаний (ближайшие на любом уровне вложенностей).
// так и не придумал как это сделать оптимальнее поэтому просто перебираю 
// все дочерние элементы и нахожу те из них, у которых нету родителей.
// т.е. нахожу ближайшие "примечания родители", либо ближайшие примечания без вложенностей.
function getClosestChildrens($inner_notes) {
    var children_notes = $.grep($inner_notes, function(value, key) {
        var is_child_of = false,
            $btn = $(value),
            $parent_li = $btn.closest('li');
        $inner_notes.not($btn).each(function(key,v) {
            if($(this).closest($parent_li).length) {
                is_child_of = true;
            }
        });
        return is_child_of ? false : true;
    });
    return $(children_notes);
}


Теперь логика выполнения выглядт следующим образом:
  1. Нажимаем «save» вопроса
  2. В beforeSend находим все примечания, откидываем все кроме ближайших дочерних. Остается 2е примечание. Останавливаем сохранение вопроса, подписываемся на завершение сохранения 2го примечания.
  3. Начинаем сохранение 2го примечания, видим что есть вложенности, находим их (4, 8, 6), откидываем все кроме ближайших. Остаются 4 и 6. Останавливаем сохранение 2го примечания, подписываемся на завершение сохранения 4го и 6го.
  4. Начинаем сохранение 6го примечания, вложенностей нету, сохраняем его.
  5. Начинаем сохранение 4го примечание, находим вложенности (8е). Останавливаем сохранение 4го примечания, подписываемся на завершение сохранения 8го.
  6. Начинаем сохранение 8го примечания, вложенностей нету, сохраняем его.
  7. Начинаем сохранение родительского примечания для 8го, т.е. 4е примечание. Редактируемых вложенностей не осталось, сохраняем.
  8. Начинаем сохранение родительского примечания для 8го и 6го, т.е. 2е примечание. Редактируемых вложенностей не осталось, сохраняем.
  9. Начинаем сохранение вопроса. Редактируемых вложенностей не осталось, сохраняем.


p.s.1 Я во всех примерах рассматривал сохранение начиная с вопроса, но это только из-за того что вопрос находится в самом верху, так сказать самый трудный вариант. Конечно же, если в такой ситуации как изображена на последнем примере нажать сохранить на одном из примечаний, скажем 2м, все вложенные в него примечания также успешно сохраняться.

p.s.2 Во всех я примерах показал функцию saveData, которая вызывается при сохранении элемента. Также нужно добавить beforeSend функцию в ф-цию editData, которая вызывается при нажатии на редактирование элемента. Ведь если мы нажимаем редактировать вопрос, а внутри остались редактируемые примечания — их также нужно сохранить, но это вы уже можете посмотреть в демо.

Таким образом можно сохранять структуры любых вложенностей без потери редактируемых данных.

Для демонстрации пришлось применить немного php (для ответов с сервера), отчего продемонстрировать демо на jsFiddle не могу.
Я залил полностью работающий пример на Github, так что кому интересно можете скачать и поиграться у себя.
Также залил демо на один завалявшийся хостинг, посмотреть демо.

Я добавил задержку в 1 секунду чтобы можно было увидеть как происходит замена вёрстки. Каждый раз когда редактируется либо сохраняется вопрос/примечание, при замене вёрстки мигает фон текущего блока. Так легче понять что происходит.

Вот и всё, буду рад ответить на любые вопросы в комментариях.
Поделиться публикацией

Похожие публикации

Комментарии 9
    0
    Почему сервер возвращает разметку, а не данные?
      +1
      Такие были условия.
      Но здесь это просто как пример, я же хотел показать саму технику, мало ли где это может ещё пригодится.
      0
      Почему сервер возвращает разметку все и вся (в смысле и внутренние данные вопроса тоже), ведь был сохранен только вопрос?
      Почему по save нельзя сохранить и получить только данные вопроса?

      Даже если получить нельзя, можно уже в полученной разметке вопроса от сервера на клиенте поменять только вопрос, если он и был изменен «выкусив» его из всего пришедшего.

      Ну и как бы получать с сервака вместо данных разметку вместо данных не очень хорошо ИМХО
        0
        Потому так что так работал сервер, я на это повлиять не мог. И клиент хотел чтоб при сохранении вопроса — сохранялось и всё что находится внутри, чтоб не пришлось по всем примечаниям пробегаться и сохранять.
        0
          0
          Делал нечто подобное для модерации комментариев с вложенностью.
          Реализовал все через json, который уходил на сервер и сохранялся.
          Когда модератор открывал ветку комментариев, создавался массив, хранивший в себе все необходимые параметры, в том числе, кто кому родитель и т.д. При изменении комментария, массив преобразовывался и отправлялся на сервер по клику на «сохранить», либо по клику на редактирование другого комментария.
          Не понимаю, почему нужно каждый элемент сохранять отдельно? Вы их и храните наверно каждый отдельно?
            0
            Хорошая идея, но у меня не было возможности менять сервер. Сначала были просто вопросы, потом со временем добавились примечания, конечно все они сохранялись и хранились по одному. А в один день клиенту захотелось чтоб сохранялось всё разом, вот и пришлось придумывать.
            0
            В данном примере проще было не трогать дочерние элементы, да и вообще учитывая, что сервер отдает json и используются шаблоны…

            Кроме того это нарушает логику, кнопка сохранения рядом с элементом, а сохраняются и дочерние, у которых есть своя кнопка.

            Но использование deferred действительно интересное.
              0
              Сохранять всё что находится внутри — внезапная хотелка заказчика.

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

            Самое читаемое