Нативні застосунки з ClojureScript, React та Static Hermes

💡 Усі статті, обговорення, новини про Front-end — в одному місці. Приєднуйтесь до Front-end спільноти!

Що ви бачите в демо нижче — це застосунок на ClojureScript (UIx), який керує нативним вікном через UI ImGui за допомогою кастомного React reconciler. Підтримуються як hot-reloading, так і розробка через REPL, як і очікується від типового ClojureScript проєкту.

JavaScript-частина застосунку працює на движку Hermes, який надає доступ до ImGui в JS-середовищі. Проте релізна збірка цього застосунку — це повністю нативний 8MB виконуваний файл. Ви можете спробувати його на macOS або Linux.

Хоча Hermes це JavaScript VM, він також є AOT-компілятором для JavaScript, який видає або Hermes byte code або C.

Компіляція цього прикладу JavaScript через Static Hermes: shermes -emit-c index.js

print(1 + globalThis.value);

видає наступну програму C або 50-кілобайтний виконуваний файл, якщо компілювати одразу в бінарний файл shermes -Os index.js.

locals.t0 = _sh_ljs_get_global_object(shr);
frame[3] = _sh_ljs_try_get_by_id_rjs(shr,&locals.t0, get_symbols(shUnit)[1] /*print*/, get_read_prop_cache(shUnit) + 0);
locals.t0 = _sh_ljs_try_get_by_id_rjs(shr,&locals.t0, get_symbols(shUnit)[2] /*globalThis*/, get_read_prop_cache(shUnit) + 1);
locals.t0 = _sh_ljs_get_by_id_rjs(shr,&locals.t0,get_symbols(shUnit)[3] /*value*/, get_read_prop_cache(shUnit) + 2);
np0 = _sh_ljs_double(1);
frame[1] = _sh_ljs_add_rjs(shr, &np0, &locals.t0); // 1 + globalThis.value
np0 = _sh_ljs_undefined();
frame[4] = _sh_ljs_undefined();
frame[2] = _sh_ljs_undefined();
locals.t0 = _sh_ljs_call(shr, frame, 1);
_sh_leave(shr, &locals.head, frame);
return np0;

Вищезгадане демо — це 8 МБ виконуваного файлу на macOS, де 3 МБ рантайм Hermes, 1.8 МБ бібліотека React (JavaScript), скомпільована в C, а решта 3.2 МБ припадає на нативні бібліотеки ImGui та libwebsockets, і код ClojureScript, скомпільований в C.

Ось приблизна діаграма того, як все це «запаковано» у виконуваний файл:

Можна помітити, що є untyped та typed JavaScript. При AOT-компіляції typed JavaScript (підмножина TypeScript або Flow), Hermes генерує оптимальніший C-код, придатний для продуктивного коду, наприклад, для біндінгів (прив’язок) до нативних бібліотек, які працюють у гарячому циклі.

Наприклад, компіляція наступної типізованої програми з прапорцем -typed: shermes -typed -emit-c index.js.

function add(a: number): number {
  return a + globalThis.value;
}
print(add(1));

генерує C-код, який:

  • не створює функцію/замикання add, арифметика видається inline;
  • додає перевірку типу для невідомого значення globalThis.value;
  • містить менше символів та кешів.

Ви можете переглянути C-код для типізованої та нетипізованої версій тут, ось також diff, щоб зробити це більш очевидно.

Як я вже казав раніше, застосунок у режимі розробки (dev) запускає «сирий» JavaScript, щоб забезпечити швидкий цикл зворотного зв’язку та підтримати інтерактивну розробку. Це можливо завдяки тому, що Hermes має кілька рівнів оптимізації коду:

Під час розробки застосунок виконує сирий JavaScript для швидкого циклу зворотного зв’язку та інтерактивної розробки. Hermes має кілька рівнів оптимізації коду:

  1. Під час розробки JavaScript VM завантажує код та генерує байткод (JIT) під час виконання.
  2. Можна компілювати JavaScript у байткод (.hbc) заздалегідь, що популярно в React Native для пришвидшення старту мобільних застосунків.
  3. Ну і нарешті, Static Hermes можна компілювати JavaScript у нативний об’єктний файл, отримуючи статично зв’язаний машинний код без парсингу та JIT.

Але найкраща частина цієї всієї системи — це, власне, сам React. Мені не довелося нічого змінювати в UIx, щоб змусити його працювати з оригінальним demo imgui-react-runtime. Справжня сила і прокляття React полягає в тому, що це незалежна від платформи абстракція. І вже від хост-платформи залежить конкретна реалізація цього «контракту». Якщо ви розробник на Clojure, це може здатися вам знайомим.

Говорячи про абстракцію, маю на увазі reconciler (механізм узгодження) React. Реалізувавши його інтерфейс, ви можете створювати власні render targets, такі як React Native, PixiJS React, react-three-fiber, ви навіть можете створювати з його допомогою UI для терміналу та PDF-файли. Ба більше, якщо ви зробите крок назад і подумаєте про reconciler як про загальну абстракцію для перетворення декларативного опису чогось у набір імперативних операцій, ви зможете з ним навіть керувати «залізом».

Тож наскільки швидкий AOT-компільований JavaScript з Static Hermes? Ось швидкий бенчмарк:

(simple-benchmark []
    (->> (range)
         (map inc)
         (filter odd?)
         (map #(zipmap (repeatedly % Math/random) (repeatedly % Math/random)))
         (take 1e2)
         (reduce #(apply + %1 (mapcat identity %2)) 0))
    1e2)

Оптимізований виконуваний файл виконується за ~6450 мс. Той самий код, виконаний як JavaScript у Node та Bun — за ~1100 мс. Це в 6 разів повільніше, ой.
З іншого боку, виконуваний файл важить всього кілька МБ, тоді як вбудовування VM JavaScript дасть вам щонайменше 60 МБ у випадку з Bun. Якщо ви запитаєте мене, я завжди віддам перевагу застосунку, який у 6 разів швидший, незалежно від його розміру.

Однак випадок з React досить специфічний. React — це декларативна абстракція над імперативними API, що майже завжди означає, що «гарячі» (hot) шляхи коду виконуються на глибших рівнях, чи то DOM браузера, mobile views, WebGL чи ImGui. І часто у вас є можливість використовувати цей нижчий, більш продуктивний API безпосередньо, коли це необхідно.

Hermes спочатку створювався для React Native та мобільних застосунків для покращення стартової продуктивності. Навіть нативний код не може конкурувати з JIT у сучасних JS-движках.

Бонус: подібно до shadow-cljs, демо-застосунок ImGui відловлює і відображає помилки та попередження, а також включає компонент error boundary для обробки виключень під час рендеру.

Код проєкту доступний на GitHub: roman01la/cljs-static-hermes, він базується на роботі, виконаній членом команди Hermes, tmikov/imgui-react-runtime. Якщо вам цікаво керувати нативним вікном з таких рантаймів, як Node або Bun, подивіться цей проєкт, що демонструє використання FFI (Foreign Function Interface) у Bun для взаємодії з GLFW та OpenGL: roman01la/hra.

👍ПодобаєтьсяСподобалось8
До обраногоВ обраному1
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Підписатись на коментарі