Почему 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
, из основного потока выполнения. Таким образом, если это функция, которая может занять продолжительное время, она не будет блокировать выполнение последующего кода.
Как узнать, какой элемент DOM имеет фокус?
Как определить, виден ли элемент DOM в текущей области просмотра?
Как выбрать элемент по имени с помощью jQuery?
Удаление всех дочерних элементов узла DOM в JavaScript
Создание нового DOM-элемента из HTML-строки с использованием встроенных методов DOM или Prototype