Про WebAssembly в наше время слышали, я думаю, практически все. Если Вы не слышали, то на Хабре есть замечательный вводный материал об этой технологии.
Другое дело, что очень часто можно встретить комментарии вида “Ура, теперь будем писать фронтенд на C++!”, “Давайте перепишем React на Rust” и прочее, прочее, прочее…
Интервью с Бренданом Айком очень хорошо раскрывает идею WebAssembly и ее предназначение: WASM это не полная замена JS, а лишь технология, позволяющая писать критичные к ресурсам модули и компилировать их в переносимый байт-код с линейной моделью памяти и статической типизацией: такой подход позволяет ускорить производительность или упростить перенос существующего кода для веб-приложений, работающих с мультимедиа, онлайн-игр, и прочих “тяжелых” вещей.
При большом желании можно реализовать и GUI, например, на WASM портирована библиотека imgui, существуют подвижки в портировании Qt на WASM (раз и два).
Но чаще всего озвучивается простой вопрос:
Пока что категоричный ответ звучит как “Нет, нельзя”, более точный и правильный же звучит как “Можно, с использованием функций Javascript”. И эта статья, по сути дела, является рассказом о результатах моего маленького исследования о том, как это можно делать максимально удобно и эффективно.
Посмотрим, как вообще происходит генерация элементов страницы и работа с ними из скриптов на примере web-движка Blink (Chromium) и JS-движка V8. По сути дела, почти любой DOM-элемент внутри Blink имеет своё воплощение в виде C++ объекта, унаследованного от HTMLElement, унаследованного от Element, унаследованного от ContainerNode, унаследованного от Node… на самом деле это далеко не вся цепочка, но в нашем случае это не важно. Например, для тега при парсинге HTML и построении дерева будет создан объект класса HTMLImageElement:
Для контроля за временем жизни объектов и их удаления, в старых версиях Blink использовались умные указатели с подсчетом ссылок, в современных версиях используется Garbage Collector под названием Oilpan.
Для доступа к элементам страницы из JavaScript, объект описывается в виде IDL, чтобы специфицировать, какие именно поля и методы объекта будут доступны в JavaScript:
после чего мы можем работать с ними из JavaScript-кода. У JS-движка V8 также есть свой Garbage Collector, а с C++ объекты Blink заворачиваются в специальные wrapper'ы, которые в терминологии V8 называются Template Objects. В итоге за временем жизни объектов страницы следят сборщики мусора Blink и V8.
Теперь представим, как в это дело вписываются WebAssembly-модули. На данный момент, то, что происходит внутри WASM для браузера «темный лес». К примеру, если мы возьмем элемент из документа, передадим указатель в WASM-модуль, сохранив там ссылку на него, а потом вызовем для него removeChild, то по мнению Blink на объект больше не будет указывать ни одна ссылка и объект должен быть удален — ведь окружающая среда не знает, что внутри WASM указатель на элемент по-прежнему хранится. К чему может привести такая ситуация догадаться, думаю, не трудно. И это только один из примеров.
Работа с garbage-collected объектами есть в Roadmap развития WebAssembly и на github заведен специальный Issue по этому вопросу, плюс есть документ с подробностями предложений по реализации всего этого.
Итак, код WebAssembly полностью изолирован в своей “песочнице”, и на сегодняшний день передать указатель на какой-нибудь объект DOM-дерева нормальным способом в него невозможно, вызвать какой-либо метод напрямую аналогично нельзя. Единственным корректным способом взаимодействовать с какими-либо объектами DOM-дерева или использовать любой другой браузерный API, является написание JS-функций, передача их в поле imports модуля WebAssmebly и вызов из WASM-кода.
helloworld.c:
helloworld.js:
В сгенерированном байт-коде все просто и прозрачно: импортируется внешняя функция, в стек кладется аргумент, производится вызов функции, в стеке сохраняется результат сложения.
Проверить на практике как работает все это дело можно используя WasmFiddle: https://wasdk.github.io/WasmFiddle/?l7d05
Казалось бы, все хорошо, но при усложнении задачи возникают проблемы. А что если нам надо передать из WASM-кода не число, а строку (при том, что в WebAssembly поддерживаются только 32- и 64-битные целые и 32- и 64-битные числа с плавающей запятой)? А что если нам нужно выполнять много очень разных манипуляций с DOM и вызовов браузерного API, и крайне неудобно в каждом случае писать отдельную JS-функцию?
Emscripten изначально был разработан как LLVM-бэкенд для компиляции в asm.js. Кроме непосредственно компиляции в asm.js и WASM, он также содержит “обертки”, эмулирующие функционал различных библиотек (libc, libcxx, OpenGL, SDL и д.р.) через доступный в браузере API, и свой набор вспомогательных функций, облегчающий портирование приложений и взаимодействие WASM и JS кода.
Самый простой пример. Как известно, аргументами и результатами при вызове функций WASM-модуля или из него, могут быть только i32, i64, f32, f64. Модуль WebAssembly имеет линейную память, которая может быть отображена в JS как Int8Array, Int16Array и т.д. Следовательно, для того, чтобы получить значение какого-либо нестандартного типа (строки, массива, и т.п.) из WASM в JS, мы можем положить его в адресном прострастве WASM-модуля, передать «наружу» указатель, а уже в JS-коде вытащить из массива нужные байты и преобразовать их в требуемый объект (например, строки в JS хранятся в UTF-16). При передаче данных «внутрь» WASM-модуля мы должны наоборот «снаружи» положить их в массив памяти по определенному адресу, и уже потом использовать этот адрес в C/C++ коде. Для этих целей в Emscripten есть большой набор вспомогательных функций. Так, кроме getValue() и setValue() (чтение и запись значений “кучи” WASM-приложения по указателям), существует, к примеру, функция Pointer_stringify(), преобразовывающая C-строки в строковые объекты JavaScript.
Другой удобной фишкой является возможность инлайнинга javascript кода прямо в C++ код. Всё остальное компилятор сделает за нас.
После компиляции мы получаем .wasm-файл со скомпилированным байткодом и .js-файл, содержащий код запуска wasm-модуля и огромное количество различных вспомогательных функций.
Непосредственно наш заинлайненный макросом EM_ASM JS-код превратился в .js-файле в следущую конструкцию:
В байт-коде же мы имеем практически все то же самое, что и в предыдущем примере, только при вызове функции на стек также кладется число 0 (идентификатор функции в массиве ASM_CONSTS), а также указатель на строковую константу (char*) в адресном пространстве WASM-модуля, равный в нашем случае 1024. Метод Pointer_stringify() в javascript-коде извлекает данные из “кучи”, представленной в JS в виде Uint8Array, и выполняет преобразование из массива UTF8 в String-объект.
При более внимательном рассмотрении немного смущает тот факт, что строковая константа (char*), расположенная по адресу 1024, почему-то содержит не только текст “hello world” с нулевым байтом, но и дубль заинлайненного JS-кода. Объяснить причину появления этого внутри скомпилированного wasm-файла я сходу не могу, буду признателен, если кто-то поделится предположениями в комментариях.
В любом случае, напрашивается вывод, что вызов JavaScript-функций из WASM-кода – это не самое быстрое занятие в плане производительности. Как минимум время будут отнимать накладные расходы на взаимодействие WASM-кода и JS-интерпретатора, преобразование типов при передаче аргументов, и многое другое.
Читая статьи и изучая документацию на различные библиотеки, мне на глаза попался компилятор Cheerp, генерирующий WASM код из C/C++, и по громкому заверению на лендинге на официальном сайте, обеспечивающим “no-overhead access to HTML5 DOM”. Мой внутренний скептик, однако, говорил, что магии не бывает. Для начала пробуем скомпилировать простейший пример из документации:
На выходе мы получаем .wasm-файл, беглый просмотр которого говорит нам, что в нем нету data-секции со строковой константой.
Заглядываем в JS:
Честно говоря, вообще не совсем понятно, зачем в подобном случае нужно генерировать WASM-модуль, поскольку в нем не происходит ровно ничего кроме одного вызова внешней функции, а вся логика разместалась в JS-коде. Ну да ладно.
Интересной особенностью компилятора являются биндинги ко всем стандартным DOM-объектам в C++ (судя по написанному в документации, полученные путем авто-генерации кода из IDL), позволяющие писать C++ код, “на прямую” манипулирующий нужными объектами:
Посмотрим, что же у нас получилось после компиляции…
Кажется, я ошибался насчет магии. Строковые константы у нас оказались в JavaScript-коде в Uint8-массивах, и при запуске скрипта они преобразуются в String чередой посимвольных вызовов String.concat(). При том, что те же самые строки лежат чуть выше прямым текстом в JavaScript-коде. Инкрементирование test производится в WASM-коде; в функции, устанавливающей текстовое содержимое DOM-элемента можно встретить чудесное “a = a” и вызов геттера textContent без использования его результата; проверка переменной на четность в результате работы оптимизатора выродилась в выносящее мозг выражение “b + 1 >>> 0 < 3” (да, именно так, с битовым сдвигом на 0 позиций).
Можно ли это назвать “zero overhead DOM-manipulations”? Даже если учесть, что по сути дела все равно все манипуляции выполняются точно также через JS (сложно было ожидать чего-то другого, на самом деле), в лучшем случае можно говорить про “zero overhead” по сравнению с чистым JS, и странные танцы с бубном вокруг строк производительности явно не добавят, как и радости при отладке всего этого.
Как говорится, не верьте рекламе. Но стоит отметить, что проект все-таки активно развивается. Когда я забыл выставить атрибут [[cheerp::genericjs]] для функции void domOutput(int a), при компиляции с таргетом “wasm” компилятор просто упал с SIGSEGV. Я завел Issue на github разработчиков об этой проблеме, на следущий же день мне объяснили, в чем ошибка, и буквально через неделю в master-ветке появилось исправление этой проблемы. Возможно, стоит понаблюдать за Cheerp в дальнейшей перспективе.
Говоря о компиляторах и библиотеках, созданных для взаимодействия между WASM, JS и WebAPI, нельзя не упомянуть Stdweb для Rust.
Она позволяет инлайнить JS-код в код на Rust с поддержкой замыканий и предоставляет обертки для DOM-объектов и браузерных API, максимально приближенные к тому, что привычно видеть в JS:
В поставку сразу включены примеры реализации разных вещей на Rust/WASM, из которых наибольший интерес представляет TodoMVC. Её можно запустить через cargo-web командой
cargo web start –target-webasm-emscripten
в результате чего мы получаем веб-сервер на 8000 порту с нашим приложением.
После компиляции мы в .js-файле видим те же самые функции-хелперы Emscripten, но гораздо больший интерес (помятуя о том, что было в предыдущем пункте) представляет то, как именно реализован вызов JS-кода из WASM-модуля и работа с объектами.
Точно такой же, как и во втором примере (компиляция C++ с помощью Emscripten) массив ASM_CONSTS заполнен функциями примерно такого вида:
Иными словами, к примеру,
будет реализована с помощью хелперов
причем, как можно заметить, оно не транслировано в один целостный JavaScript-метод, а между WASM и JS-кодом постоянно передаются “указатели” на используемые объекты. Учитывая, что WASM-код не может работать с JS-объектами напрямую, этот трюк выполнен довольно интересным образом, и посмотреть на реализацию можно в исходниках stdweb.
При передаче JS/DOM-объекта в WASM, объект добавляется в контейнеры “ключ-значение” в JS, хранящие соответствия вида “JS объект < > уникальный RefId” и наоборот, где уникальный RefId представляет собой по сути дела автоинкреметный номер:
При этом проверяется, что этот объект еще ни разу не передавался (в противном случае будет создана не новая запись, а увеличен счетчик ссылок). В память WASM-приложения записывается идентификатор типа объекта (например, 11 для Object, 12 для Array), после чего следует запись RefId объекта. При передаче объекта в обратную сторону из map’а просто извлекается нужный объект по уникальному ID и используется.
Без тестов невозможно точно сказать, насколько сильно вызовы JS-функций на каждый чих из WASM, преобразования типов (и конверсия строк) вкупе с постоянными поисками объектов в таблицах замедлят работу, но в целом, подобный подход к взаимодействию между “мирами” мне кажется гораздо более красивым, чем непонятная мешанина кода из предыдущих примеров.
Ну и самое вкусное напоследок: asm-dom. Это библиотека виртуального DOM (подробнее про концепцию Virtual DOM можно прочитать в статье на Хабре), вдохновленная JavaScript VDOM-библиотекой Snabbdom и предназначенная для разработки SPA (Single-page applications) на C++/WebAssembly.
Код описания элементов страницы выглядит примерно так:
Также существует gccx, конвертер, генерирующий код типа приведенного выше из CPX, который, в свою очередь, является аналогом JSX, многим известного по ReactJS, позволяющий описывать компоненты прямо внутри C++-кода:
“Перегонка” VirtualDOM в реальный DOM, как и взаимодействие между WASM-кодом и Web API, происходит либо через генерацию HTML и установки свойств innerHTML у объектов, или же аналогично прошлому примеру:
Также на Github проекта есть ссылка на тесты производительности по сравнению с JS-ной VDOM библиотекой Snabbdom, по которым видно, что в некоторых тест-кейсах WASM-вариант проигрывает JS, в некоторых немного его обгоняет, и только в одном тесте при запуске в Firefox видно серьезное ускорение. В принципе, подобные результаты не удивительны, учитывая тот факт, что для обновления “реального” DOM-дерева по-прежнему используются JS-вызовы, плюс при выполнении JS кода “мусор” от удаленных объектов остается висеть в куче до срабатывания Garbage Collector’а, а asm-dom честно удаляет объекты сразу по необходимости, что тоже накладывает отпечаток на производительность.
Автор библиотеки в README.md сам сокрушается о том, что пока что GC/DOM-интеграция в WebAssembly невозможна, но настроен оптимистично в ожидании имплементации этого функционала – будем надеяться, что тогда asm-dom засияет во всей красе.
Полезные ссылки:
Другое дело, что очень часто можно встретить комментарии вида “Ура, теперь будем писать фронтенд на C++!”, “Давайте перепишем React на Rust” и прочее, прочее, прочее…
Интервью с Бренданом Айком очень хорошо раскрывает идею WebAssembly и ее предназначение: WASM это не полная замена JS, а лишь технология, позволяющая писать критичные к ресурсам модули и компилировать их в переносимый байт-код с линейной моделью памяти и статической типизацией: такой подход позволяет ускорить производительность или упростить перенос существующего кода для веб-приложений, работающих с мультимедиа, онлайн-игр, и прочих “тяжелых” вещей.
При большом желании можно реализовать и GUI, например, на WASM портирована библиотека imgui, существуют подвижки в портировании Qt на WASM (раз и два).
Но чаще всего озвучивается простой вопрос:
“А все-таки, можно ли из WebAssembly работать с DOM?”
Пока что категоричный ответ звучит как “Нет, нельзя”, более точный и правильный же звучит как “Можно, с использованием функций Javascript”. И эта статья, по сути дела, является рассказом о результатах моего маленького исследования о том, как это можно делать максимально удобно и эффективно.
В чем, собственно, проблема?
Посмотрим, как вообще происходит генерация элементов страницы и работа с ними из скриптов на примере web-движка Blink (Chromium) и JS-движка V8. По сути дела, почти любой DOM-элемент внутри Blink имеет своё воплощение в виде C++ объекта, унаследованного от HTMLElement, унаследованного от Element, унаследованного от ContainerNode, унаследованного от Node… на самом деле это далеко не вся цепочка, но в нашем случае это не важно. Например, для тега при парсинге HTML и построении дерева будет создан объект класса HTMLImageElement:
class CORE_EXPORT HTMLImageElement final
: public HTMLElement,
...
{
public:
static HTMLImageElement* Create(Document&);
...
unsigned width();
unsigned height();
...
String AltText() const final;
...
KURL Src() const;
void SetSrc(const String&);
void setWidth(unsigned);
void setHeight(unsigned);
...
}
Для контроля за временем жизни объектов и их удаления, в старых версиях Blink использовались умные указатели с подсчетом ссылок, в современных версиях используется Garbage Collector под названием Oilpan.
Для доступа к элементам страницы из JavaScript, объект описывается в виде IDL, чтобы специфицировать, какие именно поля и методы объекта будут доступны в JavaScript:
[
ActiveScriptWrappable,
ConstructorCallWith=Document,
NamedConstructor=Image(optional unsigned long width, optional unsigned long height)
] interface HTMLImageElement : HTMLElement {
[CEReactions, Reflect] attribute DOMString alt;
[CEReactions, Reflect, URL] attribute DOMString src;
...
[CEReactions] attribute unsigned long width;
[CEReactions] attribute unsigned long height;
...
[CEReactions, Reflect] attribute DOMString name;
[CEReactions, Reflect, TreatNullAs=EmptyString] attribute DOMString border;
...
после чего мы можем работать с ними из JavaScript-кода. У JS-движка V8 также есть свой Garbage Collector, а с C++ объекты Blink заворачиваются в специальные wrapper'ы, которые в терминологии V8 называются Template Objects. В итоге за временем жизни объектов страницы следят сборщики мусора Blink и V8.
Теперь представим, как в это дело вписываются WebAssembly-модули. На данный момент, то, что происходит внутри WASM для браузера «темный лес». К примеру, если мы возьмем элемент из документа, передадим указатель в WASM-модуль, сохранив там ссылку на него, а потом вызовем для него removeChild, то по мнению Blink на объект больше не будет указывать ни одна ссылка и объект должен быть удален — ведь окружающая среда не знает, что внутри WASM указатель на элемент по-прежнему хранится. К чему может привести такая ситуация догадаться, думаю, не трудно. И это только один из примеров.
Работа с garbage-collected объектами есть в Roadmap развития WebAssembly и на github заведен специальный Issue по этому вопросу, плюс есть документ с подробностями предложений по реализации всего этого.
Итак, код WebAssembly полностью изолирован в своей “песочнице”, и на сегодняшний день передать указатель на какой-нибудь объект DOM-дерева нормальным способом в него невозможно, вызвать какой-либо метод напрямую аналогично нельзя. Единственным корректным способом взаимодействовать с какими-либо объектами DOM-дерева или использовать любой другой браузерный API, является написание JS-функций, передача их в поле imports модуля WebAssmebly и вызов из WASM-кода.
helloworld.c:
void showMessage (int num);
int main(int num1) {
showMessage(num1);
return num1 + 42;
}
helloworld.js:
var wasmImports = {
env: {
showMessage: num => alert(num)
}
};
// wasmCode должен быть загружен откужда-то извне
var wasmModule = new WebAssembly.Module(wasmCode);
var wasmInstance = new WebAssembly.Instance(wasmModule, wasmImports);
console.log(wasmInstance.exports.main(5));
В сгенерированном байт-коде все просто и прозрачно: импортируется внешняя функция, в стек кладется аргумент, производится вызов функции, в стеке сохраняется результат сложения.
(module
(type $FUNCSIG$vi (func (param i32)))
(import "env" "showMessage" (func $showMessage (param i32)))
(table 0 anyfunc)
(memory $0 1)
(export "memory" (memory $0))
(export "main" (func $main))
(func $main (; 1 ;) (param $0 i32) (result i32) ;// наша int main(int)
(call $showMessage
(get_local $0) ;// передаем в вызов showMessage число со стека (аргумент)
)
(i32.add ;// суммируем число со стека (аргумент) и i32 константу
(get_local $0)
(i32.const 42)
)
)
)
Проверить на практике как работает все это дело можно используя WasmFiddle: https://wasdk.github.io/WasmFiddle/?l7d05
Казалось бы, все хорошо, но при усложнении задачи возникают проблемы. А что если нам надо передать из WASM-кода не число, а строку (при том, что в WebAssembly поддерживаются только 32- и 64-битные целые и 32- и 64-битные числа с плавающей запятой)? А что если нам нужно выполнять много очень разных манипуляций с DOM и вызовов браузерного API, и крайне неудобно в каждом случае писать отдельную JS-функцию?
И тут на помощь приходит Emscripten
Emscripten изначально был разработан как LLVM-бэкенд для компиляции в asm.js. Кроме непосредственно компиляции в asm.js и WASM, он также содержит “обертки”, эмулирующие функционал различных библиотек (libc, libcxx, OpenGL, SDL и д.р.) через доступный в браузере API, и свой набор вспомогательных функций, облегчающий портирование приложений и взаимодействие WASM и JS кода.
Самый простой пример. Как известно, аргументами и результатами при вызове функций WASM-модуля или из него, могут быть только i32, i64, f32, f64. Модуль WebAssembly имеет линейную память, которая может быть отображена в JS как Int8Array, Int16Array и т.д. Следовательно, для того, чтобы получить значение какого-либо нестандартного типа (строки, массива, и т.п.) из WASM в JS, мы можем положить его в адресном прострастве WASM-модуля, передать «наружу» указатель, а уже в JS-коде вытащить из массива нужные байты и преобразовать их в требуемый объект (например, строки в JS хранятся в UTF-16). При передаче данных «внутрь» WASM-модуля мы должны наоборот «снаружи» положить их в массив памяти по определенному адресу, и уже потом использовать этот адрес в C/C++ коде. Для этих целей в Emscripten есть большой набор вспомогательных функций. Так, кроме getValue() и setValue() (чтение и запись значений “кучи” WASM-приложения по указателям), существует, к примеру, функция Pointer_stringify(), преобразовывающая C-строки в строковые объекты JavaScript.
Другой удобной фишкой является возможность инлайнинга javascript кода прямо в C++ код. Всё остальное компилятор сделает за нас.
#include <emscripten.h>
int main() {
char* s = "hello world";
EM_ASM({
alert(Pointer_stringify($0));
}, s);
return 0;
}
После компиляции мы получаем .wasm-файл со скомпилированным байткодом и .js-файл, содержащий код запуска wasm-модуля и огромное количество различных вспомогательных функций.
Непосредственно наш заинлайненный макросом EM_ASM JS-код превратился в .js-файле в следущую конструкцию:
var ASM_CONSTS = [function($0) { alert(Pointer_stringify($0)); }];
function _emscripten_asm_const_ii(code, a0) {
return ASM_CONSTS[code](a0);
}
В байт-коде же мы имеем практически все то же самое, что и в предыдущем примере, только при вызове функции на стек также кладется число 0 (идентификатор функции в массиве ASM_CONSTS), а также указатель на строковую константу (char*) в адресном пространстве WASM-модуля, равный в нашем случае 1024. Метод Pointer_stringify() в javascript-коде извлекает данные из “кучи”, представленной в JS в виде Uint8Array, и выполняет преобразование из массива UTF8 в String-объект.
При более внимательном рассмотрении немного смущает тот факт, что строковая константа (char*), расположенная по адресу 1024, почему-то содержит не только текст “hello world” с нулевым байтом, но и дубль заинлайненного JS-кода. Объяснить причину появления этого внутри скомпилированного wasm-файла я сходу не могу, буду признателен, если кто-то поделится предположениями в комментариях.
(import "env" "_emscripten_asm_const_ii" (func $_emscripten_asm_const_ii (param i32 i32) (result i32)))
(data (i32.const 1024) "hello world\00{ alert(Pointer_stringify($0)); }")
(func $_main (; 14 ;) (result i32)
(drop
(call $_emscripten_asm_const_ii
(i32.const 0)
(i32.const 1024)
)
)
(i32.const 0)
)
В любом случае, напрашивается вывод, что вызов JavaScript-функций из WASM-кода – это не самое быстрое занятие в плане производительности. Как минимум время будут отнимать накладные расходы на взаимодействие WASM-кода и JS-интерпретатора, преобразование типов при передаче аргументов, и многое другое.
Cheerp
Читая статьи и изучая документацию на различные библиотеки, мне на глаза попался компилятор Cheerp, генерирующий WASM код из C/C++, и по громкому заверению на лендинге на официальном сайте, обеспечивающим “no-overhead access to HTML5 DOM”. Мой внутренний скептик, однако, говорил, что магии не бывает. Для начала пробуем скомпилировать простейший пример из документации:
#include <cheerp/clientlib.h>
#include <cheerp/client.h>
[[cheerp::genericjs]] void domOutput(const char* str)
{
client::console.log(str);
}
void webMain()
{
domOutput("Hello World");
}
На выходе мы получаем .wasm-файл, беглый просмотр которого говорит нам, что в нем нету data-секции со строковой константой.
(module
(type $vt_v (func ))
(func (import "imports" "__Z9domOutputPKc"))
(table anyfunc (elem $__wasm_nullptr))
(memory (export "memory") 16 16)
(global (mut i32) (i32.const 1048576))
(func $__wasm_nullptr (export "___wasm_nullptr")
(local i32)
unreachable
)
(func $_Z7webMainv (export "__Z7webMainv")
(local i32)
call 0
)
)
Заглядываем в JS:
function f() {
var a = null;
a = h();
console.log(a);
return;
}
function h() {
var a = null,
d = null;
a = String();
d = String.fromCharCode(72);
a = a.concat(d);
d = String.fromCharCode(101);
a = a.concat(d);
d = String.fromCharCode(108);
a = a.concat(d);
d = String.fromCharCode(108);
a = a.concat(d);
d = String.fromCharCode(111);
a = a.concat(d);
d = String.fromCharCode(32);
a = a.concat(d);
d = String.fromCharCode(87);
a = a.concat(d);
d = String.fromCharCode(111);
a = a.concat(d);
d = String.fromCharCode(114);
a = a.concat(d);
d = String.fromCharCode(108);
a = a.concat(d);
d = String.fromCharCode(100);
a = a.concat(d);
return String(a);
}
function _asm_f() {
f();
}
function __dummy() {
throw new Error('this should be unreachable');
};
var importObject = {
imports: {
__Z9domOutputPKc: _asm_f,
}
};
instance.exports.i();
Честно говоря, вообще не совсем понятно, зачем в подобном случае нужно генерировать WASM-модуль, поскольку в нем не происходит ровно ничего кроме одного вызова внешней функции, а вся логика разместалась в JS-коде. Ну да ладно.
Интересной особенностью компилятора являются биндинги ко всем стандартным DOM-объектам в C++ (судя по написанному в документации, полученные путем авто-генерации кода из IDL), позволяющие писать C++ код, “на прямую” манипулирующий нужными объектами:
#include <cheerp/client.h>
#include <cheerp/clientlib.h>
using namespace client;
int test = 1;
[[cheerp::genericjs]] void domOutput(int a)
{
const char* str1 = "Hello world!";
const char* str2 = "LOL";
Element* titleElement=document.getElementById("pagetitle");
titleElement->set_textContent(a / 2 == 0 ? str1 : str2);
}
void webMain()
{
test++;
domOutput(test);
test++;
domOutput(test);
}
Посмотрим, что же у нас получилось после компиляции…
function j(b) {
var a = null,
c = null;
a = "pagetitle";
a = document.getElementById(a);
a = a;
a.textContent;
if (b + 1 >>> 0 < 3) {
c = "Hello world!";
a.textContent = c;
return;
} else {
c = "LOL";
a.textContent = c;
return;
}
}
function e(f, g) {
var b = 0,
c = 0,
a = null,
t = null;
a = String();
b = f[g] | 0;
if ((b & 255) === 0) {
return String(a);
} else {
c = 0;
}
while (1) {
t = String.fromCharCode(b << 24 >> 24);
a = a.concat(t);
c = c + 1 | 0;
b = f[g + c | 0] | 0;
if ((b & 255) === 0) {
break;
}
}
return String(a);
}
var s = new Uint8Array([112, 97, 103, 101, 116, 105, 116, 108, 101, 0]);
var r = new Uint8Array([76, 79, 76, 0]);
var q = new Uint8Array([72, 101, 108, 108, 111, 32, 119, 111, 114, 108, 100, 33, 0]);
function _asm_j(b) {
j(b);
}
function __dummy() {
throw new Error('this should be unreachable');
};
var importObject = {
imports: {
__Z9domOutputi: _asm_j,
}
};
...
instance.exports.p();
Кажется, я ошибался насчет магии. Строковые константы у нас оказались в JavaScript-коде в Uint8-массивах, и при запуске скрипта они преобразуются в String чередой посимвольных вызовов String.concat(). При том, что те же самые строки лежат чуть выше прямым текстом в JavaScript-коде. Инкрементирование test производится в WASM-коде; в функции, устанавливающей текстовое содержимое DOM-элемента можно встретить чудесное “a = a” и вызов геттера textContent без использования его результата; проверка переменной на четность в результате работы оптимизатора выродилась в выносящее мозг выражение “b + 1 >>> 0 < 3” (да, именно так, с битовым сдвигом на 0 позиций).
Можно ли это назвать “zero overhead DOM-manipulations”? Даже если учесть, что по сути дела все равно все манипуляции выполняются точно также через JS (сложно было ожидать чего-то другого, на самом деле), в лучшем случае можно говорить про “zero overhead” по сравнению с чистым JS, и странные танцы с бубном вокруг строк производительности явно не добавят, как и радости при отладке всего этого.
Как говорится, не верьте рекламе. Но стоит отметить, что проект все-таки активно развивается. Когда я забыл выставить атрибут [[cheerp::genericjs]] для функции void domOutput(int a), при компиляции с таргетом “wasm” компилятор просто упал с SIGSEGV. Я завел Issue на github разработчиков об этой проблеме, на следущий же день мне объяснили, в чем ошибка, и буквально через неделю в master-ветке появилось исправление этой проблемы. Возможно, стоит понаблюдать за Cheerp в дальнейшей перспективе.
Stdweb
Говоря о компиляторах и библиотеках, созданных для взаимодействия между WASM, JS и WebAPI, нельзя не упомянуть Stdweb для Rust.
Она позволяет инлайнить JS-код в код на Rust с поддержкой замыканий и предоставляет обертки для DOM-объектов и браузерных API, максимально приближенные к тому, что привычно видеть в JS:
let button = document().query_selector( "#hide-button" ).unwrap();
button.add_event_listener( move |_: ClickEvent| {
for anchor in document().query_selector_all( "#main a" ) {
js!( @{anchor}.style = "display: none;"; );
}
});
В поставку сразу включены примеры реализации разных вещей на Rust/WASM, из которых наибольший интерес представляет TodoMVC. Её можно запустить через cargo-web командой
cargo web start –target-webasm-emscripten
в результате чего мы получаем веб-сервер на 8000 порту с нашим приложением.
После компиляции мы в .js-файле видим те же самые функции-хелперы Emscripten, но гораздо больший интерес (помятуя о том, что было в предыдущем пункте) представляет то, как именно реализован вызов JS-кода из WASM-модуля и работа с объектами.
Точно такой же, как и во втором примере (компиляция C++ с помощью Emscripten) массив ASM_CONSTS заполнен функциями примерно такого вида:
var ASM_CONSTS = [
function($0) { Module.STDWEB.decrement_refcount( $0 ); },
function($0, $1, $2, $3) {
$1 = Module.STDWEB.to_js($1);
$2 = Module.STDWEB.to_js($2);
$3 = Module.STDWEB.to_js($3);
Module.STDWEB.from_js($0, (function() {
var listener = ($1);
($2). addEventListener (($3), listener);
return listener;
})());
},
function($0) {
Module.STDWEB.tmp = Module.STDWEB.to_js( $0 );
},
function($0, $1) {
$0 = Module.STDWEB.to_js($0);
$1 = Module.STDWEB.to_js($1);
($0). appendChild (($1));
},
function($0, $1, $2) {
$1 = Module.STDWEB.to_js($1);
$2 = Module.STDWEB.to_js($2);
Module.STDWEB.from_js($0, (function() {
try {
($1). removeChild (($2));
return true;
} catch (exception) {
if (exception instanceof NotFoundError) {
return false;
} else {
throw exception;
}
}
})());
},
function($0, $1) {
$1 = Module.STDWEB.to_js($1);
Module.STDWEB.from_js($0, (function() {
return ($1). classList;
})());
},
function($0, $1, $2) {
$1 = Module.STDWEB.to_js($1);
$2 = Module.STDWEB.to_js($2);
Module.STDWEB.from_js($0, (function(){
return ($1). querySelector (($2));
})());
},
function($0) {
return (Module.STDWEB.acquire_js_reference( $0 ) instanceof HTMLElement) | 0;
},
function($0) {
return (Module.STDWEB.acquire_js_reference( $0 ) instanceof HTMLInputElement) | 0;
},
function($0) {
$0 = Module.STDWEB.to_js($0);($0). blur ();
},
Иными словами, к примеру,
let label = document().create_element( "label" );
label.append_child( &document().create_text_node( text ) );
будет реализована с помощью хелперов
function($0, $1, $2) {
$1 = Module.STDWEB.to_js($1);
$2 = Module.STDWEB.to_js($2);
Module.STDWEB.from_js($0, (function() {
return ($1). createElement (($2));
})());
},
function($0, $1, $2) {
$1 = Module.STDWEB.to_js($1);
$2 = Module.STDWEB.to_js($2);
Module.STDWEB.from_js($0, (function() {
return ($1). createTextNode (($2));
})());
},
function($0, $1) {
$0 = Module.STDWEB.to_js($0);
$1 = Module.STDWEB.to_js($1);
($0). appendChild (($1));
},
причем, как можно заметить, оно не транслировано в один целостный JavaScript-метод, а между WASM и JS-кодом постоянно передаются “указатели” на используемые объекты. Учитывая, что WASM-код не может работать с JS-объектами напрямую, этот трюк выполнен довольно интересным образом, и посмотреть на реализацию можно в исходниках stdweb.
При передаче JS/DOM-объекта в WASM, объект добавляется в контейнеры “ключ-значение” в JS, хранящие соответствия вида “JS объект < > уникальный RefId” и наоборот, где уникальный RefId представляет собой по сути дела автоинкреметный номер:
Module.STDWEB.acquire_rust_reference = function( reference ) {
...
ref_to_id_map.set( reference, refid );
...
id_to_ref_map[ refid ] = reference;
id_to_refcount_map[ refid ] = 1;
...
};
При этом проверяется, что этот объект еще ни разу не передавался (в противном случае будет создана не новая запись, а увеличен счетчик ссылок). В память WASM-приложения записывается идентификатор типа объекта (например, 11 для Object, 12 для Array), после чего следует запись RefId объекта. При передаче объекта в обратную сторону из map’а просто извлекается нужный объект по уникальному ID и используется.
Без тестов невозможно точно сказать, насколько сильно вызовы JS-функций на каждый чих из WASM, преобразования типов (и конверсия строк) вкупе с постоянными поисками объектов в таблицах замедлят работу, но в целом, подобный подход к взаимодействию между “мирами” мне кажется гораздо более красивым, чем непонятная мешанина кода из предыдущих примеров.
asm-dom
Ну и самое вкусное напоследок: asm-dom. Это библиотека виртуального DOM (подробнее про концепцию Virtual DOM можно прочитать в статье на Хабре), вдохновленная JavaScript VDOM-библиотекой Snabbdom и предназначенная для разработки SPA (Single-page applications) на C++/WebAssembly.
Код описания элементов страницы выглядит примерно так:
VNode* newVnode = h("div",
Data(
Callbacks {
{"onclick", [](emscripten::val e) -> bool {
emscripten::val::global("console").call<void>("log", emscripten::val("another click"));
return true;
}}
}
),
Children {
h("span",
Data(
Attrs {
{"style", "font-weight: normal; font-style: italic"}
}
),
std::string("This is now italic type")
),
h(" and this is just normal text", true),
h("a",
Data(
Attrs {
{"href", "/bar"}
}
),
std::string("I'll take you places!")
)
}
);
patch(
emscripten::val::global("document").call<emscripten::val>(
"getElementById",
std::string("root")
),
vnode
);
Также существует gccx, конвертер, генерирующий код типа приведенного выше из CPX, который, в свою очередь, является аналогом JSX, многим известного по ReactJS, позволяющий описывать компоненты прямо внутри C++-кода:
VNode* vnode = (
<div
onclick={[](emscripten::val e) -> bool {
emscripten::val::global("console").call<void>("log", emscripten::val("clicked"));
return true;
}}
>
<span style="font-weight: bold">This is bold</span>
and this is just normal text
<a href="/foo">I'll take you places!</a>
</div>
);
“Перегонка” VirtualDOM в реальный DOM, как и взаимодействие между WASM-кодом и Web API, происходит либо через генерацию HTML и установки свойств innerHTML у объектов, или же аналогично прошлому примеру:
var addPtr = function addPtr(node) {
if (node === null) return 0;
if (node.asmDomPtr !== undefined) return node.asmDomPtr;
var ptr = ++lastPtr;
nodes[ptr] = node;
node.asmDomPtr = ptr;
return ptr;
};
exports['default'] = {
…
'appendChild': function appendChild(parentPtr, childPtr) {
nodes[parentPtr].appendChild(nodes[childPtr]);
},
'removeAttribute': function removeAttribute(nodePtr, attr) {
nodes[nodePtr].removeAttribute(attr);
},
'setAttribute': function setAttribute(nodePtr, attr, value) {
...
Также на Github проекта есть ссылка на тесты производительности по сравнению с JS-ной VDOM библиотекой Snabbdom, по которым видно, что в некоторых тест-кейсах WASM-вариант проигрывает JS, в некоторых немного его обгоняет, и только в одном тесте при запуске в Firefox видно серьезное ускорение. В принципе, подобные результаты не удивительны, учитывая тот факт, что для обновления “реального” DOM-дерева по-прежнему используются JS-вызовы, плюс при выполнении JS кода “мусор” от удаленных объектов остается висеть в куче до срабатывания Garbage Collector’а, а asm-dom честно удаляет объекты сразу по необходимости, что тоже накладывает отпечаток на производительность.
Автор библиотеки в README.md сам сокрушается о том, что пока что GC/DOM-интеграция в WebAssembly невозможна, но настроен оптимистично в ожидании имплементации этого функционала – будем надеяться, что тогда asm-dom засияет во всей красе.
Полезные ссылки:
Комментарии (6)
RPG18
01.02.2018 10:40Все было бы здорово, если бы Apple не сломал бы wasm на IOS. Создается впечатление, что они сделали это намеренно, что бы нельзя было запускать игры на Unity/Unreal Engine/и т.д. не шли в обход стора.
tangro
02.02.2018 12:48чудесное “a = a” и вызов геттера textContent без использования его результата
Мой преподаватель по теории компиляторов на третьем курсе универа за такое возвращал лабораторку/курсовую на доработку. Двоешники.
Klimashkin
А есть хотя бы примерные планы когда WA будет работать с dom напрямую? Или еще не видно конца дискуссии?
F0iL Автор
Дискуссии ведутся, коммиты в репозиторий спецификации прилетают, но, судя по всему, до конца еще далеко.
Сам процесс разработки и принятия post-MVP фишек в стандарт состоит из ряда формализованных фаз: github.com/WebAssembly/meetings/blob/master/process/phases.md
Поддержка garbage-collected объектов и связанных вещей, необходимых для работы с DOM, сейчас находится в фазе Feautre Proposal (по сути дела 2-ая из 6), плюс еще должна появиться поддержка принятой спецификации в компиляторах и браузерах.
Так что, могу предположить, что все будет еще не скоро.