Хабрахабр

[Перевод] Как работает JS: абстрактные синтаксические деревья, парсинг и его оптимизация

[Советуем почитать] Предыдущие 13 частей цикла

Все мы знаем о том, что JavaScript-код веб-проектов может разрастаться до прямо-таки огромных размеров. А чем больше размер кода — тем дольше браузер будет его загружать. Но проблема тут не только во времени передачи данных по сети. После того, как программа загрузится, её ещё надо распарсить, скомпилировать в байт-код, и, наконец, выполнить. Сегодня мы представляем вашему вниманию перевод 14 части серии материалов об экосистеме JavaScript. А именно, речь пойдёт о синтаксическом анализе JS-кода, о том, как строятся абстрактные синтаксические деревья, и о том, как программист может повлиять на эти процессы, добившись повышения скорости работы своих приложений.

image

Как устроены языки программирования

Прежде чем говорить об абстрактных синтаксических деревьях, остановимся на том, как устроены языки программирования. Вне зависимости от того, какой именно язык вы используете, вам всегда приходится применять некие программы, которые принимают исходный код и преобразуют его в нечто такое, что содержит конкретные команды для машин. В роли таких программ выступают либо интерпретаторы, либо компиляторы. Неважно, пишете ли вы на интерпретируемом языке (JavaScript, Python, Ruby), или на компилируемом (C#, Java, Rust), ваш код, представляющий собой обычный текст, всегда будет проходить этап парсинга, то есть — превращения обычного текста в структуру данных, которая называется абстрактным синтаксическим деревом (Abstract Syntax Tree, AST).

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

Применение абстрактных синтаксических деревьев

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

Отчёты такого инструмента помогут в рефакторинге, позволят уменьшить дублирование кода. Предположим, вы хотите разработать инструмент, который находит в коде часто встречающиеся структуры. На самом деле, если вы хотите создать подобный инструмент, вам не нужно писать собственный парсер для JavaScript. Сделать это можно, пользуясь обычным сравнением строк, но такой подход окажется весьма примитивным, возможности его будут ограниченными. Например — Esprima и Acorn. Существует множество опенсорсных реализаций подобных программ, которые полностью совместимы со спецификацией ECMAScript. Существуют и инструменты, которые могут помочь в работе с тем, что генерируют парсеры, а именно, в работе с абстрактными синтаксическими деревьями.

Предположим, вы решили разработать транспилятор, преобразующий код на Python в код на JavaScript. Абстрактные синтаксические деревья, кроме того, широко используются при разработке транспиляторов. Вероятно, тут вы зададитесь вопросом о том, как такое возможно. Подобный проект может быть основан на идее, в соответствии с которой используется транспилятор для создания абстрактного синтаксического дерева на основе Python-кода, которое, в свою очередь, преобразуется в код на JavaScript. Прежде чем код преобразуется в AST, он выглядит как обычный текст, при написании которого следуют определённым правилам, которые формируют язык. Всё дело в том, что абстрактные синтаксические деревья — это всего лишь альтернативный способ представления кода на некоем языке программирования. В результате можно осуществить не только переход от исходного кода к AST, но и обратное преобразование, превратив абстрактное синтаксическое дерево в текстовое представление кода программы. После парсинга этот код превращается в древовидную структуру, которая содержит ту же информацию, что и исходный текст программы.

Парсинг JavaScript-кода

Поговорим о том, как строятся абстрактные синтаксические деревья. В качестве примера рассмотрим простую JavaScript-функцию:

function foo(x) return x + 10;
}

Парсер создаст абстрактное синтаксическое дерево, которое схематично представлено на следующем рисунке.

Абстрактное синтаксическое дерево

Настоящее абстрактное синтаксическое дерево выглядит гораздо сложнее. Обратите внимание на то, что это — упрощённое представление результатов работы парсера. Если вам интересно взглянуть на то, как выглядит реальное абстрактное синтаксическое дерево — воспользуйтесь сайтом AST Explorer. В данном случае наша главная цель — получить представление о том, во что, в первую очередь, превращается исходный код прежде чем он будет выполнен. Для того, чтобы сгенерировать AST для некоего фрагмента JS-кода, его достаточно поместить в соответствующее поле на странице.

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

Присмотритесь к этому рисунку, возможно, вы увидите там кое-что интересное.

Временные затраты на выполнение JS-кода

Если нет — посмотрите ещё раз. Видите? И это — не некие условные данные. Собственно говоря, речь идёт о том, что, в среднем, браузеры тратят 15-20% времени на парсинг JS-кода. Возможно, показатель в 15% может показаться вам не таким уж и большим, но, поверьте, это много. Перед вами — статистические сведения о работе реальных веб-проектов, которые так или иначе используют JavaScript. 4 Мб JavaScript-кода, а чтобы распарсить этот код браузеру надо примерно 370 мс. Типичное одностраничное приложение загружает примерно 0. И да, само по себе это немного. Опять же, вы можете сказать, что в этом нет ничего страшного. Сюда не входит время, необходимое на выполнение кода, или время, которое нужно на решение других задач, сопутствующих загрузке страницы, например — задач обработки HTML и CSS и рендеринга страницы. Однако не стоит забывать о том, что это — лишь время, которое нужно на то, чтобы разобрать код и превратить его в AST. В случае с мобильными системами всё ещё хуже. Причём, речь тут идёт лишь о настольных браузерах. Взгляните на следующий рисунок. В частности, время парсинга одного и того же кода на мобильных устройствах может быть в 2-5 раз больше, чем на настольных.

Время парсинга 1 Мб JS-кода на различных устройствах

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

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

Анализ сайта с помощью инструментов разработчика в браузере

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

Оптимизация парсинга и JS-движки

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

Под потоковой передачей в данном случае понимается то, что система занимается парсингом скриптов, загружающихся асинхронно, и скриптов, выполнение которых отложено, в отдельном потоке, начиная это делать с момента начала загрузки кода. Движок V8 поддерживает потоковую передачу скриптов и кэширование кода. Это ведёт к тому, что парсинг завершается практически одновременно с завершением загрузки скрипта, что даёт примерно 10% уменьшение времени, необходимого на подготовку страниц к работе.

Этот байт-код, однако, теряется после того, как пользователь переходит на другую страницу. JavaScript-код обычно компилируется в байт-код при каждом посещении страницы. Для того чтобы улучшить ситуацию в Chrome 42 появилась поддержка кэширования байт-кода. Происходит это из-за того, что скомпилированный код сильно зависит от состояния и контекста системы во время компиляции. Это позволяет Chrome сэкономить примерно 40% времени на задачах парсинга и компиляции. Благодаря этому новшеству скомпилированный код хранится локально, в результате, когда пользователь возвращается на уже посещённую страницу, для подготовки её к работе не нужно выполнять загрузку, парсинг и компиляцию скриптов. Кроме того, в случае с мобильными устройствами, это ведёт к экономии заряда их аккумуляторов.

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

Он поддерживает систему мониторинга, которая подсчитывает количество вызовов определённого скрипта. Интерпретатор SpiderMonkey, используемый в FireFox, не занимается кэшированием всего подряд. На основе этих показателей определяются участки кода, которые нуждаются в оптимизации, то есть — те, на которые приходится максимальная нагрузка.

Так, Масей Стачовяк, ведущий разработчик браузера Safari, говорит, что Safari не занимается кэшированием скомпилированного байт-кода. Конечно, некоторые разработчики браузеров могут решить, что кэширование их продуктам и вовсе не нужно. Возможность кэширования рассматривалась, но она до сих пор не реализована, так как генерация кода занимает менее 2% общего времени выполнения программ.

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

Сокращение времени подготовки веб-приложений к работе

Как мы выяснили выше, хорошо было бы свести необходимость в парсинге скриптов к минимуму, но совсем избавиться от него нельзя, поэтому поговорим о том, как сократить время, необходимое для подготовки веб-приложений к работе. На самом деле, для этого можно сделать очень много всего. Например, можно минимизировать объём JS-кода, входящего в приложение. Код маленького объёма, готовящий страницу к работе, можно быстрее разобрать, да и его выполнение, вероятнее всего, займёт меньше времени, чем у кода более объёмного.

Так, например, паттерн PRPL продвигает именно такой подход к загрузке кода. Для того чтобы сократить объём кода, можно организовать загрузку на страницу только того, что ей действительно необходимо, а не некоего огромного куска кода, в который входит абсолютно всё, нужное для веб-проекта в целом. На самом деле, тут мы затронули большую тему, достойную отдельного материала. В качестве альтернативного варианта можно проверить зависимости и посмотреть, есть ли в них что-то избыточное, такое, что приводит лишь к неоправданному разрастанию кодовой базы. Вернёмся к парсингу.

Такие методики существуют. Итак, цель данного материала заключается в обсуждении методик, позволяющих веб-разработчику помочь парсеру быстрее делать его работу. Основываясь на этих предсказаниях, парсер либо полностью анализирует фрагмент кода, применяя алгоритм жадного синтаксического анализа (eager parsing), либо использует ленивый алгоритм синтаксического анализа (lazy parsing). Современные JS-парсеры используют эвристические алгоритмы для того, чтобы определить, понадобится ли выполнить некий фрагмент кода как можно скорее, или его нужно будет выполнить позже. В ходе этого процесса выполняется решение трёх основных задач: построение AST, создание иерархии областей видимости и поиск синтаксических ошибок. При полном анализе разбираются функции, которые нужно скомпилировать как можно скорее. Здесь не создаётся AST и не выполняется поиск ошибок. Ленивый анализ, с другой стороны, используется только для функций, которые пока не нуждаются в компиляции. При таком подходе лишь создаётся иерархия областей видимости, что позволяет сэкономить примерно половину времени в сравнении с обработкой функций, которые нужно выполнить как можно скорее.

Даже устаревшие браузеры вроде IE9 поддерживают подобные подходы к оптимизации, хотя, конечно, современные системы ушли далеко вперёд. На самом деле, концепция это не новая.

Предположим, у нас имеется следующий JS-код: Разберём пример, иллюстрирующий работу этих механизмов.

function foo() { function bar(x) { return x + 10; } function baz(x, y) { return x + y; } console.log(baz(100, 200));
}

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

  • Объявление функции bar, которая принимает один аргумент (x). Эта функция имеет одну команду возврата, она возвращает результат сложения x и 10.
  • Объявление функции baz, которая принимает два аргумента (x и y). У неё тоже одна команда возврата, возвращает она результат сложения x и y.
  • Выполнение вызова функции baz с двумя аргументами — 100 и 200.
  • Выполнение вызова функции console.log с одним аргументом, которым является значение, возвращённое ранее вызванной функцией.

Вот как это выглядит.

Результат разбора кода примера без применения оптимизации

Парсер видит объявление функции bar, объявление функции baz, вызов функции baz и вызов функции console.log. Поговорим о том, что здесь происходит. Речь идёт об анализе функции bar. Очевидно, разбирая этот фрагмент кода, парсер столкнётся с задачей, выполнение которой никак не отразится на результатах выполнения данной программы. Всё дело в том, что функция bar, как минимум, в представленном фрагменте кода, никогда не вызывается. Почему анализ этой функции не несёт практической пользы? Этот простой пример может показаться надуманным, но во множестве реальных приложений имеется большое количество функций, которые никогда не вызываются.

При этом настоящий парсинг этой функции производится тогда, когда в ней возникнет необходимость, непосредственно перед её выполнением. В подобной ситуации, вместо того, чтобы заниматься разбором функции bar, мы можем просто сделать запись о том, что она объявлена, но нигде не используется. Для такой функции не надо формировать абстрактное синтаксическое дерево, так как у системы нет сведений о том, что эту функцию планируется выполнять. Естественно, при выполнении ленивого синтаксического анализа нужно обнаружить тело функции и сделать запись о её объявлении, но на этом работа заканчивается. Если в двух словах, то отказ от разбора ненужных функций ведёт к значительному повышению производительности кода. Кроме того, не выделяется память из кучи, что обычно требует немалых системных ресурсов.

В результате, в предыдущем примере, настоящий парсер сформирует структуру, напоминающую следующую схему.

Результат разбора кода примера с оптимизацией

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

Здесь мы рассмотрели очень простой пример, а, на самом деле, при принятии решения о том, будет ли некий фрагмент кода востребован в программе, нужно анализировать и функции, и циклы, и условные операторы, и объекты. В вышеописанной концепции нет ничего сложного, но её практическая реализация — задача не из лёгких. В целом можно сказать, что парсеру нужно обработать и проанализировать абсолютно всё, что есть в программе.

Вот, например, весьма распространённый паттерн реализации модулей в JavaScript:

var myModule = (function() { // Вся логика модуля // Возврат объекта модуля
})();

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

Это, к сожалению, не самая хорошая идея. А что если бы парсеры всегда использовали ленивый синтаксический анализ? Парсер выполнит один проход ленивого синтаксического анализа, после чего тут же примется за полный анализ того, что нужно выполнить как можно скорее. Дело в том, что при таком подходе, если некий код надо выполнить как можно скорее, мы столкнёмся с замедлением работы системы. Это приведёт к примерно 50% замедлению в сравнении с подходом, когда парсер сразу приступает к полному разбору самого важного кода.

Оптимизация кода с учётом особенностей его разбора

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

Предположим, у нас имеется функция foo:

function foo(x) { return x * 10;
}

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

Для начала сохраним функцию в переменной:

var foo = function foo(x) { return x * 10;
};

Обратите внимание на то, что первоначальное имя функции, находящееся между ключевым словом function и открывающейся скобкой, мы оставили. Нельзя сказать, что это совершенно необходимо, но рекомендуется поступать именно так, так как, если при работе функции будет выдано исключение, в данных трассировки стека можно будет увидеть имя функции, а не <anonymous>.

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

var foo = (function foo(x) { return x * 10;
});

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

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

Кроме того, что не мене важно чем всё то, о чём уже было сказано, код, обработанный таким образом, будет сложнее читать и понимать. Программистам, наверняка, не захочется взваливать на себя всю эту дополнительную работу. Их основная цель заключается в оптимизации времени первоначальной загрузки исходного кода на JS. В этой ситуации нам на помощь готовы прийти специальные программные пакеты вроде Optimize.js. Они выполняют статический анализ кода и модифицируют его так, чтобы функции, которые нужно выполнить как можно скорее, были бы заключены в скобки, что приводит к тому, что браузер немедленно займётся их разбором и подготовкой к выполнению.

Итак, предположим, что мы программируем, ни о чём особо не задумываясь, и у нас имеется следующий фрагмент кода:

(function() { console.log('Hello, World!');
})();

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

!function(){console.log('Hello, World!')}();

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

Это означает, что парсер данную строчку пропустит, то, что она будет обработана с использованием ленивого синтаксического анализа. Минификатор убрал скобки, в которые было заключено объявление функции, поместив в начало строки восклицательный знак. Всё это приведёт к тому, что такой вот минифицированный код будет работать медленнее, чем его исходный вариант. Более того, для того, чтобы выполнить эту функцию, системе придётся выполнять полный анализ сразу после ленивого. Если обработать минифицированный код с помощью Optimize.js, на выходе получится следующее: Теперь пришло время вспомнить об инструментах вроде вышеупомянутого Optimize.js.

!(function(){console.log('Hello, World!')})();

Это уже больше похоже на то, что нам нужно. Мы получаем и минификацию, и оптимизацию кода. Текст программы занимает меньше места на диске, а парсеру понятно, какие фрагменты нужно разбирать полностью и как можно скорее, а какие — используя методику ленивого синтаксического анализа.

Предварительная компиляция

Как видите, подготовка JS-кода к работе — дело, требующее немалых системных ресурсов. Почему бы не выполнять всё это на сервере? В конце концов, гораздо лучше один раз подготовить программу к выполнению и передавать то, что получилось, клиентам, нежели принуждать каждую клиентскую систему каждый раз обрабатывать исходный код. На самом деле, эта возможность сейчас обсуждается, в частности, вопрос заключается в том, должны ли браузерные JS-движки предлагать механизмы выполнения предварительно скомпилированных скриптов, чтобы освободить браузеры от задач по подготовке кода к выполнению. В целом, идея заключается в том, чтобы у нас был некий серверный инструмент, умеющий генерировать байт-код, который достаточно передать клиенту по сети и выполнить. Это даст значительное сокращение времени подготовки веб-страниц к работе. И хотя выглядит подобный механизм довольно соблазнительно, на самом деле, не всё так просто. Подготовка кода к работе на сервере может произвести обратный эффект, так как объём передаваемых данных, вероятно, возрастёт, может возникнуть необходимость в подписывании кода и в его проверке для целей обеспечения безопасности. Кроме того, JS-движки развиваются в уже сформировавшемся русле, в частности, команда разработчиков V8 работает над внутренними механизмами движка, направленными на то, чтобы избавиться от повторного парсинга. Подобные подходы к оптимизации на стороне клиента могут сделать предварительную компиляцию на сервере уже не столь привлекательной.

Советы по оптимизации

Вот несколько рекомендаций, которыми вы можете воспользоваться для оптимизации веб-приложений:

  • Проверьте зависимости проекта. Избавьтесь от всего ненужного.
  • Разделите код на небольшие фрагменты вместо того, чтобы складывать его в один большой файл.
  • Откладывайте, в тех ситуациях, когда это возможно, загрузку JS-скриптов. При обработке текущего маршрута пользователю можно выдавать только тот код, который необходим для нормальной работы, и ничего лишнего.
  • Используйте инструменты разработчика и средства вроде DeviceTiming для того, чтобы находить узкие места своих проектов.
  • Используйте средства вроде Optimize.js для того, чтобы помочь парсерам определиться с тем, какие фрагменты кода им нужно обработать как можно скорее.

Итоги

Автор этого материала говорит, что в его компании, которая занимается разработкой системы SessionStack, предназначенной для мониторинга и записи того, что происходит на веб-страницах, вышеописанные приёмы оптимизации начали использовать сравнительно недавно. Это позволяет им сделать так, чтобы код приложения быстрее загружался и готовился к работе. Чем быстрее это происходит — тем приятнее пользователям будет работать с системой. Пожалуй, обеспечение удобства работы пользователей — это одна из задач, которую стремятся решить разработчики любого веб-проекта, и то, о чём шла речь в этом материале, вполне способно помочь в решении этой задачи.

Уважаемые читатели! Оптимизируете ли вы ваши веб-проекты с учётом скорости загрузки и разбора их JavaScript-кода?

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть