Введение

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

Мы начнем с простого примера, здесь у нас есть три инструкции console.log().

Пример: 1

console.log(1)
console.log(2)
console.log(3)

//output
// 1
// 2
// 3

Как работает javascript,

  1. Он переходит к первой строке кода, помещает console.log(1)в стек вызовов и выполняет инструкцию, котораязаносит в журнал 1и удаляет инструкцию из стека вызовов.
  2. Затем он переходит ко второй строке кода, помещает console.log(2) в стек вызовов, записывает 2 и удаляет инструкцию из стека вызовов.
  3. Наконец, он переходит к третьей строке кода и помещает console.log(3) в стек вызовов, выполняет инструкцию, регистрируя 3, и удаляет ее из стека вызовов.

Вот еще один пример с функциями, которые делают то же самое.

Пример: 2

function one(){
    console.log(1)
}

function two(){
    console.log(2)
}

function three(){
    console.log(3)
}

one()
two()
three()

//output
// 1
// 2
// 3

Здесь у нас есть три определения функций в начале, а затем первый вызов функции выполняется с помощью one(), javascript помещает эту инструкцию в стек вызовов и выполняет функцию, которая регистрирует 1, а затем удаляет ее из стек вызовов. Затем two() попадает в стек вызовов, выполняется и удаляется из стека вызовов, и то же самое для three().

Итак, из этих двух примеров мы видим, что javascript выполняет одну инструкцию/команду за раз сверху вниз. Вот почему javascript синхронен по своей природе, выполняя коды построчно.

Проблемы с синхронным выполнением

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

Пример: 3

function one(){
    console.log(1)
}

function two(){
    for(let i=0; i<= 10000000000; i++){
    }
    console.log(2)
}

function three(){
    console.log(3)
}

one()
two()
three()

//output
// 1
// 2
// 3

Этот пример такой же, как и второй, но здесь мы только что добавили цикл for в функцию two(), где он выполняет итерацию от нуля до десяти миллиардов, и console.log(2 ). Когда мы запускаем этот фрагмент кода, механизм синхронного выполнения инструкций с помощью стека вызовов остается прежним.

Но когда two() входит в стек вызовов, он должен выполнить итерацию в десять миллиардов, что занимает много времени. Это занимает много времени, а затем регистрирует 2 и удаляет two() из стека вызовов, и только после этого three() может перейти в стек вызовов и закончить его выполнение.

Мы можем изменить function two(), чтобы отслеживать время, необходимое для завершения итерации, с помощью метода console.time().

function one(){
    console.log(1)
}

function two(){
    console.time('Execution Time')
    for(let i=0; i<= 10000000000; i++){
    }
    console.timeEnd('Execution Time')

    console.log(2)
}

function three(){
    console.log(3)
}

one()
two()
three()

//output
// 1
// Execution Time: 15600.346ms
// 2
// 3

Здесь мы добавили цикл for между console.time('Время выполнения') и console.timeEnd('Время выполнения'), чтобы получить точное время, необходимое для завершения итерации. И из вывода мы видим, что для завершения требуется примерно 15,6 секунды, что очень долго, и пока эта итерация продолжалась, стек вызовов был заблокирован на все время. Никакие другие инструкции не могли быть выполнены из-за этого.

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

Как выполнять трудоемкие задачи асинхронно

Поскольку мы знаем, что javascript выполняет код построчно, мы ожидали, что на выходе будет 1, затем 2, а затем 3, так как two() вызывается перед three(), но это здесь не так, как мы видим из примера 4. Итак, давайте выясним, почему?

Пример: 4

function one(){
    console.log(1)
}

function two(){
    setTimeout( function timer() => {
        console.log(2)
    }, 0000)
}

function three(){
    console.log(3)
}

one()
two()
three()

//output
// 1
// 3
// 2

Итак, в этом примере мы изменили функцию two(),где мы добавилиsetTimeout(callBackFunction, delay). Этоасинхронная функция, которая принимает функцию обратного вызова в качестве первого параметра и временную задержку в качестве второго, по истечении времени она будет выполнена функция обратного звонка. Здесь наша callBackFunction — это timer() и задержка равна 0000 (1000 мс = 1 с).

Теперь давайте разберемся с выполнением примера 4:

  1. one() вызывается и помещается в стек вызовов, который затем регистрирует 1, завершает свое выполнение и удаляется из стека вызовов.
  2. two() вызывается и помещается в стек вызовов, затем вызывается setTimeout() и помещается в стек вызовов, после чего немедленно создается новый вызов веб-API setTimeout с помощью функции обратного вызова timer() и задержка0000 мс (0 с). По истечении времени задержки веб-API перемещает функцию timer() в очередь обратного вызова. (чтобы функция timer() могла быть помещена в стек вызовов, когда он пуст, и завершила свое выполнение)
  3. three() вызывается и помещается в стек вызовов, который затем регистрирует 3, завершает свое выполнение и затем удаляется из стека вызовов.
  4. Теперь, когда стек вызовов пуст, цикл обработки событий возьмет функцию timer() из очереди обратного вызова и поместит ее в стек вызовов. Затем функция timer() будет выполняться с записью в журнал 2 и timer() будет извлечена из стека вызовов.

Вот почему выход 1 -> 3 -> 2.

Вот визуализация стека вызовов для лучшего понимания выполнения кода.

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

Пример 5:

function one(){
    console.log(1)
}
function two(){
    setTimeout( function timer9000() {
        console.log(2)
    }, 9000)
}
function three(){
    setTimeout( function timer1000() {
        console.log(3)
    }, 1000)
}
function four(){
    setTimeout( function timer2000() {
        console.log(4)
    }, 2000)
}
function five(){
    console.log(5)
}

one()
two()
three()
four()
five()

//output
// 1
// 5
// 3
// 4
// 2

Здесь у нас есть три функции с setTimeout(), и для каждой из них есть функция обратного вызова, timer9000(), timer1000() и timer2000(), а их задержки составляют 9000 мс, 1000 мс и 2000 мс соответственно.

Из визуализации стека вызовов мы можем видеть

  1. Когда вызывается функция two(), она создает новый вызов Web API с функцией обратного вызова timer9000() и запускает таймер задержки на 9000 мс. По истечении таймера задержки он переходит в очередь обратного вызова и ожидает там, пока текущий стек вызовов не пуст.
  2. Затем, когда вызывается функция three(), она также создает новый вызов Web API с функцией обратного вызова timer1000() и запускает таймер задержки 1000 мс после истечения таймера задержки, он переходит в Очередь обратного вызова, и то же самое касается функции four().
  3. Наконец, когда стек вызовов становится пустым, цикл обработки событий берет первую функцию из очереди, которой является здесь timer1000(), и помещает ее в стек вызовов для выполнения, а затем timer2000() и timer9000().

Одна интересная часть здесь заключается в том, что хотя timer9000() вызывается первым, он переходит в очередь обратного вызова после timer1000() и timer2000(). так как время задержки для этих двух функций меньше, чем у timer9000().

И именно поэтому выход равен 1 -> 5 -> 3 -> 4 -> 2.

Заключение

Итак, здесь я хотел дать обзор синхронного javascript, а также того, как он обрабатывает асинхронные задачи с помощью функции setTimeout. Но это гораздо больше, поэтому в следующей части мы постараемся узнать больше об очередях обратного вызова, микрозадачах, макрозадачах, цикле событий, веб-API, обещаниях и многом другом.