指南
JSX 与组件
nsh 应用用 JSX 描述界面,但其中没有 React,也没有虚拟 DOM。nsh 的编译器把每个 JSX 元素转换成对原生渲染器的直接调用,而组件只是一个普通函数,运行一次来构建它负责的那部分树。之后让界面保持最新的是响应式:JSX 中读取 signal 的表达式由原生层跟踪,变化时只有它们会重新运行。
import { Input, type InputProps, type JSX, mount, Scroll, signal, split, Text, View, type YNode } from "nsh";
/** A component: a function of its props, run once. */
function Badge(props: { label: string; color?: string }) {
return (
<View style={{ alignSelf: "flex-start", borderRadius: 999, paddingHorizontal: 10, paddingVertical: 4, backgroundColor: props.color ?? "#ddf4ff" }}>
<Text style={{ fontSize: 13, fontWeight: "600", color: "#1f2328" }}>{props.label}</Text>
</View>
);
}
/** Children are whatever the caller put between the tags. */
function Card(props: { title: string; children?: JSX.Element }) {
return (
<View style={{ backgroundColor: "#ffffff", borderRadius: 12, padding: 14, gap: 10, borderWidth: 1, borderColor: "#d0d7de" }}>
<Text style={{ fontSize: 13, fontWeight: "600", color: "#6e7781" }}>{props.title}</Text>
{props.children}
</View>
);
}
/** Own props are split off; the rest is spread onto the input and stays reactive. */
function Field(props: InputProps & { label: string }) {
const [own, input] = split(props, ["label"]);
return (
<View style={{ gap: 4 }}>
<Text style={{ fontSize: 13, color: "#6e7781" }}>{own.label}</Text>
<Input
style={{ fontSize: 16, borderWidth: 1, borderColor: "#d0d7de", borderRadius: 8, paddingHorizontal: 10, paddingVertical: 8, backgroundColor: "#ffffff" }}
{...input}
/>
</View>
);
}
function App() {
const name = signal("");
const taps = signal(0);
let field: YNode | undefined;
return (
<Scroll style={{ flex: 1, backgroundColor: "#f6f8fa" }}>
<View style={{ padding: 20, paddingTop: 24, gap: 16 }}>
<Card title="Props are live">
<Badge label={`Tapped ${taps()} times`} color={taps() % 2 ? "#fff8c5" : "#ddf4ff"} />
<View onPress={() => taps.update((n) => n + 1)} activeOpacity={0.6} style={{ backgroundColor: "#0969da", borderRadius: 8, padding: 10 }}>
<Text style={{ color: "#ffffff", textAlign: "center", fontWeight: "600" }}>Tap</Text>
</View>
</Card>
<Card title="split, spread and ref">
<Field label="Your name" placeholder="Type here" value={name()} onChangeText={(t) => name.set(t)} ref={(node) => (field = node)} />
<Text style={{ color: "#1f2328" }}>{name() ? `Hello, ${name()}!` : "Nobody yet."}</Text>
<View style={{ flexDirection: "row", gap: 8 }}>
<View onPress={() => field?.focus()} activeOpacity={0.6} style={{ backgroundColor: "#8250df", borderRadius: 8, paddingHorizontal: 12, paddingVertical: 8 }}>
<Text style={{ color: "#ffffff" }}>Focus</Text>
</View>
<View onPress={() => (name.set(""), field?.blur())} activeOpacity={0.6} style={{ backgroundColor: "#57606a", borderRadius: 8, paddingHorizontal: 12, paddingVertical: 8 }}>
<Text style={{ color: "#ffffff" }}>Clear</Text>
</View>
</View>
</Card>
</View>
</Scroll>
);
}
mount(App);#元素
每个标签都使用 PascalCase,并指向作用域中的某个绑定。宿主元素各自对应一个原生视图,它们是从 nsh 模块导入的组件;只有 If 和 Each 是无需导入的全局组件:
| 标签 | 导入自 | 原生视图 | 页面 |
|---|---|---|---|
<View> | nsh | UIView / ViewGroup:用于布局、样式和触摸的盒子 | View |
<Text> | nsh | 原生文本视图;嵌套的 <Text> 元素是其中的片段(span) | Text |
<Image> | nsh/image | 图片视图(本地资源和 URL) | Image |
<Scroll> | nsh | UIScrollView / ScrollView | Scroll |
<Input> | nsh | UITextField / UITextView / EditText | Input |
<List> | nsh/list | 虚拟化、可复用单元格的列表 | List |
<Canvas>、<WebView>、<Video>、<Modal> | nsh/canvas、nsh/webview、nsh/video、nsh/ui/mobile | 绘图、网页、视频播放器、弹出层 | 元素 |
<If>、<Each> | 无:全局组件 | 控制流,本身不产生视图 | 控制流 |
import { mount, Text, View } from "nsh";
import { Image } from "nsh/image";
标签使用 PascalCase,是为了让 TypeScript 按每个标签所指的绑定来检查它:小写标签只能对照每个名称一个的全局类型来检查,于是 List、Each 这类泛型组件无法从 each / of 推断出行的类型,应用自己的组件也完全没有类型。这些导入在运行时没有任何开销:编译器通过导入(包括别名)识别原语并就地创建原生元素,所以 <View> 是对渲染器的直接调用,导入会从 bundle 中去掉。
props 保留 camelCase 命名(onPress、numberOfLines、backgroundColor)。每个元素都接受 style、class / classList(Tailwind)、ref、触摸处理函数、无障碍属性和 testID;仅限某个平台的 props 放在 ios={{…}} 和 android={{…}} 中,只有当前平台的对象会被发送:
<View android={{ elevation: 4, ripple: { color: "#0001" } }} style={{ borderRadius: 12 }} />
小写标签会导致构建错误,错误信息会告诉你应该怎么写:
x lowercase tag <view>: tags are PascalCase components: write <View> and
| `import { View } from "nsh"`
#组件
组件是一个接收 props 对象并返回 JSX 的函数:
function Badge(props: { label: string; color?: string }) {
return (
<View style={{ borderRadius: 999, paddingHorizontal: 10, paddingVertical: 4, backgroundColor: props.color ?? "#ddf4ff" }}>
<Text style={{ fontSize: 13, fontWeight: "600" }}>{props.label}</Text>
</View>
);
}
<Badge label={`${unread()} new`} color="#fff8c5" />
这个函数只在组件创建时运行一次,而且不会跟踪它读取了什么:不存在重新渲染。函数体中的代码(创建 signal、启动 effect、计算常量)只执行一次。它返回的 JSX 与其读取的 props 和 signal 保持连接,所以上面的 Badge 会在 unread() 变化时更新文本,而无需再次运行 Badge。
组件可以返回一个元素、字符串或数字、数组、null,或者返回上述任意值的函数(渲染器会跟踪它)。nsh 中的 Component<P> 类型描述的就是组件:
import type { Component, JSX } from "nsh";
import { Text, View } from "nsh";
const Row: Component<{ title: string; children?: JSX.Element }> = (props) => (
<View style={{ flexDirection: "row", gap: 8 }}>
<Text>{props.title}</Text>
{props.children}
</View>
);
#哪些会重新运行
编译器会为 JSX 中的每个表达式判断它是否可能变化。表达式只要调用函数或读取属性(嵌套函数之外),就被视为会变化;其余的只设置一次:
<Text>{count()}</Text> // follows count
<Text>{props.title}</Text> // follows the prop
<Text>{user.name}</Text> // re-read when something it depends on changes
<Text>{label}</Text> // a plain variable: set once
<View style={{ opacity: busy() ? 0.5 : 1 }} /> // the style object is rebuilt when busy changes
会变化的 prop 或子节点对应一个很小的原生 effect,只设置那一个 prop 或替换那一个子节点。样式对象在 Rust 中做 diff,所以重建样式时只会发送真正变化的属性。
事件处理函数如果是函数字面量或一个名字(onPress={() => …}、onPress={save}),只设置一次;否则会被跟踪(onPress={props.onPress} 会跟随这个 prop)。
#props 是 getter
传入会变化的表达式的 prop 以 getter 的形式传递:读取 props.title 就会调用它,正因如此,读取它的 JSX 才能跟随变化。静态 props 是普通值。这就是为什么 props 必须在使用的地方读取:
// Wrong: reads the prop once, when the component runs.
function Title(props: { text: string }) {
const { text } = props;
return <Text>{text}</Text>;
}
// Right: reads it in JSX, which follows it.
function Title(props: { text: string }) {
return <Text>{props.text}</Text>;
}
默认值也是一样:在 JSX 中写 props.color ?? "#ddf4ff",而不是用带默认值的解构。要从 props 计算出某个值,使用一个函数或 computed:
function Price(props: { cents: number }) {
const formatted = () => `$${(props.cents / 100).toFixed(2)}`;
return <Text>{formatted()}</Text>;
}
#children
写在组件标签之间的内容会作为 props.children 传入,它是一个 getter,被读取时才构建这些元素:
function Card(props: { title: string; children?: JSX.Element }) {
return (
<View style={{ padding: 14, borderRadius: 12, backgroundColor: "#ffffff", gap: 8 }}>
<Text style={{ fontWeight: "600" }}>{props.title}</Text>
{props.children}
</View>
);
}
<Card title="Account">
<Text>Signed in as {user().name}</Text>
</Card>
每读取一次 props.children 都会重新构建一遍子元素(新的原生视图)。只在子元素应该出现的位置读取一次。children 也可以是函数,即组件带参数调用的渲染回调:
function Repeat(props: { times: number; children: (i: number) => JSX.Element }) {
return <View>{Array.from({ length: props.times }, (_, i) => props.children(i))}</View>;
}
<Repeat times={3}>{(i) => <Text>Row {i}</Text>}</Repeat>
Fragment(<>…</>)可以把多个元素组合在一起而不产生视图。
#控制流
<If> 和 <Each> 是内置的控制流,本身不渲染视图。
<If when={user()} else={<Text>Signed out</Text>}>
<Text>Hello, {user()!.name}</Text>
</If>
<Each of={todos()} key={(t) => t.id}>
{(todo, index) => (
<Text>
{index() + 1}. {todo().title}
</Text>
)}
</Each>
<If when else>在when为真值时渲染子元素,否则渲染else元素。只有当条件的真假发生变化时才会重新渲染分支,而不是每次重新计算when都渲染。<Each of key>为每一项渲染它唯一的子节点(一个函数)。行按key(item)保留(默认是项本身:对象按引用,原始值按值):数组变化时,保留下来的行会被移动而不是重建,只有新的 key 才会创建新行。函数接收的是访问器todo()和index(),因为保留下来的行,其对应的项和位置都可能改变。
在 JSX 中使用三元表达式或 && 也可以({busy() ? <Spinner /> : <Content />}),但只要它读取的任何值变化,就会重建分支。分支包含较多元素时,优先使用 <If>。
对于很长或无限的列表,使用 List:它只为屏幕上的行创建视图,并复用这些视图。详见控制流。
#ref
ref 让你拿到宿主元素的原生节点(一个 YNode),用于命令式调用:focus()、blur()、scrollTo()、scrollToIndex() 等。传入变量时会给它赋值,传入函数时会调用它:
import type { YNode } from "nsh";
import { Input, Scroll, Text, View } from "nsh";
function Search() {
let input: YNode | undefined;
return (
<View>
<Input ref={input} placeholder="Search" />
<View onPress={() => input?.focus()}>
<Text>Focus</Text>
</View>
</View>
);
}
<Scroll ref={(node) => (scroller = node)} />
ref 在元素创建时就被设置,早于组件返回,因此在 effect 和事件处理函数中都可以使用。调用会按顺序发送给视图,排在它之前的修改之后。nsh/ui/mobile 中的组件同样接受 ref。
#展开 props
用 {...props} 把一个 props 对象展开到元素或组件上。getter 仍然是 getter,所以展开的 props 会继续跟随它们读取的值。如果要自己取走一部分 props、把其余的继续传下去,使用 split:
import { Input, type InputProps, split, Text, View } from "nsh";
function Field(props: InputProps & { label: string }) {
const [own, rest] = split(props, ["label"]);
return (
<View style={{ gap: 4 }}>
<Text>{own.label}</Text>
<Input style={{ borderWidth: 1, borderRadius: 8, padding: 8 }} {...rest} />
</View>
);
}
<Field label="Email" value={email()} onChangeText={(t) => email.set(t)} keyboardType="email-address" />
split(props, keys) 返回 [picked, rest],两者都仍然读取原始 props(而解构 { label, ...rest } = props 会把每个 prop 都读取一次)。在 JSX 中靠后的元素 props 会覆盖靠前的。
<If> 和 <Each> 不接受展开 props,也不支持展开子节点({...items}):请使用 <Each> 或数组表达式。
#标签与名称
标签和平常一样,指向作用域中的绑定:<Badge />、<Theme.Provider>、导入的 <Button>。指向作用域中不存在的名称的标签会导致构建错误,全局的 <If> 和 <Each> 除外。
import { Button } from "nsh/ui/mobile";
<Button title="Save" onPress={save} />
你的绑定优先于全局组件:作用域中名为 Each 的组件会让 <Each> 指向这个组件。原语通过导入识别,所以 import { View as Box } from "nsh" 会让 <Box> 成为视图,而你自己名为 Text 的组件就只是你的组件。原语本身也是真正的组件:const Tag = inline ? Text : View; 之后写 <Tag>…</Tag> 也可以。
组件和任何 TypeScript 函数一样由自己的声明获得类型:无论在哪里使用,编辑器都会检查组件的 props,无需生成任何东西。
#挂载与根视图
mount(App) 渲染根组件;mount(App, props) 同时向它传入 props。根视图填满整个 surface(窗口,或应用嵌入 nsh 时所用的视图)。给根视图设置 flex: 1 让它铺满屏幕,并设置背景色:surface 本身的背景因平台而异。
mount(() => (
<View style={{ flex: 1, backgroundColor: "#ffffff" }}>
<App />
</View>
));