Документация
Архитектура
Эта страница для разработчиков приложений, которые хотят понять, что происходит между count.set(1) и изменением надписи на экране
в приложении, написанном на Rust или TSX.
Она не нужна для написания приложений, но объясняет, почему NSH ведёт себя именно так: почему компоненты выполняются один раз, почему
анимации остаются плавными, пока JS занят, и почему основной поток никогда не ждёт ваш код.
#Общая картина
#Сборка: в JS превращается только ваш код
nsh build, nsh dev и другие команды собирают ваше приложение с помощью инструментария, написанного на Rust (без Node.js):
- Oxc удаляет типы TypeScript.
- JSX-компилятор NSH превращает JSX в прямые вызовы нативного рендерера за тот же проход. Статические свойства и дочерние элементы задаются один раз; выражения, которые могут измениться (они вызывают функцию или читают свойство), становятся привязками, за которыми следит рендерер. Компоненты превращаются в обычные вызовы функций с объектом props, в котором изменяющиеся свойства являются геттерами.
- rolldown собирает ваши модули в один ES-модуль. Импорты модулей NSH (
nsh,nsh/mobile,nsh/base, …) остаются импортами: эти модули нативные и предоставляются средой выполнения. Бандл содержит только код вашего приложения; пример со счётчиком занимает менее 1 КБ JS. - Бандл компилируется в байт-код (
bundle.nshc), который загружается быстрее исходного кода, а source map собирается по всем шагам, так что трассировки стека указывают на строки вашего.tsx.
Импортируемые изображения и шрифты собираются как ресурсы, а объявления типов для вашего редактора записываются в
.nsh/types.
У приложения на Rust бандла нет: Cargo собирает запускающий модуль для каждой целевой платформы с вашим крейтом, а корневой
компонент, названный в nsh.json, нативно выполняется движком. Всё описанное ниже справедливо и для такого приложения, только код ваших компонентов
выполняется в Rust там, где в TSX-приложении он выполняется в QuickJS.
#Выполнение: два потока на приложение
Каждое запущенное приложение (NshAppView на iOS, NshView на Android, окно упакованного приложения) — это один движок со
своим потоком движка и, для TSX, своей виртуальной машиной QuickJS. Глобальных синглтонов нет, поэтому несколько приложений могут работать рядом
в одном приложении-хосте.
#Поток движка
Здесь происходит всё, что относится к логике вашего приложения:
- Ваш код выполняется здесь: компоненты, обработчики событий, таймеры, эффекты. Компоненты на Rust выполняются нативно, TSX — в QuickJS.
- Реактивный граф (сигналы, вычисляемые значения, эффекты, владельцы, контекст) нативный. Его узлы — это объекты QuickJS нативных классов, поэтому сборщик мусора видит ссылки через них, но отслеживание зависимостей и планирование обновлений реализованы на Rust (по алгоритму Solid 1.9).
- Рендерер, который вызывает ваш скомпилированный JSX, нативный. Он создаёт узлы и превращает каждое изменение в операцию, помещаемую прямо в нативную очередь: без JSON и без отдельных сообщений на каждое свойство.
- Ядро хранит теневое дерево всех узлов. Когда ход JS заканчивается, оно забирает все операции из очереди сразу и выполняет коммит:
рендерит компоненты на Rust (
nsh/baseи собственные компоненты приложения), разрешает отступыenv()в padding и margin, пересобирает изменившиеся тексты, выполняет раскладку с помощью Taffy (flexbox и grid, с колонкой по умолчанию и без сжатия), сравнивает фреймы и стили и формирует один MountBatch: список операций, нужных нативным представлениям.
#Основной поток
Основной (UI) поток платформы применяет каждый MountBatch за один переход: создаёт представления, вставляет их, задаёт стили и фреймы, передаёт уже разложенный текст. Кроме этого он делает только то, что должно происходить именно в нём:
- отрисовка текста, уже разложенного в потоке движка;
- нативные анимации: анимированные значения из
nshуправляются покадрово (CADisplayLinkна iOS, аниматор UI-потока на Android) и записываются прямо в представления; - жесты и прокрутка: касания превращаются в события, отправляемые в поток движка;
scrollValueиpanпередают анимированные значения в том же кадре.
Основной поток никогда не ждёт JS. Когда системе нужен ответ немедленно (можно ли изменить этот текст, может ли кнопка «назад» закрыть этот экран, можно ли закрыть этот лист), ответ заранее смонтирован как свойство, а JS узнаёт о нём позже через событие. Занятый поток движка может задержать обновления, но не может заморозить прокрутку, анимации или системные жесты.
#От касания до экрана
main thread engine thread main thread
─────────── ───────────────────────────────────────────────────── ───────────
tap on a view ─ event ─▶ handler runs: count.set(count() + 1)
reactive graph: the bindings that read count() run again
renderer: one op per changed binding → native queue
JS turn ends → core commit: render, text, layout, diff
→ MountBatch (e.g. 1 Text item) ─ hop ─▶ apply: draw the new text
В примере со счётчиком касание обходится примерно в 0,12 мс в релизной сборке на Mac, включая JS и коммит вместе, и порождает небольшое число элементов монтирования (см. Тестирование).
В приложении на Rust обработчик — это замыкание Rust (count.set(count.get() + 1)), а далее следуют те же граф, коммит и пакет,
без интерпретатора на пути.
Из этого устройства следуют три свойства:
- Один ход — один коммит. Сколько бы сигналов ни установил обработчик, представления изменятся один раз, вместе.
- Компоненты выполняются один раз. Повторно выполняются только привязки; нет повторного рендера компонента и сравнения виртуального DOM. Остающееся сравнение выполняет ядро, над фреймами и стилями, на Rust.
- Передаются только изменения. Неизменившиеся стили и фреймы не отправляются; новые представления не получают значений по умолчанию.
#Текст: раскладывается один раз, вне основного потока
Текст часто оказывается самой дорогой частью UI. NSH раскладывает его в потоке движка штатным текстовым движком платформы, поэтому он выглядит в точности как нативный текст:
- подготовка:
<Text>и его вложенные спаны превращаются в одну строку с атрибутами (CoreText на платформах Apple,PrecomputedText/StaticLayoutна Android); - измерение при ширинах, которые запрашивает раскладка; измерения кешируются по содержимому и ширине, поэтому изменение только цвета не приводит к повторному измерению;
- раскладка в итоговом размере.
Основной поток получает неизменяемый результат и только рисует его. Текст, вложенный в <Text>, — это спан, а не представление;
строки вне <Text> ничего не отображают.
#Списки
List виртуализируется нативно. Ядро решает, какие строки находятся рядом с областью просмотра, вычисляет позиции, колонки и
закреплённые заголовки и переиспользует ячейки. Когда ячейке нужен другой элемент, среда выполнения привязывает его (меняются
акцессоры item() и index() вашей функции строки); кадры, не открывающие новых строк, вообще не вызывают JS. См. Списки.
#Всё остальное, что делает среда выполнения
- Сеть:
fetchи другие глобальные API — нативные классы. Запросы идут в HTTP-стек платформы (NSURLSession,HttpURLConnection) с потоковой передачей в обоих направлениях. - Изображения загружаются и декодируются вне основного потока, в размере представления, с кешем в памяти.
- Ограничения и разрешения обеспечиваются средой выполнения на Rust: см. Песочница и разрешения.
- Горячая замена модулей тоже нативная: патчи применяются средой выполнения значительно быстрее миллисекунды (см. Сервер разработки и отладка).
#Три слоя
UI в NSH построен из трёх слоёв, как и в GPUI Kit:
| Слой | NSH | GPUI Kit | Что это |
|---|---|---|---|
| Примитивы | nsh-core и среда выполнения (nsh, nsh/mobile, nsh/desktop) | gpui | Хост-элементы, стили, раскладка, рендеринг, реактивность, окружение |
| Базовые компоненты | nsh-base (nsh/base) | gpui-base | Компоненты без стилей, которые отвечают за поведение, состояние, фокус, клавиатуру и доступность, а внешний вид берут из приложения |
| Библиотека компонентов | ваша собственная или сообщества | gpui-component | Стилизованные компоненты: кнопки, карточки, строки с внешним видом |
Первые два слоя — это Native Shell. Третий — ваш: приложение строит собственную библиотеку компонентов на nsh/base и
примитивах, в TSX или как компоненты Rust в собственном крейте, использует библиотеку, опубликованную сообществом, или
стилизует свой UI прямо в приложении.
#Крейты
NSH — это рабочее пространство Rust. Для написания приложений собирать его не нужно (дистрибутив содержит CLI и готовые среды выполнения), но эти имена встречаются в логах и документации:
| Крейт | Что это |
|---|---|
nsh-core | Платформонезависимое ядро: теневое дерево, стили, раскладка Taffy, слияние текста, виртуализация списков, элементы монтирования; значения CSS и Tailwind; нативный граф анимации (значения, интерполяция, timing / spring / decay); фреймворк и тема компонентов на Rust; модули среды выполнения на Rust (BackHandler из nsh/mobile, …). |
nsh-base | nsh/base, базовые компоненты без стилей (gpui-base из NSH): навигация, диалоги, элементы управления и строка состояния; приложения на Rust используют его как nsh_base. |
nsh-js | Хост QuickJS и среда выполнения NSH: реактивный граф, рендерер, все нативные модули, глобальные API, горячая замена модулей, ограничения; а также объявления TypeScript для всего этого. |
nsh-engine | Поток движка: управляет nsh-js и nsh-core и передаёт платформе один пакет за кадр. |
nsh-package | Подписанные пакеты: ключи, сертификаты, отзыв, хранилище доверия. |
nsh-bundler | Сборщик: rolldown, Oxc, JSX-компилятор NSH, source map, проверки классов Tailwind. |
nsh-dev | Сервер разработки и его клиент. |
nsh-platform-ios | Бэкенд UIKit (iOS, симулятор, Mac Catalyst): монтирование представлений, текстовый движок CoreText, ввод; SDK для встраивания. |
nsh-platform-android | Бэкенд Android: libnsh.so и среда выполнения на Java (NshView, представления, изображения, сеть). |
nsh-cli | Команда nsh. |
Направление зависимостей: nsh-core ← nsh-js ← nsh-engine ← платформы; nsh-base строится на nsh-core. Ядро не зависит ни от платформы, ни от JS-движка,
что позволяет nsh test запускать настоящую среду выполнения без интерфейса на любой машине.