- PVSM.RU - https://www.pvsm.ru -
Приветствую хабр, довелось мне недавно писать сервис опросов. В админке этого сервиса была форма с вопросами и вложенными в них примечаниями. И нужно было мне при сохранении вопроса, сохранять все открытые на редактирование вложенности, в чем мне безумно помог 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? моментов», поэтому распишу подробнее что я имел ввиду.
// внутри функции
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);
});
}
var deferreds = [];
$children_notes.each(function() {
deferreds.push(app.saveData.call(this));
});
$.when.apply(null, deferreds).always(function() {
saveData.call(self);
});
saveData: function() {
...
return $.Deferred(function() {
var def = this;
...
$.ajax({...}).complete(function() {
def.resolve();
});
}
}
return $.Deferred(function() {
var def = this;
$.ajax({...}).complete(function() {
def.resolve();
});
});
// Обе функции идентичны
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() {});
… но если бы я сделал по первому способу, аякс пришлось бы вынести в отдельную функцию, а мне не хотелось, так что это чисто дело эстетики.
beforeSend: function(xhr){
xhr.abort();
}
$.when.apply(null, deferreds).always(function() {
saveData.call(self);
});
var $inner_notes = $li.find('ul .save')/*.not('.ignore')*/; // предположим .not() нету
if($inner_notes.length) {}
Благодаря классу .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);
}
Теперь логика выполнения выглядт следующим образом:
p.s.1 Я во всех примерах рассматривал сохранение начиная с вопроса, но это только из-за того что вопрос находится в самом верху, так сказать самый трудный вариант. Конечно же, если в такой ситуации как изображена на последнем примере нажать сохранить на одном из примечаний, скажем 2м, все вложенные в него примечания также успешно сохраняться.
p.s.2 Во всех я примерах показал функцию saveData, которая вызывается при сохранении элемента. Также нужно добавить beforeSend функцию в ф-цию editData, которая вызывается при нажатии на редактирование элемента. Ведь если мы нажимаем редактировать вопрос, а внутри остались редактируемые примечания — их также нужно сохранить, но это вы уже можете посмотреть в демо.
Таким образом можно сохранять структуры любых вложенностей без потери редактируемых данных.
Для демонстрации пришлось применить немного php (для ответов с сервера), отчего продемонстрировать демо на jsFiddle не могу.
Я залил полностью работающий пример на Github [1], так что кому интересно можете скачать и поиграться у себя.
Также залил демо на один завалявшийся
Я добавил задержку в 1 секунду чтобы можно было увидеть как происходит замена вёрстки. Каждый раз когда редактируется либо сохраняется вопрос/примечание, при замене вёрстки мигает фон текущего блока. Так легче понять что происходит.
Вот и всё, буду рад ответить на любые вопросы в комментариях.
Автор: NeXTs_od
Источник [4]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/javascript/49758
Ссылки в тексте:
[1] Github: https://github.com/NeXTs/Wait-while-recursive-saving-nested-items
[2] хостинг: https://www.reg.ru/?rlink=reflink-717
[3] посмотреть демо: http://chooozy.com/habr/saving_nested/
[4] Источник: http://habrahabr.ru/post/204456/
Нажмите здесь для печати.