Строим свой full-stack на JavaScript: Основы. Почему я использую JavaScript

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

Стек

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

Этот процесс складывания тарелок сохраняет последовательный порядок, когда каждая тарелка добавляется в стопку. Снятие тарелки из стопки также не нарушит последовательность всех тарелок. Если тарелка снимается сверху стека, каждая тарелка в стопке по-прежнему будет сохранять тот же порядок в стопке.

В качестве более технологичного примера стека можно привести операцию «Отменить » (Undo ) в текстовом редакторе. Каждый раз, когда текст вводится в текстовый редактор, он помещается в стек. Самое первое изменение текста представляет собой дно стека; самое последнее — вершину. Если пользователь хочет отменить самое последнее изменение, удаляется верх стека. Этот процесс может повторяться до тех пор, пока не останется ни одного изменения.

Операции стека

Так как теперь у нас есть концептуальная модель стека, давайте определим две операции стека:

  • push(data) — добавляет данные;
  • pop() — удаляет последние добавленные данные.

Реализация стека

Теперь давайте напишем код для стека.

Свойства стека

Мы создадим конструктор с именем Stack . Каждый экземпляр Stack будет иметь два свойства: _size и _storage :

function Stack() { this._size = 0; this._storage = {}; }

this._storage — позволяет каждому экземпляру Stack иметь собственный контейнер для хранения данных; this._size отображает сколько раз данные были добавлены в текущую версию Stack . Если создается новый экземпляр Stack и в хранилище добавляются данные, то this._size увеличится до 1. Если данные снова добавляются в стек, this._size увеличится до 2. Если данные удаляются из стека, this._size уменьшается до 1.

Методы стека

Определим методы, с помощью которых можно добавлять (push ) и удалять (pop ) данные из стека. Начнем с добавления данных.

Метод push(data)

Этот метод может быть общим для всех экземпляров Stack , так что мы добавим его в прототип Stack .

Требования к этому методу:

  1. Каждый раз, когда мы добавляем данные, размер стека должен увеличиваться;
  2. Каждый раз, когда мы добавляем данные, порядок стека должен сохранять свою последовательность:

Stack.prototype.push = function(data) { // увеличение размера хранилища var size = this._size++; // назначает размер в качестве ключа хранилища // назначает данные в качестве значения этого ключа this._storage = data; };

Объявляем переменную size и присваиваем ей значение this._size ++ . Устанавливаем переменную size в качестве ключа this._storage , data — в качестве значения соответствующего ключа.

Если стек вызывал push(data) пять раз, то размер стека будет 5. Первое добавление данных в стек назначит этим данным ключ 1 в this._storage . Пятый вызов push(data) присвоит данным ключ 5 в this._storage . Мы только что задали порядок для наших данных.

Метод 2 из 2: pop()

Следующий логический шаг заключается в удалении данных из стека. Удаление данных из стека подразумевает удаление только последних добавленных элементов.

Цели для этого метода:

  1. Использовать текущий размер стека, чтобы получить последние добавленные элементы;
  2. Удалить последние добавленные элементы;
  3. Уменьшить _this._size на один;
  4. Вернуть последние удаленные данные.

Stack.prototype.pop = function() { var size = this._size, deletedData; deletedData = this._storage; delete this._storage; this.size--; return deletedData; };

pop() выполняет все перечисленные нами задачи. Мы объявляем две переменные: size инициализируется значением размера стека; deletedData назначается для последних добавленных в стек данных. Затем мы удаляем пару ключ-значение из последних добавленных элементов. После этого мы уменьшаем размер стека на 1, и возвращаем данные, которые были удалены из стека.

Если мы протестируем текущую реализацию pop() , то увидим, что она работает в следующих случаях: если мы передаем данные в стек, то размер стека увеличивается на один; если мы удаляем данные из стека, его размер уменьшается на один.

Но если мы выполним все операции в обратном порядке, то возникает проблема. Рассмотрим следующий сценарий: мы вызываем pop() , а затем push(data) . Размер стека становится -1, а затем 0. Но корректный размер нашего стека 1.

Чтобы решить эту проблему, мы добавим в pop() оператор if :

Stack.prototype.pop = function() { var size = this._size, deletedData; if (size) { deletedData = this._storage; delete this._storage; this._size--; return deletedData; } };

После добавления оператора if , тело кода выполняется только тогда, когда в нашем хранилище есть данные.

Полная реализация стека

Наша реализация стека завершена. Вот окончательный вариант кода:

function Stack() { this._size = 0; this._storage = {}; } Stack.prototype.push = function(data) { var size = ++this._size; this._storage = data; }; Stack.prototype.pop = function() { var size = this._size, deletedData; if (size) { deletedData = this._storage; delete this._storage; this._size--; return deletedData; } };

От стека к очереди

Стек может оказаться полезным, если мы хотим добавлять и удалять данные в последовательном порядке. Исходя из определения, из стека можно удалять только последние добавленные данные. Но что, если мы хотим удалить старые данные? Для этого нам нужно использовать структуру данных под названием очередь.

Очередь

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

Чтобы помочь вам понять, как это работает, я приведу одну аналогию. Представьте себе очередь по записи по талонам. Каждый клиент берет талон и обслуживается, когда называется его номер. Клиент, который получил талон с номером один, должен быть обслужен первым.

Клиент, который получил второй талон, будет обслужен вторым. Если бы наша система обслуживания работала как стек, клиент, который был добавлен в стек первым, был бы обслужен последним.

Практическим примером реализации очереди является цикл событий веб-браузера. В нем различные события, которые были инициированы, например, нажатием кнопки мыши, добавляются в очередь цикла событий и обрабатываются в порядке их поступления.

Операции очереди

Как вы заметили, операции очереди очень похожи на стек. Разница заключается в том, откуда убираются данные:

  • enqueue(data) — добавляет элементы в очередь;
  • dequeue — удаляет самые старые данные из очереди.

Реализация очереди

Теперь давайте напишем код для очереди.

Свойства очереди

Для реализации мы создадим конструктор с именем Queue . Затем мы добавим три свойства: _oldestIndex , _newestIndex и _storage :

function Queue() { this._oldestIndex = 1; this._newestIndex = 1; this._storage = {}; }

Методы очереди

Теперь мы создадим три метода, распространяющиеся на все экземпляры очереди: size(), enqueue(data) и dequeue(data).

Метод size()

Цели этого метода:

  1. Вернуть корректный размер очереди;
  2. Сохранить правильный диапазон ключей для очереди.

Queue.prototype.size = function() { return this._newestIndex - this._oldestIndex; };

Реализация size() может показаться вам слишком простой, но вы быстро поймете, что это не так. Давайте ненадолго вернемся к реализации метода size() для стека.

Представим, что мы добавляем в стек пять тарелок. Размер нашего стека — пять, и каждая тарелка имеет соответствующий ей номер от 1 (первая добавленная тарелка ) до 5 (последняя добавленная тарелка ). Если мы уберем три тарелки, то у нас останется две тарелки.

Мы можем просто вычесть три из пяти, чтобы получить правильный размер — два. Вот основной принцип для размера стека: текущий размер представляет собой корректный ключ, связанный с номером тарелки в верхней части стека (2) и другой тарелки в стеке (1). Другими словами, диапазон ключей всегда имеет границы от текущего размера до одного.

Теперь давайте применим эту реализацию размера стека для очереди. Представьте себе, что пять клиентов получили талоны. Первый клиент имеет талон с номером 1, пятый клиент имеет талон с номером 5. В очереди клиент с первым талоном обслуживается первым.

Давайте представим, что первый клиент обслужен и этот талон удаляется из очереди. По аналогии со стеком мы можем получить корректный размер очереди путем вычитания 1 из 5. В очереди в настоящее время есть четыре необслуженных талона. Тут и возникает проблема: размер больше не соответствует номерам, указанным на талонах. Если мы просто вычтем 1 из 5, мы получим размер 4. Мы не можем использовать 4 для определения текущего диапазона талонов, оставшихся в очереди. В очереди остались талоны с номерами от 1 до 4 или от 2 до 5? Ответ неясен.

Вот для чего нам нужны два свойства _oldestIndex и _newestIndex . Представьте себе, что наша система обслуживания клиентов имеет две системы выдачи талонов:

  1. _newestIndex — для обслуживания клиентов;
  2. _oldestIndex — для обслуживания сотрудников.

Когда числа в обеих системах одинаковы, это значит, что каждый клиент в очереди был обслужен, и очередь пуста. Мы будем использовать следующий сценарий, чтобы доказать эту логику:

  1. Клиент берет талон. Номер талона, который извлекается из _newestIndex , равен 1. Следующий талон, доступный в системе обслуживания клиентов, имеет номер 2;
  2. Сотрудник не берет билет, а текущий талон в системе обслуживания сотрудников имеет номер 1;
  3. Мы берем текущий номер талона в системе обслуживания клиентов (2) и вычитаем из него номер в системе сотрудников (1), чтобы получить число 1. Число 1 представляет собой количество талонов в очереди, которые еще не были удалены;
  4. Сотрудник берет талон из системы обслуживания. Этот талон представляет собой талон клиента, который должен быть обслужен. Талон, который был обслужен, извлекается из _oldestIndex , здесь отображается номер 1;
  5. Мы повторяем шаг 4, и теперь разница равна нулю — в очереди больше нет талонов;
  6. Теперь у нас есть свойство (_newestIndex ), которое указывает наибольшее количество (ключ ), назначенное в очереди, и свойство (_oldestIndex ), которое указывает самый первый порядковый номер (ключ ) в очереди.

Метод enqueue(data)

Для enqueue у нас есть две задачи:

  1. Использовать _newestIndex в качестве ключа для this._storage и использовать любые добавляемые данные в качестве значения этого ключа;
  2. Увеличить значение _newestIndex на 1.

Мы создадим следующую реализацию enqueue(data) :

Queue.prototype.enqueue = function(data) { this._storage = data; this._newestIndex++; };

В первой строке мы используем this._newestIndex , чтобы создать новый ключ для this._storage и присвоить ему значение data. this._newestIndex всегда начинается с 1. Во второй строке кода, мы увеличиваем this._newestIndex на 1, и значение теперь равняется 2.

Метод dequeue()

Задачи для этого метода:

  1. Удалить старые элементы из очереди;
  2. Увеличить _oldestIndex на один:

Queue.prototype.dequeue = function() { var oldestIndex = this._oldestIndex, deletedData = this._storage; delete this._storage; this._oldestIndex++; return deletedData; };

В теле dequeue() мы объявляем две переменные. Первой переменной, oldestIndex , присваивается текущее значение очереди для this._oldestIndex . Второй переменной, deletedData , присваивается значение, содержащееся в this._storage .

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



В мире JavaScript очень легко набрать свой стек технологий, используя набор небольших пакетов, каждый из которых решают свою конкретную проблему. И это хорошо, c одной стороны, а с другой стороны, у вас особо нет выбора - фреймворки которые выполняют широкий спектр задач в JavaScript не популярны.


  • Статья 1: Основы

Я перешел на full-stack JavaScript из.NET. И уже после того как я освоил основы Node, мне пришлось еще уйму времени потратить на построение своего базового стека технологий и способа организации кода. К сожалению, существует не так много примеров конечных приложений на JS, где видно как решать основные, для большинства проектов задачи, такие как: обработка ошибок, доступ к БД, организация кода, авторизация и прочее.


Для того, чтобы всегда иметь под рукой пример использования базовых технологий, был написан Contoso Express , пример full-stack приложения на JS. В нашей команде, используем этот проект, как стартовый шаблон для других проектов, поэтому он будет поддерживаться и обновляться в дальнейшем.



Contoso Express это ремейк известного Microsoft учебника, для обучения основам разработки.NET веб проектов.



Для многих частей стека, сложно остановиться на одной альтернативе. Поэтому для некоторых технологий есть альтернативные имплементации. Смотри ветки ALT в Contoso Express repository.


Эти статьи для тех, кто уже знает основы, если вам нужно восполнить знания в какой-то области, поищите нужное в списке дополнительных ресурсов .


Приступим!

Почему JavaScript

Причин может быть много, вот мой топ лист:

    Простота : В основном, библиотеки в Node имеют простые API с которыми легко разобраться и которые работают интуитивно понятным образом. Если не получилось с одной библиотекой, как правило, несложно найти хорошую альтернативу.

    Контроль : Программист сам строит инфраструктуру проекта, выбирая и объединяя небольшие модули для конкретных задач. Это требует больше времени, но результат стоит того. Разобравшись один раз, полученный опыт легко применять в дальнейшем.

    Универсальность : JavaScript изначально работал только на клиенте. Вначале вместе с Node он перебрался на сервер, а совсем недавно на нем стало можно успешно писать десктопные (Eletctron) и мобильные приложения. Причем, для мобильных приложений есть опция гибридных приложений (используется обертка над браузером (Cordova)) или приложений с нативным интерфейсом (ReactNative, NativeScript). Для Node существует огромное множество библиотек и его легко интегрировать с другими технологиями, базы данных, облачные технологии, различные форматы и протоколы, найдется все.

    Легкое развертывание : Node очень легко разворачивать на сервере: как на Linux, так и на Windows. После многих лет работы с.NET, деплоймент каждый раз был для меня неприятным испытанием, на Node этот процесс даже приносит удовольствие. Это просто надо попробовать.

    Производительность : Node асинхронен и не блокирует процесс выполнения во время длительных операций, таких как вычитка файла или обращение к базе данных. Это позволяет достичь высокого уровня производительности при использовании единственного потока (single threaded environment). C другой стороны, вычисления в JavaScript медленнее чем в статически типизированных языках. Для большинства проектов это не проблема. Если нужны вычисления, а не просто преобразования данных, то лучше написать отдельный сервис на чем-то другом.

    Один язык на сервере и клиенте : Это удобно, так как позволяет, без усилий переносить код между клиентом и сервером, легче в разработке и поддержке.

  • Язык JavaScript : JavaScript очень гибок и прост в использовании, многие недостатки языка исправлены в последней версии ES6, типизацию опционально можно добавить, используя TypeScript.

И это далеко не полный список.

Варианты языка JS

Современный JavaScript можно писать в нескольких вариантах:

  • ES6 / ES7 next
  • Языки компилируемые в JS (TypeScript и другие)

Стандарты JavaScript

    ES5 - версия JavaScript от 2009 года, полностью поддерживаемая всеми современными браузерами и Node.

    ES6 - недавнее утвержденное обновление языка. Разработка поддержки стандарта всех JS движках пока еще в процессе разработки.

  • ES7 / Next - еще не утвержденные новые фичи JS.

Взглянуть на текущее состояние поддержки ES6 можно на
Kangax ES6 .


Таким образом, в ближайшее время разрабатывать клиентскую часть сразу на ES6 нельзя, потому что поддержки во всех браузерах еще нет.


Для Node используется V8 движок, текущая стабильная LTS версия которого (4.x) не поддерживает все новые особенности ES6.


LTS (long term support) это версия NodeJs, рекомендуемая для использования на продакшeне. Следующая LTS Node ожидается в октябре 2016 и в ней уже есть поддержка большинства возможностей ES6.

Транспайлинг (transpiling)

Для того чтоб бы использовать фичи ES6/ES7 существует несколько транспайлеров которые преобразуют код написанный на ES6 в ES5.


Обратите внимание на разницу между транспиляцией и компиляцией: .


Babel самый популярный транспайлер из ES6/Next в ES5.

TypeScript

TypeScript это язык расширение JavaScript, которое добавляет возможность статической типизации. Типы в TypeScript используются для проверки корректности кода и дополнительных возможностей рефакторинга и авто-подсказок. Когда TypeScript транспилируется в JS, все определения типов опускаются.


TypeScript поддерживает многие ES6/ESNext возможности и может использоваться как Транспайлер (вместо Babel).


Кроме того в TypeScript существуют дополнительные конструкции, которых нет в JS - энумы, наследование и полиморфизм в классах и прочее. Они транспилируются в JS с помощью вспомогательного JS кода.


Не все возможности TypeScript одинаково полезны перечислю основные категории:

    Транспиляция ES6/ESNext - TS отлично с этим справляется, хотя и уступает Babel в некоторых моментах, например async/await в TypeScript пока только доступно если транпилировать в ES6 версию. Больше об этом в следующей статье.

    Статическая типизация в коде приложения - это основная причина использовать TS, типы в TS опциональны, если тип не указан, предполагается тип "any", что значит в этот тип можно записать и прочитать из него любые поля, это делает перевод существующего кода JS на TS, гораздо легче, не нужно добавлять типы везде, а только там где это имеет смысл. При этом даже если вы не описываете типы в TS, у вас появляется много дополнительных проверок, которые помогают обнаруживать тривиальные ошибки-опечатки, еще до того, как вы запускаете приложение. Дополнительно у вас хорошо работают подсказки в коде и не нужно лишний раз смотреть какие методы есть у модуля и какие у них сигнатуры.

    Типизация сторонних библиотек - TS позволяет описывать структуру сторонних библиотек (например lodash иди express), это позволяет контролировать, что вы вызываете методы с правильными параметрами и позволяет видеть методы и их сигнатуры без использования документации. К сожалению качество таких описаний часто оставляет желать лучшего и когда в описании нет нужной сигнатуры ее нужно добавлять вручную. Иногда легче не использовать сторонние описания (работать с библиотекой как с типом "any"). Проблема в том, что сейчас описания библиотек в TS и сами библиотеки чаще всего пишут разные люди. Скорее всего ситуация будет улучшаться с ростом популярности TS.

  • Возможности TS, которых нет в JS - это я бы не рекомендовал к использованию без необходимости или если вы не используете фреймворки написанные непосредственно на TS (например Angular2). Использование таких возможностей делает конвертацию TS<->JS гораздо сложнее, они чаще изменяются между разными версиями TS.

Что выбрать

Я бы рекомендовал выбирать между ES6 и TypeScript. ES6 имеет очень много полезных дополнений, которые делают разработку легче и приятнее и это стоит того, чтобы потратить больше времени на первоначальную настройку. На своих проектах я перешел на TypeScript, потому что это действительно серьезно улучшает процесс разработки, хотя и требует гораздо больших усилий по настройке и интеграции. Что бы вы не выбрали, хорошо если вы делаете осознанный выбор, поработав и с тем и с другим.

Среда разработки

WebStorm - умная среда разработки со множеством встроенных функций. Хорошая поддержка для TypeScript. Платная. Подробности настройки для contoso express на wiki-странице проекта. WebStorm основная IDE в нашей команде.


Visual Studio Code - бесплатная среда от Microsoft. Наилучшая поддержка для TypeScript, появилась недавно, нет некоторых привычных функций. Быстро развивается, полностью сделана и поддерживает плагины на JavaScript.


Другие - можно пользоваться и другими JavaScript IDE, такими как Atom, Sublime, Brackets. TypeScript в той или иной степени поддерживается везде.

Подбираем npm пакеты

Работать с npm очень просто. Просто устанавливать и просто публиковать свои пакеты. Вследствии этого пакетов на npm великое множество (на текущий момент 330.000+).


Подобрать правильный пакет достаточно сложно. По любому запросу на npmjs.com найдется множество пакетов, при этом не факт, что там будут предоставлены основные пакеты для решения нужной задачи.


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


Есть сайт с альтернативным поиском по npm npms.io , там сразу просчитываются такие характеристики, как популярность и регулярность поддержки.


В дальнейших статьях я буду описывать основные опции по пакетам для базовых задач разработки. Если имя пакета в кавычках (например "bluebird"), это значит это имя с которым пакет зарегистрирован в npm и его можно посмотреть по ссылке https://www.npmjs.com/package/{package_name } (т.е. в данном случае https://www.npmjs.com/package/bluebird).

Базы данных

SQL или NoSQL?

На этот вопрос нельзя ответить однозначно, зависит от конкретной ситуации. Самыми популярными опциями для SQL/NoSql являются PostgreSQL/MongoDb. Недавнее добавление JSON полей в PostgreSQL, делает его очень привлекательным вариантом соединяющим в себе лучшее из миров SQL/NoSql. Но, несмотря на это, MongoDb по-прежнему самая популярная база данных для Node, и может быть легче для работы, особенно для тех, у кого не было предыдущего опыта работы с SQL базами данных.

Доступ к базе данных

Работая с базой данных вы можете использовать доступ напрямую с помощью драйвера базы данных или каккую-то ORM абстракцию более высокого уровня. Если у вас не много взаимодействий с базой данных, то лучше использовать доступ напрямую или абстракцию низкого уровня, такую как Knex (для SQL баз данных).

ORM

Sequelize - самая популярная ORM для SQL баз данных. Она предоставляет высокий уровень абстракции над БД схемой и поддерживает основные SQL диалекты (PostgreSQL, MySQL, SQLite and MSSQL). Используется в Contoso Express.


Knex - это абстракция более низкого уровня. Больше как конструктор запросов, чем полоценная ORM. Поддерживает большее количество диалектов и дает больше контроля над генерируемым SQL. Имеются функции построения схемы БД и ее миграций.


Bookshelf - eще одна популярная ORM основанная на Knex, уровень абстракции ниже чем в Sequelize и многие вещи нужно определять вручную.


Mongoose - Самая популярная ORM для самой популярной в JS базы данных MongoDB

Прямое подключение

Для всех основных баз данных существуют драйверы хорошего качества, для прямого соединения. Для Postgres используйте "pg" или "pg-promise" пакеты.

Что дальше

В следующей статье я расскажу про работу с JS на сервере. Она будет более практичной и покроет такие вещи, как выбор веб фреймворка, организация структуры проекта, работа с конфигами, авторизация, логирование, обработка ошибок и другое.


Буду рад замечаниям комментариям, особенно, непосредственно, по коду проекта Contoso Express:)


Спасибо всем кто дочитал до конца! Stay tuned!

Популярность JavaScript растёт, его возможности используют на разных уровнях применяемых разработчиками стеков технологий и на множестве платформ. На JS делают фронтенд и бэкенд, пишут гибридные и встраиваемые приложения, а также многое другое.

С другой системой статистических сведений по GitHub можно ознакомиться , она подтверждает то, что было сказано выше.

Если множество проектов плотно завязаны на JavaScript, значит, разработчикам необходимо как можно более эффективно использовать всё, что даёт им язык и его экосистема, стремясь, на пути разработки замечательных программ, к глубокому пониманию внутренних механизмов языка.

Как ни странно, существует множество разработчиков, которые регулярно пишут на JavaScript, но не знают, что происходит в его недрах. Пришло время это исправить: этот материал посвящён обзору JS-движка на примере V8, механизмов времени выполнения, и стека вызовов.

Обзор

Почти все слышали, в самых общих чертах, о JS-движке V8, и большинству разработчиков известно, что JavaScript - однопоточный язык, или то, что он использует очередь функций обратного вызова.

Здесь мы поговорим, на довольно высоком уровне, о выполнении JS-кода. Зная о том, что, на самом деле, происходит при выполнении JavaScript, вы сможете писать более качественные программы, которые выполняются без «подвисаний» и разумно используют имеющиеся API.

Если вы недавно начали писать на JavaScript, этот материал поможет вам понять, почему JS, в сравнении с другими языками, может показаться довольно-таки странным.

Если вы - опытный JS-разработчик, надеемся, этот материал поможет вам лучше понять, как на самом деле работает то, чем вы пользуетесь каждый день.

Движок JavaScript

V8 от Google - это широко известный JS-движок. Он используется, например, в браузере Chrome и в Node.js. Вот как его, очень упрощённо, можно представить:


Упрощённое представление движка V8

На нашей схеме движок представлен состоящим из двух основных компонентов:

  • Куча (Memory Heap) - то место, где происходит выделение памяти.
  • Стек вызовов (Call Stack) - то место, куда в процессе выполнения кода попадают так называемые стековые кадры.

Механизмы времени выполнения

Если говорить о применении JavaScript в браузере, то здесь существуют API, например, что-то вроде функции setTimeout , которые использует практически каждый JS-разработчик. Однако, эти API предоставляет не движок.

Откуда же они берутся? Оказывается, что реальность выглядит немного сложнее, чем может показаться на первый взгляд.


Движок, цикл событий, очередь функций обратного вызова и API, предоставляемые браузером

Итак, помимо движка у нас есть ещё очень много всего. Скажем - так называемые Web API, которые предоставляет нам браузер - средства для работы с DOM, инструменты для выполнения AJAX-запросов, нечто вроде функции setTimeout , и многое другое.

Стек вызовов

JavaScript - однопоточный язык программирования. Это означает, что у него один стек вызовов. Таким образом, в некий момент времени он может выполнять лишь какую-то одну задачу.

Стек вызовов - это структура данных, которая, говоря упрощённо, записывает сведения о месте в программе, где мы находимся. Если мы переходим в функцию, мы помещаем запись о ней в верхнюю часть стека. Когда мы из функции возвращаемся, мы вытаскиваем из стека самый верхний элемент и оказываемся там, откуда вызывали эту функцию. Это - всё, что умеет стек.

Рассмотрим пример. Взгляните на следующий код:

Function multiply(x, y) { return x * y; } function printSquare(x) { var s = multiply(x, x); console.log(s); } printSquare(5);
Когда движок только начинает выполнять этот код, стек вызовов пуст. После этого происходит следующее:


Стек вызовов в ходе выполнения программы

Каждая запись в стеке вызовов называется стековым кадром .

На механизме анализа стековых кадров основана информация о стеке вызовов, трассировка стека, выдаваемая при возникновении исключения. Трассировка стека представляет собой состояние стека в момент исключения. Взгляните на следующий код:

Function foo() { throw new Error("SessionStack will help you resolve crashes:)"); } function bar() { foo(); } function start() { bar(); } start();
Если выполнить это в Chrome (предполагается, что код находится в файле foo.js), мы увидим следующие сведения о стеке:

Трассировка стека после возникновения ошибки

Если будет достигнут максимальный размер стека, возникнет так называемое переполнение стека. Произойти такое может довольно просто, например, при необдуманном использовании рекурсии. Взгляните на этот фрагмент кода:

Function foo() { foo(); } foo();
Когда движок приступает к выполнению этого кода, всё начинается с вызова функции foo . Это - рекурсивная функция, которая не содержит условия прекращения рекурсии. Она бесконтрольно вызывает сама себя. В результате на каждом шаге выполнения в стек вызовов снова и снова добавляется информация об одной и той же функции. Выглядит это примерно так:


Переполнение стека

В определённый момент, однако, объём данных о вызовах функции превысит размер стека вызовов и браузер решит вмешаться, выдав ошибку:

Превышение максимального размера стека вызовов

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

Однако, и у исполнения кода в однопоточном режиме тоже есть определённые ограничения. Учитывая то, что у JavaScript имеется один стек вызовов, поговорим о том, что происходит, когда программа «тормозит».

Параллельное выполнение кода и цикл событий

Что происходит, когда в стеке вызовов имеется функция, на выполнение которой нужно очень много времени? Например, представьте, что вам надо выполнить какое-то сложно преобразование изображения с помощью JavaScript в браузере.

«А в чём тут проблема?», - спросите вы. Проблема заключается в том, что до тех пор, пока в стеке вызовов имеется выполняющаяся функция, браузер не может выполнять другие задачи - он оказывается заблокированным. Это означает, что браузер не может выводить ничего на экран, не может выполнять другой код. Он просто останавливается. Подобные эффекты, например, несовместимы с интерактивными интерфейсами.

Однако, это - не единственная проблема. Если браузер начинает заниматься обработкой тяжёлых задач, он может на достаточно долгое время перестать реагировать на какие-либо воздействия. Большинство браузеров в подобной ситуации выдают ошибку, спрашивая пользователя о том, хочет ли он завершить выполнение сценария и закрыть страницу.


Браузер предлагает завершить выполнение страницы

Пользователям подобные вещи точно не понравятся.

Итак, как же выполнять тяжёлые вычисления, не блокируя пользовательский интерфейс и не подвешивая браузер? Решение этой проблемы заключается в использовании асинхронных функций обратного вызова. Это - тема для отдельного разговора.

Итоги

Мы, в общих чертах, рассмотрели устройство JS-движка, механизмов времени выполнения и стека вызовов. Понимание изложенных здесь концепций позволяет улучшить качество кода.

Уважаемые читатели! Этот материал - первый в серии «How JavaScript Works» из блога SessionStack . Уже опубликован второй - посвящённый особенностям V8 и техникам оптимизации кода. Как по-вашему, стоит ли его переводить?

Stacks, queues and deques are the most basic of the slightly more advanced data structures you will meet. The good news is that they are very, very easy to implement in JavaScript.


JavaScript Data Structures

Contents

  1. * First Draft

JavaScript has a really useful Array object that can be used to create some of the most basic data structures - stacks and queues. In this short article we take a look at how to create stacks and queues and describe some aspects of why they are useful.

The LIFO stack

It is always difficult to know which stack is the most basic but the LIFO or Last In First Out stack is perhaps the most commonly used.

A simple array object already has the two basic methods needed to create a LIFO stack push and pop.

The push method will add any object to the top of the stack and the pop method will remove it. To treat an array as a LIFO stack you simply create an instance and use push and pop.

var stack=new Array();
stack.push("A);
stack.push("B");
stack.push("C"
alert(stack.pop());
alert(stack.pop());
alert(stack.pop());

If you try this out you will see the alerts display "C", "B" and "A". This is the key property of a LIFO stack - it reverses the order of the data you store on it. You pushed A, B and then C byt you got back C, B and then A.

You can use any Array object as a LIFO stack and it is sometimes useful to treat an array as a stack for a while and then go back to working with it as an array. If you really want to create a stack object that can only be used as a stack then you have to encapsulate an Array and expose only the push and pop methods.

function Stack()
{
this.stac=new Array();
this.pop=function(){
return this.stac.pop();
}
this.push=function(item){
this.stac.push(item);
}
}

If you make the stac Array object private using a closure say then the only operations allowed on Stack are push and pop.

var stack=new Stack();
stack.push("A");
stack.push("B");
stack.push("C");

alert(stack.pop());
alert(stack.pop());
alert(stack.pop());

and again you will see the data retrieved in the order "C", "B", "A".

Notice that while we refer to the "top of the stack" push adds the object to the "end" of the array and pop removes the final element.

That is if the array has three items already stored i.e. array, array and array then push() stores its object in array.

Also notice that if you try to pop a value that doesn"t exist i.e. pop an empty stack the result is undefined. You could test for this error in the Stack object and throw an exception or some other error if the user attempts to pop an empty stack.

A typical stack will often allow you to manipulate the stack pointer say or peek at the value on the top of the stack i.e. retrieve it without removing it but these "non-stack" operations are generally not necessary.

Also notice that as you can push and pop any object onto the stack you can use it in very sophisticated ways. For example, if you need to store an object on the stack long with the time it was created you don"t need to create a more complex Stack object because you can simply push a composite object:

Push({MyData,Time});

Also notice that there is no need to worry about problems of enumeration - because you don"t naturally ever need to enumerate a stack structure.

The FIFO stack or Queue

The close relative of the LIFO stack is the FIFO - First In First Out stack - also known as the Queue because it mimics the behaviour of a queue in the real world. That is you store an item on the back of the queue and retrieve it from the front.

Once again the Array object provides methods that enable us to create a Queue directly. The unshift method adds an item to the front of the Array. To create a queue we have to remove items from the end of the Array and this we can do using the pop method again.

That is to treat an Array as a queue all we have to do is

var Q=new Array();
Q.unshift("A);
Q.unshift("B");
Q.unshift("C");

alert(Q.pop());
alert(Q.pop());
alert(Q.pop());

If you try this out you will see the data retrieved in the order "A", "B" and "C". That is a queue or a FIFO stack doesn"t change the order of the data the items are retrieved in the order that they were stored.

A queue is useful when ever you have data to deal with and not enough time to deal with it all. You simply add the data you can"t deal with to the queue and deal when it when you can. The queue ensures that it is dealt with in the order it arrived. Another name for a queue is a buffer.

If you want to create a queue object you can follow the basic idea used for the LIFO stack:

Function Queue()
{
this.stac=new Array();
this.dequeue=function(){
return this.stac.pop();
}
this.enqueue=function(item){
this.stac.unshift(item);
}
}

Var Q=new Queue();
Q.enqueue("A");
Q.enqueue("B");
Q.enqueue("C");

Alert(Q.dequeue());
alert(Q.dequeue());
alert(Q.dequeue());

The methods enqueue and dequeue aren"t standard names but they are often used. Another controversy is over whether you join the head or start of the queue ot the tail or end of the queue. It all depends on how you think about it and as long as you get the basic FIFO action it all works.

As in the case of a stack trying to remove something from an empty queue returns undefined. You can also queue complex objects and augment the queue with additional methods to return the number of items in the queue and even the nth item in the queue.

There are more complex types of queue that you can implement but these are less commonly encountered.

For example, the priority queue works int he same way but when you enqueue an item you can also specify its priority. When items are dequeued they are returned in priority order rather than the order that they arrived in. You can implement a priority queue either by keeping the array sorted in priority order or simply search for the value to be returned in an unsorted list.

You can also use more complex data structures to implement a priority queue such as the heap - see a future article for details.

Стек вызова функций (Call stack in programming languages)

Как рассматривать стек вызова функции (Call Stack) в контексте языков программирования. Хочу сказать, что стек вызова не совсем то, что демонстрирует нам большинство материалов. Чтобы окончательно понять что такое стек вызова, необходимо вернуться к азам и рассмотреть как работает стек вызова в операционной системе, почему именно так? Потому что нельзя рассматривать стек вызова функций оторванно от физической памяти и возможностью управления ею и тем самым не надо забывать, что стек вызова функций это не «виртуальная» операция, а конкретное место в памяти выделяемое операционной системой для выполнение программного кода функции, которая была запущена внутренним или внешним API (будь-то браузера, IDE или движка какого либо языка).

Хочу определить что такое стека вызова функции в низкоуровневых (low-level programming languages) языках (Assembler, C++):

На примере Ci++:

(В современных низкоуровневых языках есть современные библиотеки позволяющие поддерживать работу ООП и контролировать стек функции не на физическом уровне, а использованием вывода консоли: Poppy, Pantheios)

Шаг 1: запуск лейаута кода.

В лейауте кроме самих инструкций находится дополнительная информация (переменные, декларативные данные, дополнительные технические данные и …)

Получив первую инструкцию о запуске ОС начинает строить стек. Сначала выделяет физическое место на кластерах памяти и заполняет его данными, но какими:

  1. Первый вызов какой либо программной инструкции происходит от модуля ОС, этот запуск так и получил название уровень модуля (это первая инструкция, которая начинает свою работу с модуля памяти ОС) (далее приведено стандартное название в языках main());

Инструкция получена, создается сам стек в котором основная часть - выделенное место для выполнения операций, а сегмент стека для хранения данных для возврата результата выполнения функции.

На рисунке 1 показаны стеки (участки памяти) предназначенные для выполнения кода для каждой функции, с правой стороны находится сегмент, куда отправляются инструкции возврата в порядке вызова функций.

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

Инструкция возврата вызова, это не место вызова этой функции, а следующая за ней инструкция.

Сегмент стека (который мы сейчас по простоте называем стеком вызова) это отдельный участок памяти, в котором храняться инструкции о возврате результатов вызванных функции. Используется метод: кто пришел последний - уйдет первый (LIFO).

В языках подобных С++ имплементированы два метода: Call и Ret, Call создает инструкцию с адресом(в бинарной нотации) и кладет на верхний уровень стека, Ret не содержит адреса, этот метод просто забирает верхнюю инструкцию и возвращает нас по указанному адресу. Сам стек, после возвращения данных и выполнения функций уничтожается и данные из него недоступны.

На рисунке 2 схематично нарисован сегмент стека вызова функций и указаны локальные переменные и инструкции по возврату.

Далее необходимо понять, как высокоуровневые языки реализовывают работу стека вызова. Давайте рассмотрим два случая: языки использующие многопоточность и однопоточные языки. Почему именно такие варианты? Потому что именно многопоточность (multithreading) и однопоточность определяет взаимодействие виртуальной и физической памяти ресурса.

Языки использующие многопоточность рассмотрим на примере Java.

На рис 3 приведена простая схема распределения динамической памяти. (Здесь тоже хочу сказать, что динамическая память также использует физические кластеры, то есть общее представление стека вызовов будет таким же как у низкоуровневых языков), на этом рисунке еще более схематично динамическая память при вызове функции.

В движок java имплементирована специальная коллекция Stack, у которой есть два метода push(добавит данные) и pop(удалить данные). Виртуальная машина Javaведет запись всех вызванных функций и с каждым вызовом функции создает StackTraceElement. После завершения выполнения функции StackTraceElement уничтожается, поэтому информация о стеке вызовов всегда актуальная. С Помощью метода getMethodName можно всегда получить информацию о методе верхнего элемента StackTraceElement, а значит о методе вызова функции.

На рисунке представлен стек вызова с переменными и методами объектов, реализованных в Java

Javascript: Javascript однопоточный скриптовый язык использующий очередь функций обратного вызова(callback functions queue) с методом FIFO (первый пришел - первый уйдешь).

Два варианта взаимодействия физической и виртуальной памяти в Javascript:

  1. Работа JS в браузере (цикл событий - event loop с очередью исполняемых колбэков). За переполнением стека (опять же того сегмента с информацией о адресе возврата) тут следит как ОС так и API браузера и V8 двигатель JS. У API Google Chrome есть метод, который после 16 тыс записей стек-кадров (stack frames) выдает сообщение о переполнении стека. после этого браузер выводит сообщение об ошибке или невозможности получить данные для возврата функции и очистки стека. (Источник: ;)

Сам стек как и базовые использует LIFO, а очередь колбэков - FIFO. Так же в V8 имплементированы методы push и pop, декларация идентична методам, реализованным в Java.

Таймеры в JS

Методы Javascript использующие таймеры использующие API OS и таймеры которые работают на API браузера. Таймеры всегда передвигают выполнение функции-колбэка в конец стека вызовов. Что это значит? Это значит, что в браузере выполняя методы setTimeout, setInterva сначала запускается таймер, а лишь потом функция-колбэк попадает в очередь ожидания. Если в момент попадания колбэка стек переполнен, колбэку приходится ждать своей очереди. Таймеры запущенные вне браузера реализованы на основании либо внутренних методов V8 либо при обращении к API окружения (API OS).

Особенностью языка Javascript является технология ajax или иначе возможность отправки асинхронных запросов (XMLHTTPRequest) и выполнения каких либо операций без задержки выполнения основного кода. XMLHTTPRequest может быть отправлен синхронно, но при этом весь остальной код будет дожидаться либо результата отправки запроса, что порой нецелесообразно. Технология ajax реализована исключительно в браузерах, что делает выделяет Javascript из группы языков использующих async. Асинхронные запросы в JS используют цикл событий с очередью колбэков (event loop with callback queue). В остальных языках программирования тоже есть возможность запуска асинхронного кода (Python (asyncio), C#(async/await)), что позволяет выполнять какие-то операции без задержки выполнения основных функций, принципом тут тоже выступает цикл событий, где функция колбэк помещается в очередь ожидания. Принцип работы очереди - FIFO.