Документация
Руководства по написанию кода
В этом руководстве описаны архитектура приложения и приёмы написания кода, которые зарекомендовали себя как долговечные в приложениях Native Shell. Оно рассчитано и на инженеров, и на coding-агентов: на разработчиков приложений NSH (модули TSX, компоненты на Rust, нативные приложения со встроенным NSH) и на авторов библиотек компонентов, построенных на nsh/base. Сначала прочитайте руководства по дизайну: структура кода должна сохранять замысел продукта, а не подменять его.
Это нормативное руководство. Обязательно отмечает ограничения жизненного цикла, корректности или экосистемы; следует обозначает архитектуру по умолчанию, отступать от которой нужно только по конкретной причине. Точные сигнатуры по-прежнему определяются текущим исходным кодом, справочными страницами и объявлениями в .nsh/types.
#Сделайте типичный путь дешёвым
Принцип проектирования: выбирайте типы фреймворка и модель владения так, чтобы обычное использование компонентов было дешёвым по умолчанию. Разработчик должен иметь возможность строить и переиспользовать UI, не добавляя кэш в каждое место вызова. Примеры: компоненты, которые выполняются один раз, и сигналы, читаемые там, где они используются. Компонент один раз строит свою часть дерева, а затем повторно выполняются только те привязки, которые читают изменившийся сигнал, поэтому на типичном пути нет повторной работы. Реактивный граф, рендерер, раскладка и списки нативные, а один проход кода приложения превращается в один пакет нативных операций. Тем не менее небольшие затраты умножаются на число элементов и обновлений, особенно в TSX, код которого выполняется в интерпретаторе; прежде чем добавлять кэши, измеряйте исключительные горячие пути (Производительность). Ни один из этих подходов не обещает нулевой стоимости. Механику объясняют правила владения состоянием и последующие разделы о рендеринге.
#Архитектура в общих чертах
Зависимости направлены вниз. Верхние слои отвечают за доменный смысл и оркестрацию; нижние слои отвечают за переиспользуемое представление или поведение. Не заставляйте переиспользуемый компонент зависеть от страницы приложения, а библиотеку компонентов — от темы одного приложения (Три слоя).
Используйте следующие границы:
- оболочка приложения: точка входа (
App.tsxилиnsh.json"root") собирает корень и стеки навигации, не включая в себя логику страниц; - страница: один
NavPageсо своим содержимым, своими диалогами и запускаемой им работой; - компонент приложения: повторяющийся шаблон, учитывающий предметную область;
- библиотека компонентов: стилизованный UI общего назначения, собственный или от сообщества, построенный на
nsh/base; nsh/base: переиспользуемое поведение без продуктового представления: навигация, диалоги, всплывающие окна, меню, шторки, цель нажатия;nsh: примитивы, стили, раскладка, реактивность и окружение.
#Организуйте файлы приложения
Файлы приложения следуют соглашениям NSH, поэтому ничего настраивать не нужно: App.tsx является точкой входа, рядом с ним лежит nsh.json, и оба находятся по соглашению.
App.tsx # the entry, by convention: mount(App)
nsh.json
Cargo.toml # the app's crate, with Rust components ("rust": ".")
src/
lib.rs
pages/
Inbox.tsx
Compose.tsx
Settings.tsx
components/
Card.tsx
store.ts
pages/содержит по одному файлу на каждыйNavPage. Страница разрастается на несколько файлов, только когда в этом есть необходимость.components/содержит переиспользуемые компоненты приложения, которые используют несколько страниц.store.tsсодержит состояние приложения: его сигналы и функции, которые их изменяют. Страницы читают сигналы и вызывают функции.- Тесты — это файлы
*.test.tsxрядом с тем, что они тестируют (App.test.tsx,pages/Inbox.test.tsx). Cargo.tomlиsrc/lib.rs— собственный crate приложения с его компонентами на Rust (nsh.json"rust": "."); его#[native_module]вsrc/lib.rsзадаёт, что импортирует TSX. Приложение, написанное только на Rust, — это тот же проект Cargo, где корневой компонент находится вsrc/lib.rs(Структура проекта).
Оболочка приложения собирает страницы, но почти не содержит логики страниц. Страницы читают сигналы хранилища и вызывают его функции; они никогда не записывают состояние другой страницы. Держите сигналы хранилища приватными и экспортируйте только то, что их читает и изменяет, чтобы изменяемое состояние оставалось за его публичными функциями:
/// The app's workspace: its state stays behind these functions.
#[derive(Clone, Copy)]
pub struct Workspace {
name: Signal<SharedString>,
}
impl Workspace {
/// A workspace named "Untitled".
pub fn new() -> Self {
Self { name: signal(SharedString::from("Untitled")) }
}
/// Its name (tracked where it is read).
pub fn name(&self) -> SharedString {
self.name.get()
}
/// Renames it.
pub fn rename(&self, name: impl Into<SharedString>) {
self.name.set(name.into());
}
}// store.ts
import { signal } from "nsh";
const name = signal("Untitled");
/** The workspace's name (tracked where it is read). */
export const workspaceName = () => name();
/** Renames the workspace. */
export function renameWorkspace(next: string) {
name.set(next);
}Страница может хранить рабочее пространство и передавать его идентификатор в поиск; поиску не нужны вся коллекция рабочего пространства или его приватные сигналы. Простая ациклическая форма зависимостей выглядит так:
App.tsx ──▶ pages/ ──▶ store.ts
└──────▶ components/ ──▶ nsh/base, nsh
Страницы могут зависеть от хранилища и компонентов, но не от оболочки и не от приватных частей другой страницы. Если одна страница должна изменить то, что показывает другая, делайте это через хранилище. Такие границы позволяют разным инженерам или ИИ-агентам работать над отдельными страницами параллельно, с меньшим числом конфликтов файлов и более слабой связанностью через общее состояние; само хранилище по-прежнему требует согласованности.
#Начальная загрузка и владение корнем
Монтируйте приложение один раз и отдавайте корню всю поверхность: точка входа TSX вызывает mount(App), приложение на Rust указывает свой корневой компонент в nsh.json ("root": "App"). Корень заполняет поверхность с помощью flex: 1 и фона из темы:
#[component]
pub fn App() -> impl IntoElement {
provide_context(Workspace::new());
view()
.flex_1()
.bg(move || theme().background)
.child(inbox())
}import { mount, View } from "nsh";
mount(() => (
<View class="flex-1 bg-background">
<App />
</View>
));Корень координирует всё, что относится ко всей поверхности: стек навигации, диалоги, всплывающие окна, меню, шторки и подсказки, которые показываются поверх всего содержимого, а также безопасную область. Не монтируйте отдельный корень для каждой страницы внутри одной поверхности. Одна поверхность — это один движок: каждый NshAppView, NshView или AppView, который встраивает нативное приложение, запускает собственную VM, собственное состояние модулей и собственный реактивный граф, поэтому несколько поверхностей в одном приложении-хосте никогда не разделяют состояние случайно.
#Поймите, что выполняется один раз, а что выполняется снова
NSH — это сохраняемое состояние с декларативным построением, на модели Solid (Реактивность). Сигнал сохраняется между обновлениями; тело компонента один раз описывает свою часть дерева при создании компонента. Держите это различие явным:
| Что вы пишете | Выполняется | Выполняется снова, когда |
|---|---|---|
| Тело компонента | Один раз, при создании | Никогда |
| Привязка: замыкание (Rust) или выражение, вызывающее функцию или читающее свойство (TSX), там, где ожидается значение | Один раз | Изменился сигнал, который она прочитала; обновляется только это свойство или этот дочерний элемент |
computed(fn) | При создании | Изменился сигнал, который он прочитал |
effect(fn) | После того как элементы данного прохода существуют | Изменился сигнал, который он прочитал |
Строка each / <Each> | Когда её ключ появляется впервые | Никогда; изменяются сигналы её элемента и индекса |
Ячейка virtual_list / <List> | Один раз на ячейку | Никогда; ячейке передаются другие строки |
- компонент, который выполняется, является владельцем: эффекты, вычисляемые значения, задачи, таймеры, привязки клавиш и очистки, созданные во время его выполнения, принадлежат ему и освобождаются вместе с ним;
- контекст (
provide_context/use_context,context()и<Ctx.Provider>) передаёт значение поддереву, не подразумевая, что элементы, читающие его, им владеют; Scopeкомпонента на Rust (cx) генерирует его события и читает переданные ему отступы;- ссылки (
element_ref(),ref) обозначают элемент хоста для императивных вызовов (focus(),scroll_to(),measure()); - раскладка и измерение происходят позже, в нативных фазах; используйте
onLayoutиmeasure()только тогда, когда действительно нужна вычисленная геометрия.
Никогда не читайте контекст в обработчике событий или таймере, где нет компонента, от которого его можно искать: читайте его в теле компонента и сохраняйте полученное. Сохраняйте типизированные дескрипторы (сигналы, ресурсы, ссылки, доменные идентификаторы), которые в Rust являются Copy и перестают что-либо делать, как только их владелец уничтожен.
#Выберите подходящую единицу
#Используйте обычную функцию для элементов-значений
По умолчанию используйте обычную функцию для лёгких переиспользуемых фрагментов UI. Вызывающий код передаёт актуальные свойства и обработчики, функция возвращает элементы, и от неё не остаётся ничего, кроме созданных ею привязок. Её аргументы — это её свойства; отображаемая подпись, значение и колбэки берутся из текущего состояния вызывающего кода. Как и любой компонент, она выполняется один раз: впоследствии изменяются её привязки.
fn empty_state(title: impl Into<SharedString>) -> impl IntoElement {
view()
.gap_2()
.items_center()
.child(text(title.into()).text_color(move || theme().muted_foreground))
}function EmptyState(props: { title: string }) {
return (
<View class="items-center gap-2">
<Text class="text-muted-foreground">{props.title}</Text>
</View>
);
}#Используйте компонент для сохраняемого поведения
Используйте #[component] (или компонент TSX, создающий сигналы), когда фрагмент UI владеет постоянным состоянием или независимым жизненным циклом: сигналами, эффектами, асинхронной работой, таймерами, привязками клавиш. Создавайте это состояние в теле компонента, один раз, и никогда внутри привязки.
#[derive(Debug, Clone, PartialEq, Props)]
pub struct SearchProps {
/// Shown while the field is empty.
pub placeholder: Prop<SharedString>,
}
#[component]
pub fn Search(props: SearchProps) -> impl IntoElement {
let query = signal(String::new());
input()
.value(move || query.get())
.on_change(move |text: &str| query.set(text.to_owned()))
.placeholder(move || props.placeholder.get().to_string())
}function Search(props: { placeholder: string }) {
const query = signal("");
return <Input value={query()} onChange={({ value }) => query.set(value)} placeholder={props.placeholder} />;
}#[component] имеет типизированные свойства (#[derive(Props)]) и события (#[derive(Event)]); TSX импортирует его с помощью объявлений, сгенерированных на их основе. Сохраняемое состояние, которое TSX использует через new (реестр, парсер, сессия), — это #[class], а функции — это #[module] (Разработка на Rust). Части, которые приложение использует чаще всего, библиотеки компонентов и требовательную логику пишите на Rust, который от начала до конца является нативным кодом; TSX оставляйте для того, что часто меняется или доставляется во время выполнения.
Не превращайте каждый визуальный фрагмент в компонент. У границ компонентов есть издержки жизненного цикла и координации; используйте их там, где важна сохраняемая идентичность.
#Элементы, представления и системы поведения — это разные вещи
Не пытайтесь подогнать каждый компонент под один шаблон. Экосистема включает:
- семантические элементы, такие как
Button,SwitchиMenuItem; - составные корни поведения, такие как
Dialog,Popover,Menu,ContextMenu,SheetиNavStack; - системы с состоянием, такие как
Input,List,TextViewи состояние стека навигации; - инфраструктуру, такую как оверлеи и их размещение, виртуализация, прокрутка, безопасные области, клавиатура и анимация.
Элемент может быть внутренне сложным и всё равно оставаться значением для вызывающего кода. Система с состоянием может предоставлять функции рендеринга, чтобы приложения владели представлением, не реализуя поведение заново. Выбирайте публичный шов исходя из поведения, а не из того, сколько представлений появляется в его выводе.
#Владение состоянием
#Владейте постоянным текстом UI через SharedString
В Rust обязательно предпочитайте SharedString для текста UI, сохраняемого между обновлениями или замыканиями колбэков, включая подписи компонентов, заголовки и плейсхолдеры: свойства принимают его (Prop<SharedString>), а клонирование его в привязку не копирует сам текст. Для изменяемого буфера редактирования или текста, собираемого с форматированием, используйте String и преобразуйте результат, когда он готов; для временного доступа только на чтение в рамках одного вызова используйте &str. В TSX строки — это собственные значения приложения; текст, вычисляемый из состояния, держите в computed или замыкании, а не пересобирайте его в нескольких местах.
Для неизменяемого текста ответа, который UI будет сохранять, храните полученное значение нетронутым, а производный или отформатированный текст для отображения помещайте в отдельное доменное значение, не переписывая сам ответ.
Помещайте каждое состояние в самого узкого владельца, который может поддерживать его корректность:
- доменное состояние принадлежит хранилищу (
store.ts) или crate приложения; - временное состояние представления принадлежит компоненту, который его отображает, в виде сигнала;
- переиспользуемое поведенческое состояние принадлежит компоненту, созданному для него (
MenuилиPopoverбезopenхранит своё собственное); - состояние, общее для поддерева, — это сигнал, предоставляемый через контекст;
- то, что переживает запуск, принадлежит хранилищу или серверу.
Для обычного выбора и переключателей предпочитайте контролируемые значения: передайте текущее значение в компонент, получите запрошенное изменение, обновите владельца, и привязки последуют за ним. Колбэк сообщает о намерении; он не должен создавать второй скрытый источник истины.
switch()
.checked(show_hidden)
.on_change(move |next: &bool| show_hidden.set(*next))
.child(text("Show hidden files"))<Switch checked={showHidden()} onChange={({ checked }) => showHidden.set(checked)}>
<Text>Show hidden files</Text>
</Switch>Уведомлять никого не нужно: запись в сигнал обновляет ровно те привязки, которые его читают. Компонент на Rust генерирует семантическое событие, которое его владелец должен обработать через cx.emit(..); эффект следует за сигналом, пока жив его владелец. Несколько полей, образующих один инвариант, записывайте в одном batch, чтобы эффекты и вычисляемые значения видели их одновременно.
Собственный контролируемый компонент приложения следует тому же контракту. Пример — счётчик количества (quantity stepper), доменный элемент управления, написанный один раз на Rust и размещаемый из Rust и TSX:
use nsh_base::{button, theme};
use nsh_core::prelude::*;
/// The props of [`QuantityStepper`].
#[derive(Debug, Clone, PartialEq, Props)]
pub struct QuantityStepperProps {
/// The quantity: the app owns the value.
#[prop(default)]
pub value: Prop<u32>,
/// The smallest quantity (0 when left out).
#[prop(default)]
pub min: Prop<u32>,
/// The largest quantity (99 when left out).
#[prop(default = 99)]
pub max: Prop<u32>,
/// What screen readers say for the button that takes one away.
pub decrement_label: Prop<SharedString>,
/// What screen readers say for the button that adds one.
pub increment_label: Prop<SharedString>,
}
/// What a [`QuantityStepper`] reports.
#[derive(Debug, Clone, PartialEq, Event)]
pub enum QuantityStepperEvent {
/// The user asked for another quantity; the owner sets `value`.
Change(u32),
}
/// A quantity with a button to take one away and one to add one.
#[component]
pub fn QuantityStepper(props: QuantityStepperProps, cx: Scope<QuantityStepperEvent>) -> impl IntoElement {
// Read untracked: the user's act, measured against the owner's current value.
let step = move |delta: i64| {
let value = props.value.get_untracked();
let (min, max) = (props.min.get_untracked(), props.max.get_untracked());
let next = (i64::from(value) + delta).clamp(i64::from(min), i64::from(max)) as u32;
if next != value {
cx.emit(QuantityStepperEvent::Change(next));
}
};
view()
.flex_row()
.items_center()
.gap_3()
.child(step_button(
"−",
move || props.decrement_label.get(),
move || props.value.get() <= props.min.get(),
move || step(-1),
))
.child(text(move || props.value.get().to_string()).text_color(move || theme().foreground))
.child(step_button(
"+",
move || props.increment_label.get(),
move || props.value.get() >= props.max.get(),
move || step(1),
))
}
fn step_button(
symbol: &'static str,
label: impl Fn() -> SharedString + 'static,
disabled: impl Fn() -> bool + 'static,
on_press: impl Fn() + 'static,
) -> impl IntoElement {
button()
.disabled(disabled)
.accessibility_label(move || Some(label()))
.on_press(on_press)
.w(px(32.0))
.h(px(32.0))
.items_center()
.justify_center()
.rounded(move || theme().radius)
.bg(move || theme().muted)
.active(|s| s.opacity(0.6))
.disabled_style(|s| s.opacity(0.4))
.child(text(symbol).text_color(move || theme().foreground))
}
// The owner:
let quantity = signal(1_u32);
quantity_stepper()
.value(quantity)
.min(1)
.max(10)
.decrement_label("Fewer")
.increment_label("More")
.on_change(move |next: &u32| quantity.set(*next))import { signal } from "nsh";
import { QuantityStepper } from "my-app"; // the Rust component: src/lib.rs has #[native_module("my-app")]
function CartRow() {
const quantity = signal(1);
return (
<QuantityStepper
value={quantity()}
min={1}
max={10}
decrementLabel="Fewer"
incrementLabel="More"
onChange={({ value }) => quantity.set(value)}
/>
);
}Тот же контракт в TSX, компонент в components/:
import { Text, View } from "nsh";
import { Button } from "nsh/base";
export interface QuantityStepperProps {
/** The quantity: the app owns the value. */
value: number;
/** The smallest quantity (0 when left out). */
min?: number;
/** The largest quantity (99 when left out). */
max?: number;
/** What screen readers say for the button that takes one away. */
decrementLabel: string;
/** What screen readers say for the button that adds one. */
incrementLabel: string;
/** The user asked for another quantity; the owner sets `value`. */
onChange?: (e: { value: number }) => void;
}
export function QuantityStepper(props: QuantityStepperProps) {
const min = () => props.min ?? 0;
const max = () => props.max ?? 99;
const step = (delta: number) => {
const next = Math.min(max(), Math.max(min(), props.value + delta));
if (next !== props.value) props.onChange?.({ value: next });
};
const buttonClass = "size-8 items-center justify-center rounded-(--radius) bg-muted active:opacity-60 disabled:opacity-40";
return (
<View class="flex-row items-center gap-3">
<Button disabled={props.value <= min()} accessibilityLabel={props.decrementLabel} onPress={() => step(-1)} class={buttonClass}>
<Text class="text-foreground">−</Text>
</Button>
<Text class="text-foreground">{props.value}</Text>
<Button disabled={props.value >= max()} accessibilityLabel={props.incrementLabel} onPress={() => step(1)} class={buttonClass}>
<Text class="text-foreground">+</Text>
</Button>
</View>
);
}
#Ограничивайте связи владения и подписки
Компонент владеет тем, что создаёт, и всё созданное исчезает вместе с ним: когда скрывается его ветка <If>, удаляется его строка или снимается его страница. В Rust задачи, таймеры и интервалы (spawn, set_timeout, set_interval) и привязки клавиш (on_key) заканчиваются вместе с владельцем, под которым были созданы; в TSX регистрируйте остановку через
cleanup или возвращайте её из эффекта. Подписка выполняется через on(event, listener)
(host.on(name, listener), BackHandler.on("press", listener)), которая возвращает функцию, останавливающую её
(() => void, из Rust это Stop), а то, что регистрируется во время рендера компонента, исчезает вместе с компонентом, как и привязки keymap.bind.
Если что-то неожиданно продолжает работать, сначала проверьте эффекты и вычисляемые значения, созданные вне компонента (в обработчике события или в колбэке таймера у них нет владельца, и они живут вечно), слушатели, зарегистрированные без их остановки, и работу, запущенную из хранилища, которым не владеет ни один компонент. Отброшенная загрузка resource прерывается (info.signal в TSX); она не удерживает свой компонент живым. Это типичные места, с которых стоит начать, а не исчерпывающий список.
Не записывайте сигнал только потому, что значение было прочитано или выведено. Избегайте безусловной записи из привязки или эффекта, который читает тот же сигнал: он запускает сам себя снова, пока NSH не остановит его с ошибкой. Значение, вычисляемое из другого состояния, — это computed, а не эффект, который записывает второй сигнал.
#Избегайте циклов обратной связи состояния
У текстового ввода, выбора, фильтров и управляемых всплывающих окон обычно два пути: внешний владелец обновляет значение, а действие пользователя запрашивает новое. Не отправляйте значение, предоставленное владельцем, обратно через пользовательский колбэк во время синхронизации. При обработке действия пользователя читайте текущее значение владельца без отслеживания (props.value.get_untracked() в Rust, как выше; обработчик событий в TSX уже не отслеживается) и сравнивайте его со следующим, чтобы каждое логическое изменение сообщалось один раз. Делайте колбэки безопасными для повторного входа, если колбэк может синхронно закрыть, заменить или обновить компонент, который его вызвал.
#Стабильная идентичность
Ключ — часть поведения. Он задаёт строке, странице или повторяющемуся элементу управления стабильную идентичность и определяет, какой из них сохраняет своё состояние, фокус и измерения при изменении данных.
- Используйте стабильные доменные идентификаторы для строк, вкладок, страниц и повторяющихся элементов управления:
each(projects, |p| p.id, ..),<Each of={projects()} key={(p) => p.id}>,keyExtractorсписка. - Добавляйте к тестовым идентификаторам пространство имён владеющего объекта, когда один и тот же элемент управления повторяется (
delete-${project.id}). - Никогда не выводите идентичность из переведённой подписи или изменяемого индекса списка, если элементы можно вставлять или переупорядочивать.
- Не генерируйте новый случайный ключ или идентификатор во время рендеринга.
each(projects, |p| p.id, move |project, _index| {
let id = project.get_untracked().id;
button()
.on_press(move || delete(id))
.child(text("Delete").test_id(&format!("delete-{id}")))
})<Each of={projects()} key={(p) => p.id}>
{(project) => (
<Button onPress={() => remove(project().id)}>
<Text testID={`delete-${project().id}`}>Delete</Text>
</Button>
)}
</Each>Изменённый ключ означает изменённую идентичность UI. Считайте такой сброс намеренным.
То же правило действует для маршрутов навигации, ключей списков, ключей хранилища и тестовых идентификаторов. Маршрут, остающийся в setRoutes,
сохраняет свою страницу и своё состояние; если два независимо сохраняемых поведения используют один ключ, они могут перезаписать состояние друг друга; если поведение меняет ключи при каждом обновлении, оно никогда не накапливает состояние.
#Рендеринг и композиция
Держите тело компонента декларативным: читайте текущее состояние, выводите значения представления и компонуйте элементы. Выносите доменные операции, разбор и нетривиальные изменения в именованные функции хранилища или крейта приложения.
#[component]
pub fn ProjectPage() -> impl IntoElement {
view()
.flex_1()
.child(toolbar())
.child(content())
}function ProjectPage() {
return (
<View class="flex-1">
<Toolbar />
<Content />
</View>
);
}Выделяйте вспомогательную функцию, когда она даёт имя значимому участку и уменьшает объём состояния, который читателю нужно держать в голове. Выделяйте новый компонент, когда у участка есть собственный переиспользуемый контракт или сохраняемый жизненный цикл, а не просто потому, что цепочка билдера или блок JSX получились длинными.
Последовательно используйте типизированные методы стилей и билдеры компонентов в Rust и классы в TSX. Предпочитайте
.when(..) / .when_some(..) (Rust) и classList (TSX) для небольших условных уточнений; используйте when / <If>,
когда ветки представляют существенно разные интерфейсы, поскольку тернарный оператор в замыкании строит свою ветку заново при каждом изменении того, что он читает.
Сначала компонуйте из стандартного семантического компонента, а не создавайте собственную поверхность. Не воспроизводите меню, поповер, лист или диалог из обычных представлений только ради совпадения с одним скриншотом: Menu, ContextMenu, Popover, Sheet,
Tooltip и Dialog из nsh/base сохраняют размещение, передачу фокуса, навигацию с клавиатуры, выбор, отключённое состояние, закрытие по Escape и «назад», а также контракт доступности. Если стандартный компонент не может выразить повторяющийся допустимый паттерн, улучшайте его явный API вместо стилизации произвольных потомков в каждом месте вызова.
Функции рендеринга, передаваемые кодом приложения, должны быть свободны от побочных эффектов. Строка списка, TextView у renderCodeBlock или элементы меню могут выполняться всякий раз, когда их владельцу нужна ячейка, часть или открытие. Они не должны выполнять бизнес-операцию, добавлять данные или запускать неограниченную подписку.
#Граница между поведением и представлением
Долговременное правило Base:
Base владеет переиспользуемым поведением и структурой, необходимой для его реализации. Слой представления владеет визуальным языком продукта.
«Без стилей» не означает «одно пустое представление». Размещение оверлеев, навигация с клавиатуры, редактирование, виртуализация, навигация назад, закрытие и безопасные зоны требуют внутренней структуры и состояния. Перекладывание этой работы на каждого вызывающего не создало бы гибкости, а продублировало бы хрупкое поведение.
И наоборот, Base не должен выбирать фирменные цвета, типографику, плотность, итоговые значки, варианты компонентов, строки или композицию приложения. Предоставляйте представление через class / style для главной части, <part>Class /
<part>Style для каждой другой части, типизированные стили состояний (active, hover, focus, selected, checked, disabled),
именованные слоты (trigger, header, footer) и функции рендеринга. Не инспектируйте произвольных потомков, чтобы найти заголовки, описания или кнопки закрытия: делайте семантические части явными и принимайте каждую подпись как проп или дочерний элемент.
#Тема и стилизация
Читайте семантические значения из темы и применяйте раскладку через типизированные методы стилей в Rust и классы Tailwind в TSX (Tailwind, токены темы):
view()
.bg(move || theme().background)
.text_color(move || theme().foreground)
.border_1()
.border_color(move || theme().border)
.rounded(move || theme().radius)<View class="rounded-(--radius) border border-border bg-background text-foreground" />Правила:
- не задавайте жёстко фирменные цвета, радиусы скругления, отступы или геометрию элементов управления;
- код приложения не должен вводить «сырые» значения hex,
rgb/rgbaилиhsla; читайте семантический цвет изtheme()или класса токена (bg-primary) либо добавьте недостающую роль в файл темы приложения; - раскладка приложения должна использовать шкалы отступов и типографики (
p-2,gap-3,w-64,text-sm;.p_2(),.gap_3(),.w_64(),.text_sm()) вместо произвольных значений (p-[13px],px(13.0)); - классы означают ровно то, что они означают в Tailwind CSS v4, а значения стилей — то, что даёт им
справочник по стилям (как в CSS, но в нативных формах: числа — это пункты); предупреждение
unknown class— это находка, которую нужно исправить; - используйте семантические токены для смысла, а не для позиции в палитре;
- держите геометрию, не зависящую от состояния, в обычной цепочке билдера или строке классов;
- используйте
active:,hover:иfocus:(.active(..),.hover(..),.focus(..)) для состояний взаимодействия во время выполнения; они применяются нативно, без обращения к коду приложения; - используйте семантические стили состояний компонента (
selected:,checked:,disabled:) для вида отмеченного, выбранного, нажатого или отключённого состояния; - держите владение всплывающим окном в явном состоянии (
openвместе сonOpenChange), чтобы его триггер мог отображать открытый или нажатый вид до закрытия; - держите варианты компонентов немногочисленными и осмысленными, а не добавляйте вариант под каждое место вызова;
- привязывайте основной стиль к реальному действию по умолчанию области решения и её действию клавиши return; не выводите его из количества действий, частоты или положения на панели инструментов;
- держите варианты badge и alert семантическими и редкими. Обычные метаданные остаются нейтральными; не сопоставляйте каждому значению enum или разделу свой цвет только потому, что вариант существует.
Эффективный приоритет таков: собственный стиль элемента, затем стили состояний, где более поздние состояния побеждают, а disabled
выше всех. Более поздние слои заменяют только те поля, которые задают.
Для нового оформления, принадлежащего приложению, предпочитайте семантические токены темы. Каждое имя в файле темы приложения — это токен, как и переменная Tailwind @theme ("up": "#16a34a" даёт text-up, bg-up/30, token("up") в TSX и
theme().color("up") в Rust), а у темы есть светлые и тёмные значения, поэтому токен следует за внешним видом без класса
dark:. Называйте токены по ролям, а не по компонентам, чтобы они не превращались в точку расширения для каждого виджета.
Меняйте тему через файл темы приложения (nsh.json "theme") или позвольте встраивающему приложению предоставить свой: классы, называющие токены, и привязки Rust, читающие theme(), перестилизуются нативно при каждом его изменении, без выполнения JavaScript. Код, копирующий значение токена в собственный сигнал, перестаёт ему следовать.
Внешнему кольцу фокуса нужно физическое место. Предок с overflow: hidden (overflow-hidden) обрезает его. Предпочитайте раскладки, оставляющие место; если продукту необходимо сильное обрезание, рисуйте состояние фокуса внутри элемента управления, а не молча скрывайте весь фокус клавиатуры.
#Размер текста пользователя — это масштаб приложения
Текст следует размеру текста пользователя (Dynamic Type, масштаб шрифта в Android, настройки десктопов): fontScale() в TSX,
font_scale() в Rust, оба реактивны. Шкала типографики (text-sm, text-base: 16 пунктов, размер основного текста) задаёт опорное значение, а настройка пользователя масштабирует его. Это сознательно следует полезной части модели Tailwind: именованные ступени типографики, отступов и размеров вместо несвязанных констант в пунктах.
Позволяйте текстовым контейнерам расти вместе с текстом: задавайте им минимальную высоту и отступы, а не фиксированную высоту. Пользовательский компонент, сочетающий масштабируемый текст с фиксированной геометрией вокруг него, должен документировать, почему эта часть не должна расти.
Считайте каждое произвольное значение в пунктах и каждый конструктор «сырого» цвета в UI приложения находкой для ревью. Допускайте их только для документированной физической или платформенной границы, измеряемой геометрии времени выполнения, растрового или данных цвета либо самого определения темы. Удобство и совпадение со скриншотом не являются допустимыми исключениями.
Всё, что кэшируется из вычисленной раскладки, должно включать масштаб шрифта в ключ инвалидации, напрямую или через ревизию, которая меняется вместе с ним: высоты строк, о которых сообщили списку, измерения текста, геометрию всплывающих окон, размеры значков, выведенные из текста, и пользовательские метрики canvas.
#События, команды и фокус
Используйте нажатия для активации: onPress, onLongPress, onPressDown и onPressUp означают одно и то же для пальца, пера, мыши и клавиатуры. Обработчики касаний и жесты применяйте только для того, что следует за пальцем (pan, drag). Сочетания клавиш (keymap.bind, on_key) и пункты меню используйте для команд, которые должны поддерживать привязки клавиш, меню или вызов из нескольких источников ввода. Держите обработчики рядом со страницей, которой принадлежит команда.
Описывайте каждую логическую команду один раз. Button, пункт Menu, пункт ContextMenu, пункт меню приложения nsh/desktop и сочетание клавиш должны вызывать одну и ту же функцию store, а не копировать пять мутаций. Где это возможно, выводите их подпись, значок, сочетание клавиш и состояние disabled из единой политики команды (key_label("mod+s") записывает сочетание клавиш так, как принято на платформе), чтобы точки входа не расходились. Меню отвечает за навигацию и закрытие; страница по-прежнему решает, разрешена ли команда и что она делает.
on_key("mod+s", move |_| save());
view()
.child(button().on_press(save).child(text("Save")))
.child(
menu()
.trigger(view().px_3().py_2().child(text("File")))
.child(menu_item().on_select(save).child(text(format!("Save {}", key_label("mod+s"))))),
)keymap.bind("mod+s", save);
<View>
<Button onPress={save}><Text>Save</Text></Button>
<Menu trigger={<View class="px-3 py-2"><Text>File</Text></View>}>
<MenuItem onSelect={save}><Text>Save {keymap.label("mod+s")}</Text></MenuItem>
</Menu>
</View>Сохраняйте семантические роли при выборе элемента. Используйте Button для команд, даже если нужен сдержанный вид; задайте ему неброский стиль вместо замены ссылкой. Приложения NSH резервируют роль link для целей, открываемых за пределами приложения, например URL или адрес электронной почты; для перехода внутри приложения используйте навигацию, а для команды — Button.
Останавливайте распространение события (stopPropagation() для события касания) только тогда, когда вложенное взаимодействие должно помешать родителю обработать то же касание. Повсеместная остановка распространения ломает прокрутку, нажатия и жесты так, что причину трудно найти.
Главный поток никогда не ждёт код приложения. Когда системе нужен ответ немедленно (можно ли закрыть эту страницу, можно ли закрыть этот sheet или popover, можно ли изменить этот текст), NSH отвечает нативно на основе props, смонтированных заранее, а событие сообщает приложению о результате позже. Поэтому решение — это prop, который выставляется, как только решение известно, а не callback, возвращающий true или false: popOnBack={!dirty()} с onBackPress для подтверждения (popOnBack), closeOnEscape и closeOnBackdropPress с onCancel чтобы узнать о попытке, maxLength у поля ввода, disabled у кнопки.
shortcut пункта меню (nsh/desktop) привязывает свою клавишу, пока меню установлено, и меню показывает её в стиле платформы; привязка keymap того же сочетания клавиш срабатывает раньше. Назначайте каждому сочетанию клавиш одного владельца и заново устанавливайте меню приложения, когда меняются его пункты или сочетания клавиш.
Делайте владение фокусом явным:
Button,Switchи пункты меню являются остановками фокуса клавиатуры на десктопах и в вебе; стройте элементы управления на них, а не на «голых» view;- перемещайте фокус с помощью
focus()у ref из события или эффекта, следующего за изменением состояния, а фокус экранного диктора перемещайте с помощьюset_accessibility_focus()(Rust) илиsetAccessibilityFocusизnsh(TSX); - отображайте видимое состояние
focus:; - не запрашивайте фокус безусловно во время рендеринга.
Составные виджеты должны реализовывать полную модель навигации (движение стрелками, Home / End или постраничное перемещение там, где это уместно, подтверждение, отмена и поведение Tab), а не набор разрозненных сочетаний клавиш.
Оверлеи закрываются сверху: Escape и кнопка «Назад» в Android передаются верхнему оверлею, который их обрабатывает (onCancel). Обрабатывайте быстрые последовательности закрытия и открытия так, чтобы не действовать над промежуточной, уже закрывающейся поверхностью.
#Асинхронная работа и побочные эффекты
Запускайте асинхронную работу из события, эффекта или resource, а не как безусловный побочный эффект тела компонента. Эффект отслеживает только то, что читает синхронно: сначала прочитайте сигналы, затем запускайте работу. Когда работа завершается, обновляйте состояние через сигналы, учитывайте, что компонент уже может не существовать, и записывайте согласованные изменения вместе.
Держите повторяющуюся работу под владельцем, чтобы его уничтожение отменяло её: задачи и таймеры Rust завершаются вместе с владельцем, таймеры TSX останавливаются в cleanup. Одноразовую задачу, которая должна завершиться независимо, можно запустить из store, но тогда она теряет возможность отмены. Обрабатывайте ошибки и неудачные запросы явно.
Представляйте асинхронные операции явными состояниями: state() ресурса — это unresolved, pending, ready, refreshing или errored. Сохраняйте пригодные предыдущие данные во время обновления: ресурс хранит своё значение, пока загружается снова. Предотвращайте повторную отправку деструктивных операций и показывайте в UI ошибки, которые можно исправить; не полагайтесь на логи как на обратную связь для пользователя.
let results = resource(
move || {
let q = query.get();
(!q.is_empty()).then_some(q)
},
|q, _info| async move {
let response = fetch(format!("https://api.example.com/search?q={q}")).await?;
if !response.ok() {
return Err(format!("HTTP {}", response.status()));
}
Ok(response.text())
},
);const results = resource(
() => query() || undefined,
async (q, { signal }) => {
const response = await fetch(`https://api.example.com/search?q=${encodeURIComponent(q)}`, { signal });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return (await response.json()) as SearchHit[];
},
);Выносите затратный разбор или вычисления из обработчиков TSX: в computed, за пределы пути взаимодействия или в Rust. Результаты могут прийти после того, как изменились запрос, страница или выделение: ресурс отбрасывает устаревшие загрузки, а для собственной работы прикрепляйте ревизию или идентификатор и отклоняйте устаревшие результаты вместо применения их к новому состоянию.
#Раскладка, измерения и прокрутка
View раскладывает дочерние элементы в колонку, и по умолчанию они растягиваются поперёк неё; class="flex" (.flex_row()) — это строка, как в CSS. Для строки элементов управления нужно центрировать дочерние элементы (items-center), что и выражает это явно. Строка из колонок полной высоты сохраняет растяжение по умолчанию, поэтому колонка заполняет высоту строки, а её заголовок остаётся вверху:
view()
.flex_row()
.flex_1()
.child(sidebar())
.child(content())<View class="flex flex-1">
<Sidebar />
<Content />
</View>Большая часть UI должна использовать раскладку, а не измерять себя сама (Layout). Измерения (onLayout, measure()) — это низкоуровневый инструмент поведения для оверлеев, виртуализации, редакторов, диаграмм и подобных компонентов, корректность которых зависит от вычисленной геометрии.
- Размещайте измерения и геометрию в слое, которому принадлежит поведение.
- Наблюдайте за раскладкой только тогда, когда обычная раскладка не может выразить нужную связь.
- Никогда не записывайте несвязанное состояние приложения при каждом событии раскладки.
- Считайте измеренные данные привязанными к ревизии: они могут устареть после изменения размера текста, ширины, темы или содержимого.
- Позвольте оверлеям
nsh/baseразмещать всплывающие элементы: их размещение, переворот и отступы безопасной области следуют единой политике.
Для инвариантов выравнивания предпочитайте построение коррекции: соседние области должны использовать один и тот же токен отступа или общий inset, а не повторять одинаковые литералы. Добавляйте проверки геометрии или снимки экрана на реальной платформе (nsh test --target) для критичных повторяющихся границ, колонок и промежутков. Проверяйте не только окно по умолчанию: крупный размер текста и масштабирование дисплея могут превратить дробные координаты в сдвиг на один пиксель, даже если снимок по умолчанию выглядит выровненным.
При проверке точности измеряйте итоговый результат, но не кодируйте измеренную поправку произвольным значением. Найдите причину расхождения: дублирующийся padding, вложенные insets, владение границей, метрики шрифта или округление, а затем исправьте структурного владельца.
У каждой прокручиваемой области должен быть один владелец. Избегайте случайной вложенной прокрутки по одной и той же оси. Отдавайте scroll view элемент, которому принадлежит вся страница, панель или список, чтобы индикатор определялся относительно края области, а внутренний отступ содержимого задавайте внутри него (contentClass / contentStyle), а не оборачивайте scroll view в контейнер с padding. Индикатор, плавающий между содержимым и границей страницы, обычно выдаёт неверного владельца прокрутки или padding на неправильном слое. Не заходите в безопасную область и под клавиатуру с помощью env(safe-area-inset-*) и env(keyboard-inset-height) в padding и margin (значения читайте через их аксессоры): они нативно следуют за поворотом и клавиатурой.
#Списки, таблицы и большие данные
Используйте List (virtual_list в Rust), когда данные могут вырасти за пределы небольшой ограниченной коллекции. Разделяйте идентичность строки и её видимую позицию и не копируйте весь набор данных при каждом обновлении. Пусть список владеет переиспользованием ячеек, согласованием прокрутки и видимым диапазоном, а функции строк — представлением строки.
Разделяйте:
- исходные данные и доменные идентификаторы;
- состояние фильтрации и сортировки;
- состояние выделения;
- состояние прокрутки;
- рендеринг строк.
Это делает обновления локальными и не даёт дереву view превратиться в модель данных.
Виртуализация — это контракт поведения, а не просто переключатель производительности. Функция строки выполняется один раз для каждой ячейки, а ячейки переиспользуются для других строк:
- состояние каждого элемента нужно хранить в данных по ключу id, а не в сигнале, созданном внутри строки, который переезжает вместе с ячейкой к следующему элементу;
- элемент нужно читать там, где он используется, в привязках: значение, скопированное в начале функции строки, принадлежит первой строке, которую показывала ячейка;
- обновляйте неизменяемо в TSX (новый массив, неизменённые элементы переиспользуются, изменённый элемент — новый объект) и задавайте
estimatedItemSize, близкий к типичной строке; - прокрутка к элементу (
scrollToIndex) работает в координатах данных, даже когда большинства строк не существует.
let liked = signal(HashSet::<u32>::new());
virtual_list(move || posts.with(Vec::len), move |at| {
let post = computed(move || posts.with(|p| p[at.index()].clone()));
view()
.flex_row()
.p_4()
.child(text(move || post.with(|p| p.title.clone())).flex_1())
.child(
text(move || if liked.with(|s| s.contains(&post.with(|p| p.id))) { "♥" } else { "♡" })
.on_press(move || toggle(post.with(|p| p.id))),
)
})
.estimated_item_size(px(64.0))const liked = signal<Set<number>>(new Set());
<List each={posts()} estimatedItemSize={64} class="flex-1">
{(post) => (
<View class="flex-row p-4">
<Text class="flex-1">{post().title}</Text>
<Text onPress={() => toggle(post().id)}>{liked().has(post().id) ? "♥" : "♡"}</Text>
</View>
)}
</List>#Дизайн публичного API
Для переиспользуемых компонентов:
- props задают допустимые значения по умолчанию:
#[prop(default)]или#[prop(default = …)]в Rust,??там, где prop читается в TSX; - у каждого prop, события и экспорта есть doc-комментарий; в Rust он становится объявлением TypeScript, которое видит TSX;
- callbacks описывают запрашиваемые изменения и включают сведения об указателе только тогда, когда они значимы;
- явные части, слоты и функции рендеринга предпочтительнее инспекции произвольных потомков;
- добавление переиспользуемого поведения не должно навязывать визуальный выбор на уровне продукта.
Приложения поставляются хост-приложениям как подписанные пакеты и могут никогда не собираться заново: каждый последующий релиз NSH запускает их как есть. Применяйте ту же дисциплину к тому, что приложение или библиотека компонентов предоставляет другим (props и события своих компонентов, экспорты своего crate, функции, которые получают нативные компоненты и модули хост-приложения):
- входные данные получают только необязательные члены; типы только расширяются; значения по умолчанию и поведение сохраняются (новое поведение включается явно);
- функция, у которой параметры могут добавляться, принимает опции одним объектом (
scrollTo({ x, y, animated })), а не ещё одним позиционным параметром; параметры, которые никогда не добавятся, например начало и конец диапазона, остаются позиционными; - режим — это строковое объединение, которое может получить новые значения, а не boolean, которому позже потребуется третье состояние;
- у значения один тип:
version: string, но никогдаstring | number; - функция возвращает объект, в который можно добавить поле, а не кортеж или «голое» значение;
- функция, возвращающая Promise, сообщает о любой ошибке через его отклонение, а ошибка, которую вызывающий может обработать, несёт
code, как и собственные функции NSH: вызывающие ветвятся по кодам, а сообщения предназначены для людей и могут меняться; - переименование сохраняет старое имя как
@deprecated-алиас той же реализации.
В Rust приватные поля — вариант по умолчанию для поведенческого состояния, которое должно развиваться без поломки вызывающего кода. Публичные поля уместны для намеренно record-подобной конфигурации и сериализуемых данных. Каждая публичная структура с публичными полями должна иметь #[non_exhaustive] и реализовывать Default, чтобы вызывающие могли создавать значения без исчерпывающих литералов структуры, а поле можно было добавить, ничего не сломав.
Сохраняйте стабильность публичных путей импорта при реорганизации внутренностей: спецификатор #[native_module] crate и его экспорты, а также экспорты модуля TSX — это то, что импортирует другой код. Предпочитайте терминологию платформенных элементов управления и устоявшиеся названия NSH словарю веб-фреймворков.
#Границы платформ и возможностей
Не предполагайте, что все платформы поддерживают одни и те же возможности. Строки заголовка и элементы управления окном, accessibility, буфер обмена, haptics, физика прокрутки, шрифты и тайминги могут различаться. Один бандл работает везде (Платформенный код); размещайте платформенный код там, где NSH отводит для него место, и определяйте поведение на случай отсутствия возможности:
- то, что элемент означает на каждой платформе, задаётся prop верхнего уровня; собственная системная возможность платформы задаётся в
ios={{…}},android={{…}},desktop={{…}}илиweb={{…}}(в Rust это метод билдера), на каждой платформе, где она есть, и применяется только объект текущей платформы; - API мобильных платформ берутся из
nsh/mobile, API десктопных платформ изnsh/desktop; - стили для отдельных платформ задаются классами
ios:,android:иweb:.
Платформенная ветка должна сохранять семантический контракт, даже если представление отличается. Например, диалог на iOS выглядит как sheet, а на десктопах как слой поверх окна, но приложению всё равно нужно согласованное состояние открытости. Общее поведение тестируйте в headless-режиме, а платформенную часть с помощью nsh test --target. Для каждой ветки по значению, о котором сообщает NSH (platform.os, состояние, причина), добавляйте default: в следующих релизах появятся новые платформы и случаи, а поставленное сегодня приложение будет работать и на них (Совместимость).
Приложение объявляет всё, к чему обращается за пределами своей VM, в nsh.json "capabilities": хосты, с которых оно загружает данные, страницы, которые показывает web view, буфер обмена, строку состояния (Sandbox и Capabilities). Приложение, которое будет поставляться внутри других приложений, перечисляет ровно то, что использует, и обрабатывает отклонённый запрос так же, как неудавшийся: приложение-хост может предоставить меньше, чем объявлено. Отклонённый вызов завершается с code "ERR_ACCESS_DENIED", где permission называет возможность, а resource то, что было запрошено.
#Соглашения о файлах и именах
- Называйте страницы и компоненты по концепциям продукта:
ProjectList,ProjectEditor,SettingsPage. - Называйте обработчики событий по намерению:
confirmDelete,openProject,onQueryChange. - Держите в модуле одну основную ответственность; разделяйте файл, когда владение состоянием или жизненный цикл уже нельзя понять, не читая несвязанное поведение.
- Храните модуль компонента, его props, события и целевые тесты вместе, если они меняются вместе.
- Документируйте инварианты и неочевидные ограничения жизненного цикла; не комментируйте очевидные вызовы билдера.
- Используйте
rustfmtи соблюдайте требования Clippy в Rust, а в TSX используйтеnsh check. Избегайте широких атрибутовallow, которые скрывают несвязанные предупреждения.
#Словарь — часть API
Используйте одно и то же слово для одной и той же концепции во всех компонентах. Прежде чем назвать prop или событие, поищите термин сначала в собственном API NSH, затем в gpui-base GPUI Kit, в GPUI и gpui-component, а потом в элементах управления платформ. То, что показывает примитив, и глобальные API берут имена из HTML, DOM и CSS (src, html, placeholder). Имена в Rust записываются как snake_case и переводятся в camelCase в TSX (on_open_change превращается в onOpenChange); одно слово остаётся одним словом (readonly, multiline).
| Концепция | Шаблон именования | Пример |
|---|---|---|
| Компонент | Существительное, PascalCase | Button, Dialog, QuantityStepper |
| Его props и события (Rust) | <Component>Props, <Component>Event | ButtonProps, ButtonEvent::Press → onPress |
| Объект состояния поведения | <Control>State, создаётся через create<Control> | NavStackState, createNavStack |
| Почему значение изменилось | <Control>ChangeReason, объединение строк | DialogChangeReason: "cancel", "backdropPress" |
| Состояние | Прилагательное в утвердительной форме, без префикса is | open, hidden, disabled, selected, checked, readonly |
| Что разрешено пользователю | Прилагательное | scrollable, zoomable, selectable, resizable |
| Возможность без прилагательного | <feature>Enabled | pagingEnabled |
| Показывается ли часть | Boolean, названный по части, по умолчанию true | scrollbar, caret (scrollbar={false}) |
| Показывается ли слот | <slot>Visible, по умолчанию true | headerVisible (header — слот страницы) |
| Стили части | <part>Class, <part>Style | backdropClass, contentStyle |
| Стили состояния | классы <state>Style, <state>: | activeStyle, disabled: |
| Слот | То, чем он является | header, footer, trigger, content, empty |
| Содержимое самого приложения | render<Part> | renderCodeBlock |
| Флаг закрытия | closeOn<Trigger>, по умолчанию true | closeOnEscape, closeOnBackdropPress |
| Изменение значения | on<Value>Change({ <value>, ... }), один объект | onChange({ value }), onOpenChange({ open, reason }) |
| Любое другое событие | on<Event>, настоящее время, один объект | onPress(), onSubmit({ text }) |
| Фаза | <act>Start, <act>End | onPanStart, onLoadStart |
| Мышь над view | Enter / Move / Leave, как в DOM | onMouseEnter, onMouseMove, onMouseLeave |
| Начало прослушивания | on(event, listener), возвращает () => void | host.on(name, l), BackHandler.on("press", l) |
| Значение окружения | Аксессор, рядом с ним set<Value> | colorScheme(), setColorScheme |
| Boolean только для чтения | is<Adjective>, has<Noun> | isAvailable |
Props никогда не называются is<Adjective>: эта форма предназначена для того, что только читает boolean (константа, поле payload). Часть, которая показывается, называется по самой части, а не <part>Shown, <part>Hidden или shows<Part>.
Билдер в Rust принимает и возвращает Self и называется по prop, который задаёт, без set_ (.disabled(..), .checked(..), .on_change(..)); изменение на месте называется set_<field> (setRoutes, setColorScheme). Геттер boolean называется либо has<Noun>, когда значение что-то содержит, либо is<Adjective>, когда он описывает состояние или разрешение. Используйте прилагательное всякий раз, когда оно есть у действия: isClosable вместо canClose, isZoomable вместо canZoom. Если действие выражено глагольной фразой без формы прилагательного, назовите то, что ему нужно: hasDefinition, а не canGoToDefinition. Не добавляйте новые геттеры can.
#Пусть контекст передаёт окружающее имя
Имя читается внутри чего-то: prop внутри своего компонента, поле внутри своего типа. Ни то ни другое не повторяет то, что его охватывает (contentStyle, а не contentContainerStyle; у Dialog — backdropClass, а не dialogBackdropClass). Поля одного типа держите на одном уровне сокращения.
#Используйте точные доменные слова
- selected — принадлежность к набору или текущий элемент; checked и pressed — значение чекбокса или переключателя; focused — цель клавиатуры; hovered — присутствие указателя. Никогда не используйте их взаимозаменяемо.
- open / close — состояние оверлея; show / hide — временный запрос; expand / collapse — структура.
- disabled блокирует взаимодействие; readonly разрешает выделение и навигацию, но не редактирование; loading предотвращает дублирование работы, пока выполняется операция.
- press — активация любым вводом, и события NSH говорят press (
onPress); click относится к мыши. - index — позиция; id — идентичность. Не сохраняйте переупорядочиваемые данные и не используйте их ключами по index.
- value — управляемые доменные данные; state — сохраняемое поведение.
- src — ресурс, который показывает элемент (изображение, видео, страница); любой другой адрес — url.
- role сообщает, что означает выбор (
"cancel","destructive"); внешний вид остаётся делом приложения.
Избегайте расплывчатых имён, таких как data, item2, handleAction, updateUi, process, manager или config, когда существует более узкий доменный термин.
#Стиль типов и модулей
- Типы Rust записываются как
UpperCamelCase; модули, функции, методы, поля и локальные переменные — какsnake_case; константы — какSCREAMING_SNAKE_CASE. Привязки контекста называйтеcx. - Компоненты TSX и их теги записываются как
PascalCase; props, функции и локальные переменные — какcamelCase. Примитивы импортируются изnsh, базовые компоненты — изnsh/base. - Модуль, названный по компоненту, владеет его публичной границей; разделяйте его состояние, части и тесты по файлам, не меняя того, что импортируют остальные.
- Используйте единственное число в именах модулей для одной концепции компонента и устоявшиеся имена для семейств (
menuдляMenu,MenuItemиSubmenu). - Добавляйте к идентификаторам суффикс
Id, а коллекции называйте осмысленными формами множественного числа. Не чередуйтеidx,indexиixв одной подсистеме. - По возможности называйте предикаты в утвердительной форме. Положительное состояние проще компоновать, чем несколько отрицательных, но сохраняйте устоявшиеся термины, например
disabled, там, где они соответствуют семантике элемента управления.
#Формулировки callback и событий
- Каждое событие передаёт один объект, даже если поле одно, и это относится также к изменению значения; поля называются так же, как props, о которых они сообщают:
onChange({ value }),onOpenChange({ open, reason }),onLinkPress({ url }). Тогда новое поле будет просто дополнением. Тип данных API является полем, а не самим payload (onImagePress({ image })); событие, которому нечего сообщать, ничего не передаёт. - Называйте само действие, а не прошедшее время или запрос:
onPress,onClose,onCancel,onBackPress. - Если у действия есть имя, называйте намерение, а не геометрию:
onLoadMore, а не «достигнут конец». - Пары жизненного цикла —
onOpen/onClose; фазы —Start/End, но никогдаBegin; мышь над view —onMouseEnter/onMouseMove/onMouseLeave(имена из DOM), файл, перетаскиваемый над ним, —Enter/Leave. - Документируйте, вызывается ли callback до или после изменения собственного состояния компонента и может ли он повторно входить в компонент.
#Стиль документации и текстов
Публичная документация должна начинаться с того, что делает компонент и кто владеет его состоянием. В примерах должны использоваться актуальные API и стабильные id. Документируйте значения по умолчанию, различия между платформами, поведение фокуса, порядок вызова callback и то, какие props основной поток читает нативно.
Следуйте правилам языка интерфейса из Design Guides для подписей, команд, диалогов подтверждения, регистра букв и многоточий. Используйте один канонический термин для каждого доменного объекта, команды и состояния.
Ключи перевода описывают устойчивое намерение (dialog.deleteProject.title), а не предложение на исходном языке и не положение на экране. Никогда не собирайте предложение из переведённых фрагментов и не используйте один ключ для значений, у которых случайно совпал английский текст.
Локализуйте намерение, а не синтаксис. Дайте каждой локали контроль над порядком слов, плюрализацией, пунктуацией и объёмом необходимого контекста. Тесты должны ловить отсутствующие ключи и несогласованные фиксированные термины; решение о том, оправдан ли повтор контекстом, остаётся за ревью человеком. Проверяйте каждую строку внутри её компонента на реалистичном содержимом, с увеличением длины текста и при большом размере шрифта.
#Стратегия тестирования
Тестируйте на самом низком уровне, который способен доказать поведение (Тестирование):
- чистые тесты для переходов состояний, форматирования, разбора и упорядочивания (
cargo testдля функций крейта, тестовый файл для функций store); - headless-тесты приложения для компонентов, событий и времён жизни: настоящий движок, рендерер, раскладка и списки, с кадрами отображения на 60 Гц, в
nsh test(nsh/test) илиcargo test(nsh_testing::Headless); - тесты взаимодействия для фокуса, клавиатуры, указателя, раскладки и отрисованного состояния, по шагам, которые выполняет пользователь (
press,type,scroll,drag,keyboard.press), в headless-режиме и на реальной платформе (nsh test --target macos,ios,android,web, …); - smoke-тесты примеров или приложения для полных сценариев и
nsh test --traceдля измерения стоимости взаимодействия.
#[test]
fn a_tap_counts() {
let mut app = Headless::start(EngineConfig::new(Root::of::<my_app::App>())).unwrap();
app.wait_for("Count: 0").unwrap();
app.press("Tap").unwrap();
app.wait_for("Count: 1").unwrap();
}import { expect, open, test } from "nsh/test";
test("a tap counts", async () => {
const app = await open("./App.tsx");
await app.press("role:button[Tap]");
expect(app.texts()).toContain("Count: 1");
});Для интерактивного компонента покрывайте семантический контракт, а не детали реализации: активацию нажатием и с клавиатуры, изменения управляемого значения, поведение в состоянии disabled, перемещение фокуса, количество и порядок событий, стабильную идентичность и важные пустые состояния и состояния ошибок. Подменяйте сеть (маршруты network) и управляйте временем (ручные часы), вместо того чтобы ждать реальных. Добавляйте регрессионный тест перед исправлением бага, если сбой можно воспроизвести детерминированно.
Находите view так, как это делает пользователь: по роли и имени, подписи или тексту (role:button[Save], label:Close), а там, где ничто другое их не называет, по стабильному testID. Селекторы перечитываются на каждом шаге, поэтому шаг никогда не действует на устаревший снимок. Используйте скриншоты (app.screenshot, на реальной платформе) для визуальных фактов, которые селекторы выразить не могут; координаты применяйте только как запасной вариант. Автоматизированные и ручные свидетельства указывайте отдельно.
#Правила производительности
- Не записывайте состояние безусловно из привязки или эффекта, который его читает.
- Избегайте создания сигналов, эффектов, привязок клавиш и дорогих структур данных внутри привязок или функций строк.
- Читайте сигналы в привязках, чтобы согласованное изменение обновляло самую узкую часть дерева.
- Используйте
Listдля длинных коллекций и рендерите только видимый диапазон. - Избегайте клонирования больших строк или коллекций только ради замыкания; захватывайте
Copyдескрипторы или общие данные (SharedString,Rc). - Держите обработчики TSX и вычисляемые значения лёгкими; переносите тяжёлую работу с пути взаимодействия или в Rust.
- Измеряйте с помощью
nsh test --trace, прежде чем добавлять кэши. У кэша должен быть чёткий владелец инвалидации. - Ограничивайте работу анимации: анимируемые значения на UI-потоке, а не сигналы, записываемые каждый кадр;
scrollValueвместо слушателя прокрутки (Анимация); учитывайте reduced motion (reduceMotion()).
#Типичные ошибки
Избегайте следующих паттернов:
- один сигнал, содержащий несвязанное состояние приложения;
- бизнес-логика и сетевые запросы, встроенные в длинное тело компонента;
- случайные ключи или ключи на основе индекса для контента, порядок которого может меняться;
- состояние отдельных строк в сигналах, созданных внутри строки
List; - чтение пропа или сигнала в теле компонента в расчёте на то, что он будет обновляться;
- литеральные цвета и радиусы, которые ломают пользовательские темы и тёмный режим;
- самодельные нажимаемые представления там, где семантический компонент уже обеспечивает фокус, работу с клавиатурой, отключённое состояние и доступность;
- дублирующееся локальное состояние, которое расходится с контролируемым значением;
- эффекты, копирующие один сигнал в другой, и записи, зацикливающиеся через привязку;
- решение, которое система должна получить немедленно, оставленное на усмотрение колбэка вместо пропа, заданного заранее;
- вложенные прокручиваемые представления без явного владельца;
- новый вариант компонента для разового экрана;
- диалоги подтверждения для обратимых действий с низким риском;
- тесты, которые вызывают внутренние функции, но никогда не нажимают, не вводят текст и не прокручивают.
#Правила для агентов, пишущих код
Перед внесением правок агент должен прочитать ближайшую реализацию, её тесты, публичные экспорты модуля и соответствующую страницу справочника. Он должен искать сигнатуры в текущем исходном коде и .nsh/types, а не переводить пример из React, React Native, CSS или GPUI по аналогии: компоненты NSH выполняются один раз, а привязки следуют за сигналами, которые они читают.
Для каждого изменения агент должен уметь назвать:
- владельца поведения и владельца представления;
- сохраняемую идентичность и жизненный цикл состояния;
- контракт нажатия, клавиатуры, фокуса и доступности;
- владельца раскладки и прокрутки;
- токены темы и намеренные исключения;
- тест, который упадёт при регрессии поведения.
Сгенерированный код должен быть проверен и протестирован человеком. «Компилируется» — это не планка качества UI, а масштабный рефакторинг, который лишь делает сгенерированный код опрятным, не заменяет соответствия архитектуре приложения.
#Контрольный список реализации
Перед отправкой изменения на ревью убедитесь, что:
- владение состоянием и побочными эффектами определено явно;
- выбор между обычной функцией и компонентом сделан осознанно, а пропы и сигналы читаются там, где используются;
- у повторяющихся элементов есть стабильные ключи и test id, основанные на предметной области;
- токены темы и шкала Tailwind заменяют единичные визуальные литералы;
- нажатия, горячие клавиши, фокус, отключённое состояние и оверлеи работают согласованно;
- решения, которые система должна получить немедленно, являются пропами, заданными заранее;
- представлены пути загрузки, пустого состояния, ошибки и отмены;
- для больших наборов данных используется
List, а состояние отдельных элементов хранится в данных; - дополнения к публичному API только расширяют его и сохраняют направление зависимостей и инкапсуляцию;
- тесты подтверждают поведение на подходящем уровне, в headless-режиме и на платформах, которых касается изменение;
- форматирование, Clippy,
nsh check, целевые тесты и соответствующие примеры проходят.
См. Начало работы о настройке приложения, а также справочники Elements и API для актуальных сведений об API.