В этой статье мы рассмотрим компьютерную науку, лежащую в основе Javascript.
ПРИМЕЧАНИЕ: Помните, что вам не нужно понимать эти концепции, чтобы начать продуктивно использовать JavaScript. Могут потребоваться годы опыта разработки, прежде чем они действительно усвоятся.
В прошлой статье мы узнали, что Javascript — это язык программирования, основанный на спецификации ECMA 262 Spec, но чтобы изучить, как он работает в компьютерной системе, мы должны добраться до абсолютного дна стека, то есть до «голого металла» — процессора и памяти машины.
Когда вы запускаете программу JavaScript, будь то веб-приложение в браузере или что-то на стороне сервера с помощью nodejs, ей необходимо выделить память в вашей оперативной памяти для хранения вещей для времени выполнения, переменных и объектов, на которые вы ссылаетесь в своем коде, а также ей нужен поток от вашего процессора для фактического выполнения инструкций в вашем коде, но вот в чем дело: как разработчику JavaScript, вам никогда не нужно думать об этих вещах, потому что это язык высокого уровня.
Высокоуровневый: относится к абстракции, которую язык обеспечивает над аппаратным обеспечением машины. JavaScript считается высокоуровневым, потому что он не требует прямого взаимодействия с операционной системой, аппаратным обеспечением. Кроме того, он не требует управления памятью, как C/C++, поскольку среда выполнения всегда использует сбор мусора.
Но что именно мы подразумеваем под высоким уровнем? Мы имеем в виду степень абстракции или упрощения, которую язык обеспечивает по отношению к аппаратному обеспечению компьютера. Машинный код — это язык самого низкого уровня; это числовой язык, который может выполняться непосредственно процессором; однако с его помощью было бы крайне сложно создать веб-сайт, поскольку вам пришлось бы запоминать число для каждой команды, которую вы хотите выполнить.
Переход на один уровень выше к ассемблеру дает некоторый синтаксический сахар, но каждый язык ассемблера специфичен для определенного процессора или операционной системы.
Таким образом, мы можем подняться еще на один уровень к языку C, который обеспечивает современный синтаксис и возможность написания кросс-платформенных программ, но разработчики все еще должны быть озабочены низкоуровневыми вопросами, такими как распределение памяти.
Еще один шаг вверх приводит нас к таким языкам, как JavaScript и Python, которые используют такие абстракции, как сборщики мусора и динамическая типизация, чтобы упростить процесс написания приложений.
Существует два основных метода преобразования кода на языке программирования в то, что может выполнить центральный процессор, поэтому давайте определим несколько важных терминов, связанных с JavaScript.
Один из них называется интерпретатором, а другой — компилятором.
Интерпретируемый или компилируемый «точно в срок»: Интерпретируемый означает, что исходный код преобразуется в байткод и выполняется во время выполнения (в отличие от компиляции в двоичный машинный код во время сборки). Именно поэтому JS обычно называют «скриптовым языком». Первоначально он был только интерпретируемым, но современные JS-движки, такие как V8, Spidermonkey и Nitro, используют различные техники для выполнения Just-in-Time Compilation или JIT для повышения производительности. Разработчики по-прежнему используют JS как интерпретируемый язык, в то время как движок магическим образом компилирует части исходного кода в низкоуровневый машинный код за кулисами.
JavaScript является интерпретируемым языком, что означает, что для чтения исходного кода и его выполнения требуется среда. Мы можем продемонстрировать это, просто открыв браузер и запустив некоторый код JavaScript из консоли.
Это отличается от компилируемых языков, таких как Java или C, которые предварительно статически анализируют весь ваш код, а затем компилируют его в двоичный файл, который вы можете запустить на компьютере.
JavaScript является динамически типизированным языком, что, как правило, является общей характеристикой интерпретируемых языков высокого уровня.
Мы можем изучить это, сравнив статически типизированный код Java с динамически типизированным кодом JavaScript.
Динамически слабо типизированный: Динамичность чаще всего относится к системе типов. JS — динамически слабо типизированный язык, то есть вы не указываете типы переменных (string, int и т.д.), и истинные типы не известны до времени выполнения.
При сравнении вы заметите, что в коде Java аннотируются такие вещи, как целые числа и строки, но в JavaScript типы неизвестны или неявны. Это происходит потому, что тип связан со значением во время выполнения, а не с фактическими переменными или функциями в вашем коде.
Вы также можете услышать, что JavaScript является мультипарадигмальным языком. Большинство языков программирования общего назначения поддерживают несколько парадигм, позволяя вам сочетать декларативные функциональные методы и императивные объектно-ориентированные подходы.
Multi-Paradigm: означает, что язык является универсальным или гибким. JS может использоваться для декларативного (функционального) или императивного (объектно-ориентированного) стилей программирования.
Прототипное наследование является основой JavaScript. Все в JavaScript рассматривается как объект, и каждый объект в языке имеет связь со своим прототипом, образуя цепочку прототипов, от которых последующие объекты могут наследовать свое поведение. Если вы привыкли к наследованию на основе классов, это может показаться вам странным, но это одна из низкоуровневых идей, которая делает JavaScript очень гибким мультипарадигмальным языком.
Прототипическое наследование: означает, что объекты могут наследовать поведение от других объектов. Это отличается от классического наследования, при котором вы определяете
класс
или чертеж для каждого объекта и инстанцируете его. Мы подробно рассмотрим прототипическое наследование позже в этом курсе.
Вот пример:
let animal = {
eats: true
};
let rabbit = {
jumps: true
};
rabbit.__proto__ = animal; // (*)
// we can find both properties in rabbit now:
alert( rabbit.eats ); // true (**)
alert( rabbit.jumps ); // true
Javascript — это высокоуровневый интерпретируемый, динамически типизированный, мультипарадигмальный прототипный язык, но это также однопоточный, собирающий мусор, неблокирующий язык с циклом событий, который может быть скомпилирован Just-In-Time.
Первый набор определений в значительной степени относится к тому, как javascript изложен в ECMA 262 — однако он не описывает, как должна управляться память, и даже не упоминает цикл событий во всем 800-страничном документе, так что решение этих деталей реализации остается за производителями браузеров.
Spider monkey от Mozilla и v8 от Google — две наиболее популярные реализации. Их методы немного отличаются, но оба они используют технику, известную как Just-In-Time Compilation.
В случае v8, он компилирует весь ваш JavaScript в машинный код перед запуском, а не интерпретирует байткод строка за строкой, как это делает обычный интерпретатор.
Таким образом, хотя эти движки JavaScript не меняют коренным образом способ написания кода разработчиками, JIT-компилятор улучшает производительность в браузерах и на Node.
Поскольку JavaScript является однопоточным языком, он может выполнять только одно вычисление за раз.
Попробуйте это сделать: Откройте консоль с помощью Ctrl+Shift+J
в этой вкладке браузера, а затем постройте цикл while, который никогда не останавливается.
while(true){}
Если вы попытаетесь нажать на что-то в этой вкладке браузера, событие никогда не произойдет, поскольку единственный поток заперт в этом цикле и не может перейти к следующему событию.
Зайдите в диспетчер задач Chrome и вы увидите, что вкладка браузера использует почти 100% ресурсов ядер процессора. Завершите процесс и обновите вкладку.
Когда вы запускаете код JavaScript, машина выделяет две области памяти: стек вызовов и кучу.
Стек вызовов предназначен для высокопроизводительной непрерывной области памяти, используемой для выполнения ваших функций. Когда вы вызываете функцию, она генерирует фрейм и стек вызовов с копией своих локальных переменных. Когда вы вызываете функцию внутри функции, она добавляет еще один фрейм в стек, а когда вы возвращаетесь из функции, она удаляет этот фрейм из стека.
Я считаю, что просмотреть свой собственный код кадр за кадром — это лучший подход к пониманию стека вызовов.
Вот видео для этого:
Когда мы сталкиваемся с чем-то более сложным, например, с объектом, на который могут ссылаться несколько вызовов функций вне этого локального контекста, в игру вступает куча.
Куча является сборщиком мусора, что означает, что V8 или среда выполнения JS будут пытаться очистить свободную память, когда на нее больше не будут ссылаться в вашем коде. Это не означает, что вы не должны беспокоиться о памяти, но это означает, что вам не нужно вручную выделять и освобождать память, как это делается в языке C.
Мы уже видели, как простой цикл while может полностью разрушить однопоточный язык, так как же нам справиться с любой долго выполняющейся задачей? Ответ — цикл событий.
Модель параллелизма Event-Loop: Event Loop относится к функции, реализованной в таких движках, как V8, которая позволяет JS разгружать задачи на отдельные потоки. Браузерные и Node API выполняют длительные задачи отдельно от основного потока JS, затем назначают функцию обратного вызова (которую вы определяете) для выполнения в основном потоке, когда задача завершена. Именно поэтому JS называется неблокирующим, потому что он должен ждать только синхронного кода от ваших функций JS. Думайте о цикле событий как об очереди сообщений между единственным потоком JS и ОС.
Давайте начнем с нуля и напишем наш собственный код. В своей самой простой форме это просто цикл while, который ожидает сообщений из очереди и затем обрабатывает их синхронные инструкции до завершения.
while (queue.waitForMessage()) {
queue.processNextMessage();
}
В браузере вы уже делаете это, даже не задумываясь об этом; вы можете установить слушатель событий для нажатия кнопки, и когда пользователь нажмет на нее, он отправит сообщение в очередь, а среда выполнения обработает любой JavaScript, который вы определили как обратный вызов для этого события; именно это делает JavaScript неблокирующим.
Поскольку единственное, что он делает, это слушает события и обрабатывает обратные вызовы, он никогда не ждет возврата значения функции; вместо этого он ждет, пока процессор обработает ваш синхронный код, что обычно составляет микросекунды.
Рассмотрим первую итерацию цикла событий. Сначала он обработает весь синхронный код в сценарии, а затем проверит, есть ли в очереди сообщения или обратные вызовы, готовые к обработке. Мы можем легко продемонстрировать это поведение, добавив в верхнюю часть скрипта таймаут, установленный на 0 секунд.
Вот пример, демонстрирующий эту концепцию (setTimeout не запускается сразу после истечения его таймера):
const seconds = new Date().getSeconds();
setTimeout(function() {
// prints out "2", meaning that the callback is not called immediately after 500 milliseconds.
console.log(`Ran after ${new Date().getSeconds() - seconds} seconds`);
}, 500)
while (true) {
if (new Date().getSeconds() - seconds >= 2) {
console.log("Good, looped for 2 seconds")
break;
}
}
Теперь вы можете подумать, что этот таймаут должен быть выполнен первым, потому что он находится в начале файла и имеет таймаут 0 секунд, но цикл событий не доберется до него, пока не завершится первая итерация синхронного кода.
Уникальность этого метода заключается в том, что вы можете разгрузить долго выполняющиеся задания на совершенно отдельные пулы потоков в браузере. Например, вы можете сделать HTTP-вызов, на разрешение которого уйдет несколько секунд, или взаимодействовать с файловой системой на Node JS, но вы можете сделать это, не блокируя основной поток JavaScript.
С появлением Promises и Micro Task Queue JavaScript пришлось пойти на то, чтобы сделать все немного более странным.
Если мы включим в наш сценарий Promise resolve после setTimeout(),
setTimeout(() => console.log('Do this first?'),0)
Promise.resolve().then(n => console.log('Do this Second?'))
Можно подумать, что у нас здесь две асинхронные операции с нулевой задержкой, поэтому setTimeout сработает первым, а обещание — вторым, но существует нечто, называемое микроочередью задач для обещаний, которая имеет приоритет над основной очередью задач, используемой для Dom API, setTimeout и т. д. Это означает, что обработчик обещания будет вызван первым. В этом случае, по мере итерации цикла событий, он сначала будет обрабатывать синхронный код, затем перейдет к очереди микрозадач и обработает все обратные вызовы, готовые от ваших обещаний.
В завершение он запустит обратные вызовы, готовые по заданным таймаутам или API Dom.
Итак, вот как работает JavaScript. Если все это кажется вам слишком сложным, не волнуйтесь, потому что вам не нужно знать ничего из этого, чтобы начать создавать вещи на JavaScript.
Спасибо, что прочитали эту статью; следите за мной, чтобы узнать больше.