Почему setTimeout(fn, 0) иногда полезен?
У меня возникла неприятная проблема с кодом, который динамически загружает элемент <select>
с помощью JavaScript. Этот динамически загруженный <select>
имел предустановленное значение. В Internet Explorer 6 у нас уже был код, который исправлял выбранный <option>
, потому что иногда значение selectedIndex
у <select>
не совпадало с атрибутом index
у выбранного <option>
. Вот как это выглядело:
field.selectedIndex = element.index;
Тем не менее, этот код не работал. Хотя selectedIndex
поля устанавливался правильно, в конечном итоге выбирался неправильный индекс. Однако, если я вставлял оператор alert()
в нужное время, правильный вариант выбирался. Подозревая, что это может быть связано с какой-то проблемой тайминга, я попробовал некую случайную конструкцию, которую видел раньше:
var wrapFn = (function() {
var myField = field;
var myElement = element;
return function() {
myField.selectedIndex = myElement.index;
}
})();
setTimeout(wrapFn, 0);
И это сработало!
У меня есть решение моей проблемы, но меня беспокоит, что я не совсем понимаю, почему это решает проблему. Есть ли у кого-то официальное объяснение? Какую проблему с браузером я избегаю, вызывая свою функцию "позже" с помощью setTimeout()
?
5 ответ(ов)
Предисловие:
Некоторые из других ответов правильные, но не иллюстрируют, в чем заключается решаемая проблема, поэтому я создал этот ответ, чтобы представить подробное объяснение.
В связи с этим я публикую подробное описание того, что делает браузер и как использование setTimeout()
помогает. Оно выглядит длинным, но на самом деле очень простое и понятное - я просто сделал его очень детальным.
ОБНОВЛЕНИЕ: Я создал JSFiddle, чтобы продемонстрировать объяснение ниже в живом формате: http://jsfiddle.net/C2YBE/31/. Большое спасибо @ThangChung за помощь в его запуске.
ОБНОВЛЕНИЕ2: На всякий случай, если сайт JSFiddle перестанет работать или удалит код, я добавил код в этот ответ в самом конце.
ДЕТАЛИ:
Представьте себе веб-приложение с кнопкой "сделать что-то" и блоком для вывода результата.
Обработчик onClick
для кнопки "сделать что-то" вызывает функцию "LongCalc()", которая выполняет 2 действия:
- Производит очень долгие вычисления (например, занимает 3 минуты).
- Выводит результаты вычислений в блок результата.
Теперь ваши пользователи начинают тестировать это, нажимают кнопку "сделать что-то", и страница, кажется, ничего не делает в течение 3 минут, они становятся нетерпеливыми, нажимают кнопку снова, ждут 1 минуту, ничего не происходит, снова нажимают кнопку...
Проблема очевидна - вам нужен блок "Статус", который покажет, что происходит. Давайте посмотрим, как это работает.
Итак, вы добавляете блок "Статус" (изначально пустой) и модифицируете обработчик onclick
(функция LongCalc()
) так, чтобы он выполнял 4 действия:
- Записывает статус "Вычисление... может занять ~3 минуты" в блок статуса.
- Выполняет очень долгие вычисления (например, занимает 3 минуты).
- Выводит результаты вычислений в блок результата.
- Записывает статус "Вычисление завершено" в блок статуса.
И с удовольствием отдаете приложение пользователям для повторного тестирования.
Они возвращаются к вам с очень сердитым видом и объясняют, что когда они нажали кнопку, блок Статус никогда не обновился с сообщением "Вычисление..."!!!
Вы почесываете затылок, спрашиваете на StackOverflow (или читаете документацию или гуглите) и осознаете проблему:
Браузер ставит все свои задачи "TODO" (как задачи UI, так и команды JavaScript), возникающие в результате событий, в один единственный список. К сожалению, перерисовка блока "Статус" с новым значением "Вычисление..." является отдельной задачей, которая идет в конец списка!
Вот подробный разбор событий во время теста вашего пользователя и состояние очереди после каждого события:
- Очередь:
[Пусто]
- Событие: Нажатие кнопки. Очередь после события:
[Выполнить обработчик OnClick (строки 1-4)]
- Событие: Выполнить первую строку в обработчике OnClick (например, изменить значение блока Статус). Очередь после события:
[Выполнить обработчик OnClick (строки 2-4), перерисовать блок Статус с новым значением "Вычисление..."]
. Обратите внимание, что хотя изменения в DOM происходят мгновенно, для перерисовки соответствующего элемента DOM вам нужно новое событие, вызванное изменением DOM, которое попадает в конец очереди. - ПРОБЛЕМА!!! ПРОБЛЕМА!!! Подробности объясняются ниже.
- Событие: Выполняется вторая строка в обработчике (вычисления). Очередь после:
[Выполнить обработчик OnClick (строки 3-4), перерисовать блок Статус с "Вычислением..." ]
. - Событие: Выполнить третью строку в обработчике (вывод блока результата). Очередь после:
[Выполнить обработчик OnClick (строка 4), перерисовать блок Статус с "Вычислением...", перерисовать блок результата с результатом ]
. - Событие: Выполнить четвертую строку в обработчике (записать статус "Готово" в блок статуса). Очередь:
[Выполнить обработчик OnClick, перерисовать блок Статус с "Вычислением...", перерисовать блок результата с результатом; перерисовать блок Статус с "Готово" ]
. - Событие: Выполнение подразумеваемого
return
из подпрограммы обработчикаonclick
. Мы убираем "Выполнить обработчик OnClick" из очереди и начинаем выполнять следующий элемент из очереди. - ПРИМЕЧАНИЕ: Поскольку мы уже завершили вычисления, 3 минуты уже прошли для пользователя. Событие перерисовки ещё не произошло!!!
- Событие: перерисовка блока Статус с "Вычислением...". Мы выполняем перерисовку и убираем это из очереди.
- Событие: перерисовка блока результата с результатом. Мы выполняем перерисовку и убираем это из очереди.
- Событие: перерисовка блока Статус с "Готово". Мы выполняем перерисовку и убираем это из очереди.
Внимательные зрители могут даже заметить, что блок Статус с "Вычислением..." мигает на долю микросекунды - ПОСЛЕ ЗАВЕРШЕНИЯ ВЫЧИСЛЕНИЙ.
Таким образом, основная проблема заключается в том, что событие перерисовки для блока "Статус" помещается в очередь в конец, ПОСЛЕ события "выполнить строку 2", которое занимает 3 минуты, так что фактическая перерисовка не происходит до ЗАВЕРШЕНИЯ вычислений.
На помощь приходит setTimeout()
. Как он помогает? Потому что, вызывая выполнение долгих вычислений через setTimeout
, вы фактически создаете 2 события: выполнение setTimeout
само по себе и (из-за времени ожидания 0) отдельная запись в очереди для выполняемого кода.
Таким образом, чтобы исправить вашу проблему, вам нужно модифицировать обработчик onClick
, чтобы он состоял из ДВУХ операторов (в новой функции или просто блоком в onClick
):
Заполнить статус "Вычисление... может занять ~3 минуты" в блоке статуса.
Выполнить
setTimeout()
с временем ожидания 0 и вызовом функцииLongCalc()
.Функция
LongCalc()
почти такая же, как в прошлый раз, но, очевидно, не содержит обновления статуса блока "Вычисление..." в качестве первого шага и вместо этого сразу начинает выполнение вычислений.
Так что же происходит с последовательностью событий и очередью сейчас?
- Очередь:
[Пусто]
- Событие: Нажатие кнопки. Очередь после события:
[Выполнить обработчик OnClick (обновление статуса, вызов setTimeout)]
- Событие: Выполнить первую строку в обработчике OnClick (например, изменить значение блока Статус). Очередь после события:
[Выполнить обработчик OnClick (который является вызовом setTimeout), перерисовать блок Статус с новым значением "Вычисление..."]
. - Событие: Выполнение второй строки в обработчике (вызов setTimeout). Очередь после:
[перерисовать блок Статус с "Вычислением..."]
. В очереди ничего нового нет в течение еще 0 секунд. - Событие: Сигнал от таймера, истекает 0 секунд. Очередь после:
[перерисовать блок Статус с "Вычислением...", выполнить LongCalc (строки 1-3)]
. - Событие: перерисовка блока Статус с "Вычислением...". Очередь после:
[выполнение LongCalc (строки 1-3)]
. Обратите внимание, что это событие перерисовки может действительно произойти ДО сигнала, что тоже работает. - ...
Ура! Блок Статуса только что был обновлён на "Вычисление..." до начала вычисления!!!
Ниже приведен образец кода из JSFiddle, иллюстрирующий эти примеры: http://jsfiddle.net/C2YBE/31/ :
HTML код:
<table border=1>
<tr><td><button id='do'>Сделать долгие вычисления - плохой статус!</button></td>
<td><div id='status'>Еще не вычисляем.</div></td>
</tr>
<tr><td><button id='do_ok'>Сделать долгие вычисления - хороший статус!</button></td>
<td><div id='status_ok'>Еще не вычисляем.</div></td>
</tr>
</table>
JavaScript код: (исполняется при onDomReady
и может требовать jQuery 1.9)
function long_running(status_div) {
var result = 0;
// Используйте границы 1000/700/300 в Chrome,
// 300/100/100 в IE8,
// 1000/500/200 в FireFox
// Я не имею понятия, почему одинаковое время выполнения не проходит на разных браузерах.
for (var i = 0; i < 1000; i++) {
for (var j = 0; j < 700; j++) {
for (var k = 0; k < 300; k++) {
result = result + i + j + k;
}
}
}
$(status_div).text('вычисление завершено');
}
// Назначаем события кнопкам
$('#do').on('click', function () {
$('#status').text('вычисляем....');
long_running('#status');
});
$('#do_ok').on('click', function () {
$('#status_ok').text('вычисляем....');
// Это работает в IE8. Работает в Chrome
// НЕ работает в FireFox 25 с таймаутом =0 или =1
// РАБОТАЕТ в FF, если изменить таймаут с 0 на 500
window.setTimeout(function (){ long_running('#status_ok') }, 0);
});
В данном случае в ответах имеются противоречия, и без доказательств невозможно определить, кому верить. Вот доказательство того, что @DVK прав, а @SalvadorDali не прав. Последний утверждает:
"И вот почему: невозможно установить setTimeout с задержкой времени равной 0 миллисекундам. Минимальное значение определяется браузером, и оно не равно 0 миллисекундам. Исторически браузеры устанавливали этот минимум на уровне 10 миллисекунд, но по спецификациям HTML5 и в современных браузерах он установлен на уровне 4 миллисекунд."
Минимальное время в 4 мс не имеет отношения к происходящему. На самом деле, setTimeout просто помещает функцию обратного вызова в конец очереди выполнения. Если после вызова setTimeout(callback, 0) идёт блокирующий код, который выполняется в течение нескольких секунд, функция обратного вызова не будет выполнена в течение нескольких секунд, пока не завершится блокирующий код. Попробуйте следующий код:
function testSettimeout0 () {
var startTime = new Date().getTime();
console.log('установка таймера 0 при ' + sinceStart());
setTimeout(function(){
console.log('в колбэке таймера при ' + sinceStart());
}, 0);
console.log('начало блокирующего цикла при ' + sinceStart());
while (sinceStart() < 3000) {
continue;
}
console.log('блокирующий цикл завершён при ' + sinceStart());
return; // функции ниже
function sinceStart () {
return new Date().getTime() - startTime;
} // sinceStart
} // testSettimeout0
Вывод будет таким:
установка таймера 0 при 0
начало блокирующего цикла при 5
блокирующий цикл завершён при 3000
в колбэке таймера при 3033
Как видно из результатов, колбэк функции выполнится только после завершения блокирующего кода, независимо от того, что задержка в setTimeout установлена на 0 миллисекунд.
В браузерах существует процесс, называемый "главный поток", который отвечает за выполнение некоторых задач JavaScript, обновление интерфейса пользователя, такие как рендеринг, перерисовка, перерасчет и другие операции. Задачи JavaScript помещаются в очередь сообщений и затем передаются в главный поток браузера для выполнения. Когда обновления интерфейса вызываются в то время, когда главный поток занят, задачи добавляются в очередь сообщений.
Одна из причин сделать это — отложить выполнение кода на отдельный, последующий цикл событий. Когда вы реагируете на какое-либо событие в браузере (например, на клик мыши), иногда необходимо выполнить операции только после обработки текущего события. Способ, который проще всего использовать для этого, — это setTimeout()
.
правка Теперь, когда на календаре 2015 год, стоит отметить, что также существует requestAnimationFrame()
, который не совсем то же самое, но достаточно близок к setTimeout(fn, 0)
, чтобы его упомянуть.
новая правка В 2024 году стоит отметить, что с помощью Promise API можно делать такие вещи, как:
Promise.resolve().then(function() {
// ваш код здесь
});
или, более изысканно:
queueMicrotask(function() {
// ваш код здесь
});
Это не всегда подходит, но иногда может быть полезным. Лично я считаю, что проектировать на основе тонких различий в том, как работают таймеры, — довольно хрупкая задача. В некоторых странных ситуациях вам, возможно, придется это делать, но я бы посоветовал выбрать один подход, который вам нравится, и придерживаться его.
Поскольку передается значение 0
для времени ожидания, я предполагаю, что это сделано для того, чтобы исключить код, переданный в setTimeout
, из основного потока выполнения. Таким образом, если это функция, которая может занять продолжительное время, она не будет блокировать выполнение последующего кода.
Как проверить, скрыт ли элемент в jQuery?
Получить координаты (X,Y) HTML-элемента
Как изменить класс элемента с помощью JavaScript?
Удаление всех дочерних элементов узла DOM в JavaScript
Создание нового DOM-элемента из HTML-строки с использованием встроенных методов DOM или Prototype