11

Почему setTimeout(fn, 0) иногда полезен?

9

У меня возникла неприятная проблема с кодом, который динамически загружает элемент <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 ответ(ов)

7

Предисловие:

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

В связи с этим я публикую подробное описание того, что делает браузер и как использование setTimeout() помогает. Оно выглядит длинным, но на самом деле очень простое и понятное - я просто сделал его очень детальным.

ОБНОВЛЕНИЕ: Я создал JSFiddle, чтобы продемонстрировать объяснение ниже в живом формате: http://jsfiddle.net/C2YBE/31/. Большое спасибо @ThangChung за помощь в его запуске.

ОБНОВЛЕНИЕ2: На всякий случай, если сайт JSFiddle перестанет работать или удалит код, я добавил код в этот ответ в самом конце.


ДЕТАЛИ:

Представьте себе веб-приложение с кнопкой "сделать что-то" и блоком для вывода результата.

Обработчик onClick для кнопки "сделать что-то" вызывает функцию "LongCalc()", которая выполняет 2 действия:

  1. Производит очень долгие вычисления (например, занимает 3 минуты).
  2. Выводит результаты вычислений в блок результата.

Теперь ваши пользователи начинают тестировать это, нажимают кнопку "сделать что-то", и страница, кажется, ничего не делает в течение 3 минут, они становятся нетерпеливыми, нажимают кнопку снова, ждут 1 минуту, ничего не происходит, снова нажимают кнопку...

Проблема очевидна - вам нужен блок "Статус", который покажет, что происходит. Давайте посмотрим, как это работает.


Итак, вы добавляете блок "Статус" (изначально пустой) и модифицируете обработчик onclick (функция LongCalc()) так, чтобы он выполнял 4 действия:

  1. Записывает статус "Вычисление... может занять ~3 минуты" в блок статуса.
  2. Выполняет очень долгие вычисления (например, занимает 3 минуты).
  3. Выводит результаты вычислений в блок результата.
  4. Записывает статус "Вычисление завершено" в блок статуса.

И с удовольствием отдаете приложение пользователям для повторного тестирования.

Они возвращаются к вам с очень сердитым видом и объясняют, что когда они нажали кнопку, блок Статус никогда не обновился с сообщением "Вычисление..."!!!


Вы почесываете затылок, спрашиваете на 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):

  1. Заполнить статус "Вычисление... может занять ~3 минуты" в блоке статуса.

  2. Выполнить 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);
});
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 миллисекунд.

0

В браузерах существует процесс, называемый "главный поток", который отвечает за выполнение некоторых задач JavaScript, обновление интерфейса пользователя, такие как рендеринг, перерисовка, перерасчет и другие операции. Задачи JavaScript помещаются в очередь сообщений и затем передаются в главный поток браузера для выполнения. Когда обновления интерфейса вызываются в то время, когда главный поток занят, задачи добавляются в очередь сообщений.

0

Одна из причин сделать это — отложить выполнение кода на отдельный, последующий цикл событий. Когда вы реагируете на какое-либо событие в браузере (например, на клик мыши), иногда необходимо выполнить операции только после обработки текущего события. Способ, который проще всего использовать для этого, — это setTimeout().

правка Теперь, когда на календаре 2015 год, стоит отметить, что также существует requestAnimationFrame(), который не совсем то же самое, но достаточно близок к setTimeout(fn, 0), чтобы его упомянуть.

новая правка В 2024 году стоит отметить, что с помощью Promise API можно делать такие вещи, как:

Promise.resolve().then(function() {
  // ваш код здесь
});

или, более изысканно:

queueMicrotask(function() {
  // ваш код здесь
});

Это не всегда подходит, но иногда может быть полезным. Лично я считаю, что проектировать на основе тонких различий в том, как работают таймеры, — довольно хрупкая задача. В некоторых странных ситуациях вам, возможно, придется это делать, но я бы посоветовал выбрать один подход, который вам нравится, и придерживаться его.

0

Поскольку передается значение 0 для времени ожидания, я предполагаю, что это сделано для того, чтобы исключить код, переданный в setTimeout, из основного потока выполнения. Таким образом, если это функция, которая может занять продолжительное время, она не будет блокировать выполнение последующего кода.

Чтобы ответить на вопрос, пожалуйста, войдите или зарегистрируйтесь