API

nsh

nsh 是核心模块:响应式 API(signal、computed、effect、context)、mount、宿主元素 View、Text、Scroll 和 Input,以及元素、样式和事件的 TypeScript 类型。响应式图本身以 Rust 原生运行;读取一个 signal 是一次对运行时的调用,只需几纳秒,只有你自己写的闭包会在 JavaScript 解释器中执行。

import { batch, cleanup, computed, context, effect, Input, mount, Scroll, signal, split, Text, untracked, View } from "nsh";

nsh 的响应式模型与 SolidJS 相同:组件函数只运行一次,界面中读取了 signal 的部分会在 signal 变化时就地更新。组件不会重新渲染,也没有虚拟 DOM。教程见响应式。

#signal

function signal<T>(value: T, options?: SignalOptions<T>): Signal<T>;
function signal<T = undefined>(): Signal<T | undefined>;

interface Signal<T> {
  (): T;                              // 读取(会被追踪)
  set(value: T): void;                // 写入
  update(fn: (value: T) => T): void;  // 基于当前值写入
}

interface SignalOptions<T> {
  /** 新值何时算作变化(默认 `===`);`false`:总是算作变化。 */
  equals?: false | ((previous: T, next: T) => boolean);
}

signal 保存一个值。调用它会读取该值;如果处在追踪作用域内(JSX 表达式、computed、effect),该作用域会订阅它。set 和 update 写入新值,所有读过它的地方随之更新。

import { mount, signal, Text, View } from "nsh";

function Counter() {
  const count = signal(0);
  return (
    <View style={{ padding: 24, gap: 12 }}>
      <Text>Count: {count()}</Text>
      <Text onPress={() => count.update((n) => n + 1)}>Add one</Text>
    </View>
  );
}

mount(Counter);

默认情况下,写入与当前值 === 相等的值不会产生任何效果。传入 equals: false 可以让每次写入都通知订阅者(适合原地修改对象后再次写入同一个对象的场景),也可以传入自定义比较函数:

const items = signal<string[]>([], { equals: false });
const point = signal({ x: 0, y: 0 }, { equals: (a, b) => a.x === b.x && a.y === b.y });

#computed

function computed<T>(fn: (previous: T | undefined) => T, options?: SignalOptions<T>): Accessor<T>;

interface Accessor<T> {
  (): T;
}

由 signal 派生出的值。fn 读过的 signal 变化时它会重新运行;只有当结果真的变化(按 equals 判断,默认 ===)时,读取它的地方才会更新。fn 会收到上一次的结果。

const first = signal("Ada");
const last = signal("Lovelace");
const full = computed(() => `${first()} ${last()}`);
full(); // "Ada Lovelace"

派生计算开销较大或在多处读取时使用 computed。对于简单的表达式,普通函数(const full = () => first() + " " + last())同样是响应式的。

#effect

function effect(fn: () => void): void;

在渲染之后运行 fn,并在它读过的任何 signal 变化时再次运行。effect 用于副作用:打日志、定时器、调用原生模块等。effect 归属于创建它的组件(或 effect),并随之一起销毁。

const query = signal("");
effect(() => {
  console.log("query is now", query());
});

如需在 effect 再次运行前(或组件销毁时)撤销某些操作,在 effect 内部调用 cleanup。

#batch

function batch<T>(fn: () => T): T;

运行 fn,并在结束时一次性应用其中的所有写入,这样读取方只更新一次,而不是每次写入都更新一次。返回 fn 的返回值。

batch(() => {
  first.set("Grace");
  last.set("Hopper");
}); // `full` 只重新计算一次

#untracked

function untracked<T>(fn: () => T): T;

运行 fn,但当前作用域不会订阅其中读取的内容。用于在 effect 或 JSX 表达式中读取某个 signal 而不依赖它。

effect(() => {
  // `query` 变化时重新运行,`page` 变化时不会。
  search(query(), untracked(() => page()));
});

#cleanup

function cleanup(fn: () => void): void;

注册 fn,在当前 effect 再次运行之前,或当前组件、effect 被销毁时运行。

function Clock() {
  const now = signal(new Date());
  const timer = setInterval(() => now.set(new Date()), 1000);
  cleanup(() => clearInterval(timer));
  return <Text>{now().toLocaleTimeString()}</Text>;
}

#context

function context<T>(defaultValue: T): Context<T>;

interface Context<T> extends Accessor<T> {
  Provider: (props: { value: T; children?: JSX.Element }) => JSX.Element;
}

组件向其渲染的所有后代(无论多深)提供的值。调用 context(Theme())读取最近的 Provider 提供的值,没有 Provider 时得到 defaultValue。

import { context, mount, signal, Text, View } from "nsh";

const Theme = context<"light" | "dark">("light");

function Label(props: { text: string }) {
  return <Text style={{ color: Theme() === "dark" ? "#fff" : "#000" }}>{props.text}</Text>;
}

function App() {
  const dark = signal(false);
  return (
    <Theme.Provider value={dark() ? "dark" : "light"}>
      <View style={{ flex: 1, padding: 24, backgroundColor: dark() ? "#000" : "#fff" }}>
        <Label text="Hello" />
        <Text onPress={() => dark.update((d) => !d)}>Toggle</Text>
      </View>
    </Theme.Provider>
  );
}

mount(App);

#mount

function mount<P extends object>(app: Component<P>, props?: P): () => void;

把 app 渲染到界面上(窗口,或嵌入应用时的 NshAppView / NshView),返回一个用于移除它的函数。每个应用的入口文件都以 mount(App) 结尾。

mount(App);

#split

function split<T extends object, K extends readonly (keyof T)[]>(
  props: T,
  keys: K,
): [Pick<T, K[number]>, Omit<T, K[number]>];

把 props 对象拆成 keys 中列出的属性和其余属性。两部分都保持响应式:会变化的属性是 getter,split 会保留这些 getter。适用于组件自己处理一部分 props、把其余部分透传下去的场景。直接解构 props(const { label } = props)只会读取一次,之后的更新就丢失了。

import { Input, type InputProps, split, Text, View } from "nsh";

function LabeledInput(props: InputProps & { label: string }) {
  const [own, rest] = split(props, ["label"]);
  return (
    <View style={{ gap: 4 }}>
      <Text>{own.label}</Text>
      <Input {...rest} />
    </View>
  );
}

#View、Text、Scroll、Input

function View(props: ViewProps): YNode;
function Text(props: TextProps): YNode;
function Scroll(props: ScrollProps): YNode;
function Input(props: InputProps): YNode;

nsh 导出的宿主元素,写作标签:<View>、<Text>、<Scroll>、<Input>。当标签通过导入(包括别名)指向它们之一时,编译器会就地创建原生视图,所以导入它们在运行时没有任何开销。它们也是普通的组件,可以作为值传递(const Tag = inline ? Text : View)。其他元素来自各自的模块:Image 来自 nsh/image,List 来自 nsh/list,Canvas、WebView 和 Video 来自各自的模块。它们的属性见元素。

#类型

nsh 还导出响应式 API 和宿主元素的类型。最常用的有:

类型说明
Accessor<T>可读取的值:() => T
Signal<T>带有 set 和 update 的 Accessor<T>
SignalOptions<T>signal 和 computed 的选项(equals)
Context<T>context 的返回值
Component<P>(props: P) => JSX.Element
JSXJSX 命名空间(JSX.Element)
YNoderef 拿到的对象:聚焦、滚动等命令。见元素
ViewProps、TextProps、ImageProps、ScrollProps、InputProps、CommonProps、TouchProps宿主元素的属性
ViewStyle、TextStyle、ImageStyle、LayoutStyle、StyleProp<T>样式对象;见样式参考
ColorValue、DimensionValue、LengthValue、TransformStep样式中的值
GestureResponderEvent、LayoutEvent、ScrollEvent、PanEvent、TouchPoint事件数据
ImageAsset导入图片文件得到的对象({ uri, width, height })
OS"ios" | "android" | "macos" | "windows" | "linux"
AccessibilityRole、AccessibilityState无障碍属性
import { type GestureResponderEvent, type YNode, signal } from "nsh";

let field: YNode | undefined;
const onPress = (e: GestureResponderEvent) => field?.focus();

StyleProp<T> 接受单个样式,或一个从左到右合并的样式列表(假值会被跳过):

<View style={[styles.card, selected() && styles.selected]} />