Про WebAssembly в наше время слышали, я думаю, практически все. Если Вы не слышали, то на Хабре есть замечательный вводный материал об этой технологии.
![image](https://habrastorage.org/webt/hq/jo/pf/hqjopfuqrpshwebe2chyfqwshf0.png)
Другое дело, что очень часто можно встретить комментарии вида “Ура, теперь будем писать фронтенд на C++!”, “Давайте перепишем React на Rust” и прочее, прочее, прочее…
Интервью с Бренданом Айком очень хорошо раскрывает идею WebAssembly и ее предназначение: WASM это не полная замена JS, а лишь технология, позволяющая писать критичные к ресурсам модули и компилировать их в переносимый байт-код с линейной моделью памяти и статической типизацией: такой подход позволяет ускорить производительность или упростить перенос существующего кода для веб-приложений, работающих с мультимедиа, онлайн-игр, и прочих “тяжелых” вещей.
При большом желании можно реализовать и GUI, например, на WASM портирована библиотека imgui, существуют подвижки в портировании Qt на WASM (раз и два).
![image](https://habrastorage.org/webt/k5/6n/gm/k56ngm2e68raehxl0sffwzaswty.png)
Но чаще всего озвучивается простой вопрос:
Пока что категоричный ответ звучит как “Нет, нельзя”, более точный и правильный же звучит как “Можно, с использованием функций 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:
![image](https://habrastorage.org/webt/pe/mc/no/pemcnooxmx9vcd78nz2v2afqhfi.jpeg)
Честно говоря, вообще не совсем понятно, зачем в подобном случае нужно генерировать WASM-модуль, поскольку в нем не происходит ровно ничего кроме одного вызова внешней функции, а вся логика разместалась в JS-коде. Ну да ладно.
Интересной особенностью компилятора являются биндинги ко всем стандартным DOM-объектам в C++ (судя по написанному в документации, полученные путем авто-генерации кода из IDL), позволяющие писать C++ код, “на прямую” манипулирующий нужными объектами:
Посмотрим, что же у нас получилось после компиляции…
![image](https://habrastorage.org/webt/km/yz/ys/kmyzys17jove7zcmsdd--eunli0.jpeg)
Кажется, я ошибался насчет магии. Строковые константы у нас оказались в 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 честно удаляет объекты сразу по необходимости, что тоже накладывает отпечаток на производительность.
![image](https://habrastorage.org/webt/nw/n5/ak/nwn5akn3qq6okokkmnanbukjjky.jpeg)
Автор библиотеки в README.md сам сокрушается о том, что пока что GC/DOM-интеграция в WebAssembly невозможна, но настроен оптимистично в ожидании имплементации этого функционала – будем надеяться, что тогда asm-dom засияет во всей красе.
Полезные ссылки:
![image](https://habrastorage.org/webt/hq/jo/pf/hqjopfuqrpshwebe2chyfqwshf0.png)
Другое дело, что очень часто можно встретить комментарии вида “Ура, теперь будем писать фронтенд на C++!”, “Давайте перепишем React на Rust” и прочее, прочее, прочее…
Интервью с Бренданом Айком очень хорошо раскрывает идею WebAssembly и ее предназначение: WASM это не полная замена JS, а лишь технология, позволяющая писать критичные к ресурсам модули и компилировать их в переносимый байт-код с линейной моделью памяти и статической типизацией: такой подход позволяет ускорить производительность или упростить перенос существующего кода для веб-приложений, работающих с мультимедиа, онлайн-игр, и прочих “тяжелых” вещей.
При большом желании можно реализовать и GUI, например, на WASM портирована библиотека imgui, существуют подвижки в портировании Qt на WASM (раз и два).
![image](https://habrastorage.org/webt/k5/6n/gm/k56ngm2e68raehxl0sffwzaswty.png)
Но чаще всего озвучивается простой вопрос:
“А все-таки, можно ли из 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();
![image](https://habrastorage.org/webt/pe/mc/no/pemcnooxmx9vcd78nz2v2afqhfi.jpeg)
Честно говоря, вообще не совсем понятно, зачем в подобном случае нужно генерировать 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();
![image](https://habrastorage.org/webt/km/yz/ys/kmyzys17jove7zcmsdd--eunli0.jpeg)
Кажется, я ошибался насчет магии. Строковые константы у нас оказались в 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 честно удаляет объекты сразу по необходимости, что тоже накладывает отпечаток на производительность.
![image](https://habrastorage.org/webt/nw/n5/ak/nwn5akn3qq6okokkmnanbukjjky.jpeg)
Автор библиотеки в 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), плюс еще должна появиться поддержка принятой спецификации в компиляторах и браузерах.
Так что, могу предположить, что все будет еще не скоро.