Dan Abramov рассказывает о Prepack и призывает вас начать коммитить в этот проект. Почему бы и нет? Инструмент разработан Facebook, следовательно, при годных коммитах можно устроиться в FB на работу.
Примечание:
Когда это руководство будет более полным, планируется переместить его в документацию Prepack.
Сейчас я выложил его как gist, чтобы собрать первый фидбэк.
Легкое введение в Prepack (Часть 1)
Если вы разрабатываете приложения на JavaScript, вы должно быть уже знакомы с инструментами, которые компилируют JavaScript код в эквивалентный JavaScript код:
- Babel позволяет использовать новейшие возможности JavaScript и преобразовывает код в подходящий для более старых движков.
-
Uglify — с его помощью можно писать читаемый код, и после преобразования получать минифицированный код, который работает так же, но меньше весит.
Prepack — это еще один инструмент для преобразования JavaScript кода. Но, в отличие от Babel и Uglify, Prepack нацелен не на новые возможности языка и не на размер кода.
Вместо этого с Prepack можно писать привычный JavaScript код и получать на выходе код, который работает быстрее.
Если это звучит захватывающе, читайте далее о том, как работает Prepack и как вы можете его улучшить.
Что в этом руководстве?
Лично я, когда наконец понял, на что способен Prepack, был воодушевлен. Я подумал, что в будущем Prepack сможет решить много проблем, с которыми я сталкивался при разработке крупных JavaScript приложений. Я захотел рассказать об этом, чтобы другие люди тоже вдохновились этим.
Однако сначала Prepack может показаться слишком пугающим для участия в его разработке. Его исходный код содержит много терминов, с которыми я не был знаком, и мне потребовалось некоторое время, чтобы понять, что делает Prepack. В его коде используется устоявшаяся Computer Science терминология, но оказалось, что многие из этих концепций звучат более сложно, чем то, чем они на самом деле являются.
Я написал это руководство для тех JavaScript разработчиков, у кого нет Computer Science бэкграунда, но кто вдохновился обещаниями Prepack и хочет воплотить их в реальность.
В этом руководстве вы найдете общий обзор того, как работает Prepack, и как начать участвовать в его разработке. Многие концепции Prepack сопоставимы с инструментами, которые вы используете каждый день в JavaScript коде: объекты, свойства, условия и циклы. Даже если вы не можете использовать Prepack для ваших проектов уже сейчас, возможно вы обнаружите, что работа над Prepack улучшает понимание кода, который вы пишите каждый день.
Перед тем, как мы углубимся 🚧
Обратите внимание, что Prepack еще не готов для широкого пользования. Вы не можете просто подключить его в свой проект и ожидать, что он будет работать, как Babel и Uglify. Вместо этого можете воспринимать Prepack как продолжающийся амбициозный эксперимент, в котором вы можете принять участие и который в будущем возможно станет полезным для вас. Из-за его масштаба по-прежнему есть много возможностей для его улучшения.
Это не значит, что Prepack не работает. Но сейчас он направлен на очень узкий набор сценариев, и возможно у него слишком много багов, чтобы большинство было готово использовать его в продакшн. Хорошие новости в том, что вы можете помочь Prepack поддерживать больше use case’ов и помогать исправлять баги в них. Это руководство поможет вам начать.
Основы Prepack
Давайте вернемся к миссии Prepack, которую я упоминал ранее:
С помощью Prepack можно писать привычный JavaScript код и получать на выходе код, который работает быстрее.
Почему бы нам просто не писать изначально более быстрый код? Мы, конечно, можем попытаться и должны это делать, когда можем. Однако во многих приложениях помимо узких мест, выявленных при проектировании, неочевидно, что оптимизировать в будущем.
Часто нет одного участка кода, из-за которого программа работает медленно, наоборот, таких участков может быть множество. Возможности языка, которые предоставляют разделение задач, такие как вызов функций, размещение объектов и различные абстракции, уменьшают производительность. Однако, если убрать их из исходного кода, он станет неподдерживаемым, и нет легкого решения для микрооптимизации таких случаев. Несмотря на годы оптимизации движок JavaScript ограничен в своих возможностях, особенно когда инициализируется код, который выполнится только один раз.
Самый верный способ улучшить производительность — делать меньше работы. Prepack доводит этот принцип до логического завершения: он исполняет программу во время сборки, узнает, что код будет делать, и затем генерирует эквивалентный код, который делает то же самое с меньшими вычислениями.
Это звучит волшебно, поэтому давайте рассмотрим несколько примеров и увидим преимущества и ограничения Prepack. Мы будем пользоваться Prepack REPL, где можно преобразовывать код с помощью Prepack в режиме онлайн.
Два способа вычислить 2 + 2
Давайте начнем с этого примера:
На выходе:
В самом деле выполнение обоих вариантов дает одинаковый наблюдаемый эффект: значение 4
записано в глобальную переменную answer
. Однако Prepack версия не содержит код, который вычисляет 2 + 2
. Вместо этого Prepack исполнил 2 + 2
во время компиляции и «сериализовал» (причудливый способ сказать «записал» или «выдал» ) конечную операцию присваивания.
Само по себе это не очень впечатляюще: например, Google Closure Compiler также может превратить 2 + 2
в 4
. Такая оптимизация называется «cвёртка констант» (constant folding). Что отличает Prepack, так это то, что он может исполнять произвольный JS код, а не только делать свертку констант или похожие ограниченные оптимизации. Prepack также имеет свои ограничения, о которых мы поговорим немного позже.
Рассмотрим этот намеренно бестолковый и невероятно запутанный способ вычисления 2 + 2
:
Хотя мы не рекомендуем так писать код для сложения двух чисел, вы можете увидеть, что Prepack на выходе дает то же самое:
В обоих случаях Prepack исполнил код во время сборки для вычисления наблюдаемых «эффектов» (изменений) окружения (например, запись в глобальную переменную answer
значения 4
), а затем «сериализовал» (записал) код, который производит те же эффекты, но за минимальное время исполнения.
На высшем уровне (higer-level) такая же картина верна для любого кода, который прогоняется через Prepack.
Примечание: Как Prepack исполняет мой код?
«Выполнение» кода во время сборки звучит пугающе. Вы бы не хотели, чтобы Prepack удалил файл из вашей системы только потому, что в вашем коде содержится вызов fs.unlink()
.
Мы должны прояснить, что Prepack не просто вычисляет входящий код в среде Node. Вместо этого Prepack включает в себя полную реализацию интерпретатора JavaScript, поэтому он может исполнять произвольный код в «пустом» изолированном окружении. По умолчанию он не поддерживает примитивы Node такие, как require()
, module
, или примитивы браузера как, например, document
. Мы вернемся к этим ограничениям позже.
Однако это не означает, что постройка моста между средами Node и Prepack невозможна. По факту это может быть интересной идеей для изучения в будущем. Возможно ты будешь одним из тех, кто будет этим заниматься?
Дерево падает в лесу
Возможно вы слышали этот философский вопрос:
Слышен ли звук падающего дерева в лесу, если рядом никого нет?
Оказывается это напрямую относится к тому, что может и не может делать Prepack.
Рассмотрите это небольшое изменение первого примера:
На выходе, возможно неожиданно, содержатся определения для x
и y
тоже:
Это потому что Prepack воспринимает входящий код как скрипт (а не как модуль). Объявление var
вне функции становится глобальной переменной, таким образом с точки зрения
Prepack’а это то же самое, как если бы мы напрямую присвоили бы их в глобальные переменные:
Поэтому Prepack сохранил x
and y
на выходе. Не забывайте, что целью Prepack является создание эквивалентного кода, и он не защищает вас от подводных камней JavaScript.
Самый легкий способ уберечься от такой ошибки — всегда оборачивать код для Prepack в немедленно вызываемые функции (IIFE), и явно записывать в глобальный объект то, что вам нужно оставить глобальным:
Этот код возвращает ожидаемый результат:
Вот еще один потенциально запутанный пример:
Prepack REPL выдаст полезное предупреждение на это:
Здесь случилось следующее: хотя мы произвели некоторые вычисления, это никак не повлияло на среду.. Если какие то другие скрипты запущены позже, у них не было возможности определить, выполнялся ли наш код вообще. Поэтому не нужно сериализовывать никакие из этих значений.
Еще раз, для исправления этого нам нужно явно указать, что мы хотим сохранить в глобальном объекте, и позволить Prepack удалить остальное.
Концептуально это может напомнить вам сборку мусора: объекты, которые доступны из глобального объекта, должны остаться «в живых» (или, в случае с Prepack, быть сериализованы). Есть также и другие виды эффектов, которые Prepack поддерживает помимо установки глобальных свойств, но мы рассмотрим их позже.
Остаточная куча
Теперь мы знаем достаточно для того, чтобы примерно описать, как работает Prepack.
Поскольку Prepack интерпретирует входящий код, он строит внутреннее представление всех объектов, используемых в программе. Для каждого JavaScript значения (объекта, функции или числа) есть внутренний объект Prepack, который содержит информацию о нем. Кодовая база Prepack содержит такие классы, как ObjectValue
, FunctionValue
, NumberValue
, и даже UndefinedValue
и NullValue
.
Prepack также отслеживет все эффекты (такие как записывание в глобальный объект), которыми код мог бы повлиять на окружение. Для того, чтобы воспроизвести все эффекты на выходе, Prepack находит все значения, которые все еще доступны из глобального объекта после завершения выполнения кода. В приведенном выше примере global.answer
считается доступным, потому что в отличие от локальных переменных x
и y
, внешний код может читать global.answer
в будущем. Вот почему было бы небезопасно вырезать global.answer
от кода на выходе, но безопасно убрать x
и y
.
Все значения, доступные из глобальных объектов (и, следовательно, потенциально влияющие на код, который выполняется позже), совместно называются «остаточная куча». Это название звучит сложнее, чем сама идея. «Остаточная куча» является частью «кучи» (все объекты, созданные исполняемым кодом), которая остается как «остаток» (т.е. остается в выходных данных) после завершения выполнения кода. Если мы снимем наши Computer Science шляпы, мы можем назвать это «остатки».
Сериализатор
Итак, как Prepack преобразует код?
После того, как Prepack отметил все доступные значения как находящиеся в остаточной куче, он затем запускает сериализатор. Задача сериализатора состоит в том, чтобы превратить представления объекта Prepack для JavaScript объектов, функций и других значений в остаточной куче в выходной код.
Если вы знакомы с JSON.stringify()
, концептуально вы можете думать, что сериализатор Prepack делает нечто подобное. Однако JSON.stringify()
имеет шикарную возможность избегать сложных случаев, таких как циклические ссылки между объектами:
Очень часто программы на JavaScript содержат циклицеские ссылки между объектами, поэтому сериализатор Prepack обязан поддерживать все такие случаи и выдавать правильный аналогичный код для перестройки этих объектов. Так что для такого ввода:
Prepack генерирует подобный код:
Обратите внимание, что порядок присвоения другой (во входящем коде сначала было a
, а на выходе код начинается с b
). Это потому, что в этом случае порядок присвоения не имеет значения. Это иллюстрирует главный принцип работы Prepack:
Prepack не преобразовывает входящий код. Вместо этого он исполняет код, находит все значения в остаточной куче и сериализует эти значения и эффекты, которые их используют, в JavaScript код на выходе.
Примечание: Плохо ли класть что-то в глобальное окружение?
Приведенные выше примеры могут заставить вас задаться вопросом: не является ли плохой практикой записывать значения в глобальное окружение? Как правило это происходит в продакшн коде, но если вы используете какой-то экспериментальный интерпретатор JavaScript, который не готов к работе в продакшн, то у вас могут возникнуть бОльшие проблемы.
В CommonJS-подобной среде есть ограниченная поддержка Prepack c module.exports
, но она в настоящее время узкоспециализированная (и, кстати, реализуется через глобальное окружение). В любом случае, пока что это не очень важно, потому что это принципиально не меняет то, как выполняется код, и станет более актуальным только тогда, когда Prepack будет более готов к интеграции с другими инструментами.
Остаточные функции
Предположим, мы хотели добавить инкапсуляцию в наш код и обернули вычисление 2 + 2
в функцию:
Если вы попробуете воспроизвести это, вы возможно будете удивлены результатом:
Выглядит как будто Prepack не оптимизировал наше вычисление! Почему?
По умолчанию, Prepack оптимизирует только «инициализационный путь» (код, который выполняется немедленно).
С точки зрения Prepack, программа закончена тогда, когда Prepack выполнил все инструкции в ней. Эффект программы — это запись в глобальную переменную getAnswer
функции, которую мы написали, что Prepack и сделал. Следовательно, работа окончена.
Если бы мы вызвали getAnswer()
перед выходом из программы, то Prepack бы исполнил ее. Останется ли реализация getAnswer()
на выходе, зависит от того, доступна ли функция из глобального объекта (и, соответственно, безопасно ли от нее избавиться). Функции, которые включаются в код на выходе, называются «остаточными функциями» (они являются остатками на выходе).
По умолчанию Prepack не пытается выполнить или оптимизировать остаточные функции. В целом это небезопасно. К тому времени, когда остаточная функция вызывается из внешнего кода, глобальные объекты рантайма JavaScript, такие как Object.prototype
, и объекты, созданные во входящем коде, могли быть изменены без ведома Prepack. Затем Prepack должен будет либо использовать потенциально устаревшие значения, взятые из остаточной кучи, отличающиеся по поведению от исходного кода, либо всегда предполагать, что все что угодно может быть изменено, делая оптимизацию слишком сложной. Нежелательно чтобы остаточные функции оставались нетронутыми.
Однако есть экспериментальный режим, который позволяет выбрать оптимизацию определенных функций, и мы рассмотрим его далее.
Компромисс скорости и размера
Рассмотрим этот пример:
Prepack выдаст следующий код, сохранив getAnswer()
как остаточную функцию в коде на выходе:
Обратите внимание, что getAnswer()
не была оптимизирована, поскольку это остаточная функция и она не выполняется во время инициализации. Оператор «+» все еще здесь. Единственная причина, почему мы видим 2
и 2
вместо x
и y
в том, что они не меняются на протяжении программы, и поэтому Prepack рассматривает их как константы.
Но что если мы сгенерировали функцию динамически и потом добавили ее в глобальное окружение. Например:
Здесь мы создали несколько объектов, и каждый из них содержит функцию getColor()
, которая замыкает в себе определенное значение, передаваемое в makeCar()
. Prepack выдаст следующий код:
Заметьте, как на выходе Prepack не сохранил абстракцию makeCar()
. Вместо этого он сделал вызовы makeCar()
и сериализовал вернувшиеся функции. Вот почему у нас на выходе много функций getColor()
, по одной на объект Car.
Этот пример также демонстрирует, что Prepack повышает производительность выполнения кода, потенциально ценой увеличения размера. Для JavaScript движка исполнять код, сгенерированный Prepack, быстрее, потому что ему не нужно делать вызовы функций и инициализировать все вложенные замыкания. Но взамен сгенерированный код может быть больше, чем входящий, иногда даже слишком.
Хотя это разрастание кода может помочь найти области кода, которые делают слишком дорогим метапрограммирование на этапе инициализации, это затрудняет использование Prepack в проектах, где размер бандла имеет значение (таких как веб). Сегодня самый простой способ обойти разрастание кода — это отложить исполнение такого кода и переместить его в саму остаточную функцию, таким образом убирая его вовне исполнения Prepack. Конечно, в этом случае Prepack не будет оптимизировать этот участок кода. В долгосрочной перспективе Prepack может предложить лучшую эвристику и контроль над компромиссом скорости и размера.
Ленивая инициализация замыканий
В предыдущем примере значения color
были просто встроены в остаточные функции, поскольку они были постоянными. Но что если значение color
в замыкании будет меняться на протяжении времени? Рассмотрим это пример с новым методом paint(newColor)
:
Здесь Prepack не может просто опустить функции getColor()
такими инструкциями, как return "red"
, потому что внешний код может поменять переменную color со временем при вызове paint(newColor)
.
Здесь сгенерированный код для этого сценария:
Выглядит сложно! Давайте посмотрим, что здесь происходит.
Заметьте: это абсолютно нормально, если вы не понимаете этот раздел с первого раза. Я понял, что происходит, только когда я начал писать этот раздел.
Было бы проще начать чтение с конца и продвигаться вверх. Прежде всего, мы можем видеть, что Prepack все еще не оставляет makeCar()
на месте и соединяет объекты вместе вручную для того, чтобы избежать вызовов функции и излишних созданий замыканий. Каждый экземпляр функции разный:
Откуда появляются эти функции? Prepack объявляет их выше:
Мы можем увидеть, что связанные функции ($_0
and $_1
) соответствуют методам car (getColor
and paint
соответственно). Prepack переиспользует те же имлементации для всех их экземпляров.
Однако тем функциям нужно знать, какой из трех независимых изменяемых цветов должен быть прочитан и записан. Фактически Prepack должен эмулировать JavaScript замыкания без создания вложенных функций.
Для решения этой проблемы аргумент (0
, 1
и 2
) функции bind()
подсказывает, какой из цветов должен попасть в функцию. В этом примере номер цвета 0
изначально red
, цвет номер 1
— green
, а цвет номер 2
был blue
. Текущие цвета сохранены в массиве и отложенно инициализированы функцией:
В коде выше __scope_0
— это массив, где Prepack хранит соответствие индекса цвета текущему значению цвета. А __scope_1
— это функция, которая записывает изначальный цвет в массив по переданному индексу.
В итоге все, что делает вызов getColor()
, — это чтение текущего значения цвета из массива цветов. Если массив еще не существует, происходит отложенная инициализация его путем вызова функции, которую мы только что описали:
Аналогично paint()
убеждается, что массив существует, а затем записывает в него.
Почему у нас есть [0]
в обоих местах и почему мы записываем ["red"]
в массив вместо сохранения цветов напрямую? Каждое замыкание может содержать более одной изменяемой переменной, поэтому Prepack использует дополнительный уровень вложенности массива для ссылки на них. В нашем примере color
была единственной изменяемой переменной в том замыкании, поэтому Prepack использовал массив из одного элемента для ее хранения.
Вы можете убедиться, что сгенерированный код достаточно длинный. Он будет лучше после минификации. В настоящее время эта часть сериализатора больше ориентирована на корректность, чем на наиболее эффективный вывод.
Скорее всего, код на выходе может быть улучшен в каждом конкретном случае, поэтому не стесняйтесь создавать issues, если вы видите возможности для оптимизации. В начале Prepack не создавал код, который работал бы с замыканиями отложенно. Вместо этого все замкнутые переменные были подняты и инициализированы в сгенерированном глобальном коде. Опять же, это компромисс скорости/размера кода, который может и будет настроен с течением времени.
Окружение имеет значение
На этом этапе у вас может возникнуть соблазн попробовать скопировать и вставить какой-нибудь свой код в Prepack REPL. Тем не менее, вы можете вскоре обнаружить, что такие базовые вещи, как window
или document
в браузере, или require
в Node, не работают ожидаемым образом.
Например, пакет React DOM содержит такой код, который Prepack не может преобразовать:
Сообщение об ошибке будет следующее:
Большинство описаний кодов об ошибке Prepack есть на соответствующих страницах Wiki. Например, вот страница для PP0004
. (Другая ошибка PP0001
из предыдущей системы ошибок, из которой вы можете помочь мигрировать.)
Так почему этот код не работает? Чтобы ответить на этот вопрос, нам нужно вспомнить, что заставляет Prepack работать в первую очередь. Чтобы выполнить код, Prepack обычно должен знать, чему равны различные значения. Но некоторые вещи известны только во время выполнения кода.
Prepack не может знать, в каком браузере код будет выполняться в будущем, поэтому он не может быть уверен, является ли безопасным применять оператор in
к объекту document
, или это выбросит исключение (и таким образом возьмется потенциально другой путь, если есть try
/ catch
выше).
Звучит довольно мрачно. В конце концов, это стандартно для кода инициализации считывать что-то из окружения, которое неизвестно во время сборки. Есть два способа обойти это.
Один из способов — предварительно прогонять через Prepack только тот код, который не зависит от внешних данных, и поместить любые проверки окружения за пределы кода, обрабатываемого Prepack. Это может быть разумной стратегией, если такой код легко изолировать.
Другой способ решить эту проблему заключается в использовании самой мощной возможности Prepack : абстрактные значения (abstract values).
Мы рассмотрим абстрактные значения подробно в следующих разделах, но суть в том, что в ограниченном наборе случаев Prepack может выполнять код, даже если он не знает точных значений некоторых выражений, и вы можете дать ему некоторые дополнительные подсказки для таких вещей, как Node или Browser API или другие неизвестные входящие.
Продолжение следует
Мы рассмотрели основы того, как работает Prepack, но мы еще не рассмотрели его самые интересные возможности:
- Ручная оптимизация выбранных остаточных функций.
- Выполнение кода, даже если некоторые значения неизвестны.
- Как Prepack «соединяет» поток выполнения функции.
- Использование Prepack для просмотра всех значений, которые может принимать переменная.
- Экспериментальный режим компиляции React.
- Проверка Prepack локально и отладка его.
Мы рассмотрим эти темы в следующих статьях.
Перевод — Юлия Аюбова
Оригинал — Dan Abramov, A gentle introduction to Prepack