指南

JSX 与组件

nsh 应用用 JSX 描述界面,但其中没有 React,也没有虚拟 DOM。nsh 的编译器把每个 JSX 元素转换成对原生渲染器的直接调用,而组件只是一个普通函数,运行一次来构建它负责的那部分树。之后让界面保持最新的是响应式:JSX 中读取 signal 的表达式由原生层跟踪,变化时只有它们会重新运行。

guide-components.tsx 在线编辑 在新标签页打开
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>nshUIView / ViewGroup:用于布局、样式和触摸的盒子View
<Text>nsh原生文本视图;嵌套的 <Text> 元素是其中的片段(span)Text
<Image>nsh/image图片视图(本地资源和 URL)Image
<Scroll>nshUIScrollView / ScrollViewScroll
<Input>nshUITextField / UITextView / EditTextInput
<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>
));

#相关内容

  • 响应式:signal、effect、context。
  • 元素:所有宿主元素及其 props。
  • 组件:nsh/ui/mobile 中现成的组件。