Документация
Производительность
Производительность — первый приоритет NSH, и большая её часть определяется самим фреймворком, а не вашим приложением: среда выполнения нативная, каждое обновление пересекает границу потоков один раз, анимации и виртуализация списков работают без JS, а приложение на Rust вообще не запускает JS. На этой странице описаны правила, которым следует NSH, что они означают для кода, который вы пишете, на Rust и на TSX, и как проводить измерения.
#Правила
#1. JS делает как можно меньше
Приложение на Rust — это нативный код от начала до конца: его компоненты, состояние и обработчики компилируются движком NSH, и никакой интерпретатор не запускается (Building with Rust). Это самый быстрый способ написать приложение, и именно его стоит выбирать для требовательной бизнес-логики.
В приложении на TSX QuickJS — это интерпретатор: небольшой, быстро запускающийся и намного более медленный, чем нативный код, при тяжёлой работе. Поэтому в NSH JS — только код вашего приложения. Реактивный граф (сигналы, вычисляемые значения, эффекты), рендерер, который вызывает ваш скомпилированный JSX, движок раскладки, виртуализация списков, анимация и каждый импортируемый вами модуль написаны на Rust. Разбор, сравнение (diff), раскладка и обработка строк происходят нативно.
Эффект поддаётся измерению. Одно и то же приложение-счётчик на прежней JS-среде выполнения NSH и на нативной среде (релизные сборки):
| Прежняя JS-среда | Нативная среда | |
|---|---|---|
| Размер бандла | 14 KB JS / 19.6 KB байткода | 886 B JS / 1.7 KB байткода |
| Выполнение бандла | ~390 µs | ~95 µs |
| Время JS на одно нажатие (медиана) | ~14 µs | ~5 µs |
| Коммит одного нажатия | ~22 µs | ~15 µs |
| Прокрутка списка из 1000 строк, JS на кадр с перепривязкой | ~150 µs | ~77 µs |
То же верно и для компонентов: кнопка, написанная как компонент на Rust, в сравнении с эквивалентной кнопкой на TSX, по 200 штук:
| Кнопка на Rust: JS / ядро | Кнопка на TSX: JS / ядро | |
|---|---|---|
| Первый кадр (операции) | 1.32 ms / 0.77 ms (1015) | 2.32 ms / 0.70 ms (2415) |
| Обновление всех 200 заголовков (операции) | 0.42 ms / 0.42 ms (200) | 0.77 ms / 0.36 ms (600) |
(Измерено на Apple silicon; вторая таблица — на прежней среде выполнения. Обе взяты из базовых показателей производительности NSH.)
Для вашего кода: держите обработчики событий и вычисляемые значения лёгкими. Тяжёлые вычисления (разбор больших объёмов данных, сортировка тысяч элементов при каждом нажатии клавиши) в QuickJS обходятся дороже, чем вы, возможно, привыкли в V8 или JavaScriptCore с JIT. Выполните их один раз, закэшируйте в computed или вынесите с пути взаимодействия.
#2. Пересекайте границу потоков пакетами
Код вашего приложения, на Rust или JS, выполняется в потоке движка и никогда в главном потоке. Один такт, например один обработчик события со всеми его изменениями, превращается в один коммит: среда выполнения рендерит компоненты, выполняет раскладку, сравнивает и формирует один пакет нативных операций над представлениями, который попадает в главный поток за один переход. Нет ни сообщения моста на каждое представление, ни сериализации на каждое свойство.
Для вашего кода: вам не нужно объединять записи ради производительности; десять вызовов set в одном обработчике всё равно дают один коммит, на Rust и на TSX. Используйте batch, когда хотите, чтобы эффекты и вычисляемые значения увидели все записи сразу, а не выполнялись после каждой из них.
#3. Мелкозернистые обновления
Компоненты выполняются один раз, и на Rust, и на TSX. После этого заново выполняются только привязки, которые читают сигнал, когда он меняется: текст, свойство стиля, ветка when (<If>), строки each (<Each>). Ничто не перерисовывает целое поддерево, чтобы выяснить, что изменилось.
use nsh_core::prelude::*;
#[derive(Clone, PartialEq)]
struct Quote {
price: f64,
change: f64,
}
fn price(quote: Signal<Quote>) -> impl IntoElement {
// Runs once. When the quote changes, only the text and the color update.
text(move || format!("{:.2}", quote.with(|q| q.price))).text_color(move || {
if quote.with(|q| q.change < 0.0) { rgb(0xcf222e) } else { rgb(0x1a7f37) }
})
}import type { Accessor } from "nsh";
import { Text } from "nsh";
type Quote = { price: number; change: number };
function Price(props: { quote: Accessor<Quote> }) {
// Runs once. When quote() changes, only the text and the color update.
return (
<Text style={{ color: props.quote().change < 0 ? "#cf222e" : "#1a7f37" }}>
{props.quote().price.toFixed(2)}
</Text>
);
}Для вашего кода:
- Читайте сигналы там, где они используются (внутри привязок, замыканий
move ||в Rust и JSX-выражений в TSX, а также вcomputedиeffect), а не в начале компонента, чтобы обновлялась самая узкая привязка. - Используйте
each/<Each>(строки сохраняются по ключу) или виртуальный список для коллекций; строки, заново создаваемые из всей коллекции (dynamicв Rust, обычный.map()в JSX), при каждом изменении создаются все заново. - Выводите значения через
computed, когда их используют несколько привязок, чтобы они вычислялись один раз на изменение.
Добавление четвёртого элемента в список из трёх, как это показывает nsh test --trace:
// 22 mount items: 3 views removed, 4 created
dynamic(move || items.get().into_iter().map(|n| text(format!("row {n}"))).collect::<Vec<_>>())
// 4 mount items: 1 view created
each(move || items.get(), |n: &u32| *n, |n, _| text(move || format!("row {}", n.get()))){items().map((n) => <Text>{`row ${n}`}</Text>)} // 22 mount items: 3 views removed, 4 created
<Each of={items()}>{(n) => <Text>{`row ${n()}`}</Text>}</Each> // 4 mount items: 1 view createdСм. Reactivity.
#4. Не отправляйте лишнего
Ядро отправляет только то, что изменилось: новые представления не получают стиль по умолчанию и пустую маску событий, неизменённые стили и фреймы не отправляются, смена цвета текста не приводит к повторной раскладке текста. Нажатие, меняющее одно число, отправляет один текстовый элемент. Это видно в выводе nsh test --trace (ниже).
#5. Анимируйте нативно
Анимация, управляемая из потока движка (сигнал, записываемый в каждом кадре), стоит коммита на кадр и вызова JS в TSX и подтормаживает, когда поток движка занят. Анимированные значения (Animation: animated_value в Rust, API анимации nsh в TSX) вместо этого запускают анимации нативно:
use nsh_core::prelude::*;
let scroll_y = animated_value(0.0);
view()
.bind_opacity(&scroll_y.interpolate(&[0.0, 120.0], &[1.0, 0.35]))
.bind_translate_y(&scroll_y.interpolate(&[0.0, 120.0], &[0.0, -6.0]));
virtual_list(move || photos.with(Vec::len), move |at| photo_row(photos, at))
.scroll_value(&scroll_y)
.estimated_item_size(px(64.0))
.flex_1();import { List, View } from "nsh";
import { createAnimatedValue } from "nsh";
const scrollY = createAnimatedValue(0);
<View
style={{
opacity: scrollY.interpolate({ inputRange: [0, 120], outputRange: [1, 0.35] }),
translate: [0, scrollY.interpolate({ inputRange: [0, 120], outputRange: [0, -6] })],
}}
/>;
<List each={photos()} scrollValue={scrollY} estimatedItemSize={64} style={{ flex: 1 }}>
{(photo) => <Row photo={photo()} />}
</List>;timing,springиdecayвыполняются в UI-потоке платформы, кадр за кадром, без работы в потоке движка на каждый кадр.scroll_value(scrollValue) записывает смещение прокрутки в анимированное значение в том же кадре; жест панорамирования (pan={{ x, y }}) позволяет перетаскиванию двигать представление напрямую. Ваш код узнаёт о завершении (анимация завершена, по окончании панорамирования передаётся скорость).Animated.sequence/parallel/stagger/loopвыполняются как одно нативное дерево: каждый шаг начинается в момент окончания предыдущего, без паузы в ожидании JS.LayoutAnimation.configureNextнативно анимирует фреймы, вставки и удаления следующего коммита.
#6. Виртуализируйте длинные списки
List хранит только строки рядом с областью просмотра, переиспользует их ячейки и вычисляет позиции, колонки, закреплённые заголовки и видимость нативно. Кадры прокрутки, в которых не появляется новая строка, не выполняют ваш код и не создают пакет; кадр, привязывающий одну новую строку списка фотографий в галерее, — это 4 операции и 4 элемента монтирования.
Для вашего кода:
- Задавайте
estimated_item_size(estimatedItemSize) близким к реальной высоте строки. - Храните состояние каждого элемента в ваших данных (по id), а не в сигналах, созданных внутри строки: ячейки переиспользуются для других элементов, и позиция строки (
at.index();item()/index()в TSX) меняется вместе с этим. - Делайте строки дешёвыми: строка привязывается один раз на каждое переиспользование, поэтому её код выполняется, пока пользователь прокручивает.
- Используйте
scroll_value(scrollValue) вместоon_scroll(onScroll) для эффектов, следующих за положением прокрутки. Слушатель прокрутки получает не более одного события за такт движка, но каждое из них всё равно является вызовом вашего кода (в JS, в случае TSX).
#Измерения
#Без интерфейса: --trace
В тесте на Rust EngineConfig::trace выводит те же строки perf, а Headless::observe видит каждый пакет (Testing). nsh test --trace выводит их для TSX:
echo 'await app.press("Add")' | nsh test App.tsx --trace # first frame + a press
echo 'await app.scroll("id:feed", 3000)' | nsh test App.tsx --trace # scrolling, 10 pt per frame
perf commit: 56 ops, 395µs (list js 6µs), 49 mount items, 21 nodes
perf first frame: eval 271µs, total 1235µs
perf event js: 88µs
perf commit: 10 ops, 151µs (list js 2µs), 8 mount items, 27 nodes
На что смотреть:
- ops (операции, поставленные в очередь вашим JS) и mount items (нативные операции над представлениями): они детерминированы. Если нажатие, которое должно менять одну метку, порождает 40 элементов, значит, что-то пересоздаёт представления, которые следовало обновить.
- event js: стоимость JS обработчика и того, что он вызвал.
- commit: стоимость работы ядра по рендерингу компонентов, раскладке и сравнению.
- list js: время JS, потраченное на привязку строк списка во время этого коммита.
Полное руководство: Testing.
#На устройстве: --trace
nsh run App.tsx --trace
nsh run App.tsx --target android --trace --release
выводит те же строки коммитов из реального хоста приложения на TSX с собственным текстовым движком платформы. NshAppView(trace: true) и new NshView(context, true) делают то же самое во встраивающем приложении.
#На экране: --fps
nsh run App.tsx --target macos --fps --release
(AppOptions { fps: true, .. } в run_app приложения на Rust) показывает частоту кадров и стоимость кадров и коммитов поверх работающего приложения, а также CPU, GPU и память (macOS: performance monitor). На Linux и Windows --target linux --fps и --target windows --fps выводят те же показания раз в секунду со временем каждой фазы кадра; nsh test --target linux --fps -v (или windows) снимает их, пока тест управляет приложением.
#Справочные числа
Измеренные числа, базовые показатели NSH и галерея страница за страницей на Linux приведены в отчёте о производительности.
#Чек-лист
- Язык: пишите приложение или наиболее используемые его части на Rust (Building with Rust); TSX оставьте для того, что часто меняется или доставляется во время выполнения.
- Взаимодействия: нажатие меняет несколько элементов монтирования, а не десятки.
- Списки: виртуальный список (
virtual_list,List) для всего длинного, с оценочным размером элемента; состояние каждого элемента — в данных. - Анимации: анимированные значения, а не сигналы, записываемые в каждом кадре.
- Эффекты прокрутки:
scroll_value(scrollValue), а не слушатель прокрутки. - Обработчики: никакой тяжёлой работы на каждое нажатие клавиши или событие прокрутки; кэшируйте в
computed.