---
url: https://native-shell.com/zh-CN/docs/native-components
description: "在 nsh 应用中使用宿主应用的视图（地图、播放器、任意 UIView 或 Android View），支持 props、事件、命令和尺寸上报。"
---

# 原生组件

原生组件是由**嵌入方应用**按名称注册的视图：地图、相机预览、图表库，或者 iOS 上任意的 `UIView`、Android 上任意的
`android.view.View`。nsh 应用通过 [`nsh/native`](https://native-shell.com/zh-CN/modules/nsh-native.md) 提供的 `nativeComponent()` 声明它，然后像使用其他组件一样使用它。nsh
会把它和应用的其余部分一起布局，向它发送 props，把它的事件交给对应的处理函数，并转发通过 `ref` 发出的命令。

nsh 本身就用系统原生视图渲染，所以原生组件只是同一视图层级里多出来的一个子视图：不存在层级叠放问题，它会像 `<View>`
一样被裁剪、随内容滚动并接收触摸。

> [!NOTE]
> 只有在嵌入 nsh 的应用中（[iOS](https://native-shell.com/zh-CN/docs/embedding-ios.md)、[Android](https://native-shell.com/zh-CN/docs/embedding-android.md)）才有原生组件。
> 打包出的独立应用（`nsh package`）和开发宿主不会注册任何组件，因此在那里该元素会渲染成一个空视图（并记录一次“未知名称”日志）。

## 整体流程

1. 宿主应用在它的 `NshAppView`（iOS）或 `NshView`（Android）上以某个名称（如 `"embed.badge"`）注册一个工厂。
   每个视图都有自己的注册表，没有任何全局状态。
2. nsh 应用为这个名称声明一个组件，列出它用到的 props 和事件。
3. 元素创建时，nsh 先创建一个容器视图（和普通视图一样负责装饰、定位和命中测试），再向工厂请求一个组件，并把组件的视图放进容器、铺满容器。
4. 组件创建后会立即收到所有 props，之后每当某个 prop 变化时再收到该 prop。
5. 组件可以发出事件、上报自身尺寸；JS 通过 `ref` 调用它的命令。

## 在 JS 中声明组件

```tsx
import { mount, signal, Text, type YNode } from "nsh";
import { nativeComponent } from "nsh/native";
import { SafeAreaView } from "nsh/ui/mobile";

/** The host app's badge: a label it draws, sized to its text. */
const Badge = nativeComponent<{ text: string; color?: string }, { tap: { count: number } }>("embed.badge", {
  props: ["text", "color"],
  events: ["tap"],
});

const colors = ["#8250df", "#0969da", "#1a7f37", "#cf222e"];

function App() {
  const taps = signal(0);
  let badge: YNode | undefined;
  return (
    <SafeAreaView style={{ flex: 1, padding: 20, gap: 16, backgroundColor: "#f6f8fa" }}>
      <Text style={{ fontSize: 22, fontWeight: "700" }}>Rendered by nsh</Text>
      <Badge
        ref={(node) => (badge = node)}
        text={taps() === 0 ? "Tap the native badge" : `Tapped ${taps()}×`}
        color={colors[taps() % colors.length]}
        onTap={(e) => {
          taps.set(e.count);
          badge?.command("pulse");
        }}
        style={{ alignSelf: "flex-start", borderRadius: 8, overflow: "hidden" }}
      />
      <Text>{`JS saw ${taps()} taps`}</Text>
    </SafeAreaView>
  );
}

mount(App);
```

这就是 `examples/embedded/App.tsx`，仓库里的两个嵌入示例运行的都是它。

### `nativeComponent(name, spec)`

```ts
function nativeComponent<P extends object = {}, E extends object = {}>(
  name: string,
  spec: NativeComponentSpec<P, E>,
): (props: NativeComponentProps<P, E>) => YNode;
```

| 参数 | 类型 | 说明 |
|---|---|---|
| `name` | `string` | 宿主应用注册时使用的名称。请加上自己的前缀（`"maps.view"`、`"acme.chart"`），以保证名称唯一。 |
| `spec.props` | `readonly (keyof P & string)[]` | 组件自己的 props。只有这里列出的 props 才会发送到原生端。 |
| `spec.events` | `readonly (keyof E & string)[]` | 组件发出的事件。可选。 |

类型参数描述 props（`P`）以及每个事件的负载（`E`）。返回的组件接受：

- 组件自己的 props `P`；
- 每个事件对应一个处理函数：事件 `tap` 由 `onTap` 处理，`regionChange` 由 `onRegionChange` 处理（首字母大写并加上
  `on` 前缀）；
- `<View>` 能接受的一切（children 除外）：`style`、`class` / `classList`（[Tailwind](https://native-shell.com/zh-CN/docs/tailwind.md)）、布局、
  无障碍 props、`testID`、`onLayout`、`ref`。这些作用于 nsh 绘制的容器，而不是组件本身。

原生组件是叶子节点：它不接受 children。

## Props

每个自有 prop 都以 JSON 形式发送，所以它的值可以是任意 JSON 值：字符串、数字、布尔值、`null`、数组和普通对象。Props
和其他属性一样是响应式的：当 `text={label()}` 读取了一个 signal，signal 变化时（且仅在此时）新值才会被发送。

| JS 中发生了什么 | 组件收到什么 |
|---|---|
| 元素被创建 | 组件创建后立即收到所有已设置的自有 prop |
| 某个 prop 的值变化 | 该 prop 及其新值 |
| 某个 prop 变为 `undefined` | 该 prop，但没有值（Swift 中为 `nil`，Java 中为 `null`） |

非 JSON 值（函数、类实例、`NativeValue`）无法跨越边界；接口只传数据。

## 事件

组件按名称发出事件，可附带一个 JSON 负载。nsh 把它投递到引擎线程，并用该负载调用对应的处理函数：

```tsx
const MapView = nativeComponent<
  { latitude: number; longitude: number; zoom?: number },
  { regionChange: { latitude: number; longitude: number }; markerPress: { id: string } }
>("acme.map", {
  props: ["latitude", "longitude", "zoom"],
  events: ["regionChange", "markerPress"],
});

<MapView
  latitude={52.52}
  longitude={13.405}
  onRegionChange={(region) => center.set(region)}
  onMarkerPress={({ id }) => open(id)}
  style={{ flex: 1 }}
/>;
```

事件是异步的：主线程从不等待 JS。如果组件必须当场做出决定（这个手势能否开始、这个区域能否改变），就只能在原生端根据之前收到的
props 来决定。

## 命令

命令式调用（缩放到某个点、开始播放、播放一段动画）通过元素的 `ref` 发起：

```tsx
let map: YNode | undefined;

<MapView ref={(node) => (map = node)} latitude={52.52} longitude={13.405} style={{ flex: 1 }} />;

map?.command("zoomTo", { level: 3 });
```

`command(name, args?)` 把名称和参数（任意 JSON 值）发送给组件的 `command` 方法。命令会和在它之前做出的变更按顺序应用，所以紧跟在
prop 变更之后发出的命令能看到新的 prop。

## 尺寸

容器的布局方式与 `<View>` 相同：

- 样式给出了某个维度时（`width`、`height`、`flex: 1`、`alignSelf: "stretch"` 等），以样式为准。
- 样式未确定某个维度时，容器采用组件通过 `setSize` 上报的尺寸，就像图片采用它的固有尺寸一样。从不上报尺寸的组件在未确定的维度上尺寸为 0。

每当内容变化时，以点（iOS）或 dp（Android）为单位上报尺寸。上面的 badge 每次 `text` 变化时都会测量标签，并上报标签尺寸加上内边距，因此
`alignSelf: "flex-start"` 能让 badge 恰好与文字等宽。

## 在 iOS 上注册（Swift）

实现 `NshComponent`，并在 `NshAppView` 上注册一个工厂：

```swift
import NativeShell
import UIKit

/// `embed.badge`: props `text` and `color`; emits `tap`; command `pulse`.
final class Badge: NshComponent {
    let view = UIView()
    private let label = UILabel()
    private let context: NshComponentContext
    private var taps = 0

    init(_ context: NshComponentContext) {
        self.context = context
        label.textColor = .white
        label.font = .boldSystemFont(ofSize: 17)
        label.textAlignment = .center
        label.frame = view.bounds
        label.autoresizingMask = [.flexibleWidth, .flexibleHeight]
        view.addSubview(label)
        view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(tapped)))
    }

    func setProp(_ name: String, _ value: Any?) {
        switch name {
        case "text":
            label.text = value as? String
            let size = label.intrinsicContentSize
            context.setSize(CGSize(width: size.width + 32, height: size.height + 20))
        case "color":
            view.backgroundColor = (value as? String).flatMap(UIColor.init(hex:)) ?? .systemPurple
        default:
            break
        }
    }

    func command(_ name: String, _ args: Any?) {
        guard name == "pulse" else { return }
        view.alpha = 0.3
        UIView.animate(withDuration: 0.6) { self.view.alpha = 1 }
    }

    @objc private func tapped() {
        taps += 1
        context.emit("tap", ["count": taps])
    }
}

// Before loading the bundle:
app.register(component: "embed.badge") { context in Badge(context) }
```

（`UIColor(hex:)` 是示例里的一个小工具函数，不属于 NativeShell。）

| API | 说明 |
|---|---|
| `NshAppView.register(component:_:)` | 以某个名称注册一个工厂 `(NshComponentContext) -> NshComponent`。此后每创建一个元素，它就生成一个组件。重复注册同一名称会替换工厂。 |
| `protocol NshComponent` | `var view: UIView`（铺满容器）、`setProp(_:_:)`、`command(_:_:)`（可选：默认实现忽略命令）。隔离在 main actor 上。 |
| `setProp(_ name: String, _ value: Any?)` | 值是 Foundation 的 JSON 对象：`String`、`NSNumber`、`[Any]`、`[String: Any]`、`NSNull`；prop 被移除时为 `nil`。 |
| `NshComponentContext.emit(_:_:)` | 发出一个事件；数据必须是 `JSONSerialization` 能接受的值（或 `nil`）。线程安全。 |
| `NshComponentContext.setSize(_:)` | 以点为单位上报组件自身的尺寸。线程安全。 |

## 在 Android 上注册（Java / Kotlin）

实现 `com.longbridge.nsh.NshComponent`，并在 `NshView` 上注册一个工厂：

```java
import com.longbridge.nsh.NshComponent;
import com.longbridge.nsh.NshComponentContext;

final class Badge implements NshComponent {
    private final TextView label;
    private final NshComponentContext context;
    private final float density;
    private int taps;

    Badge(Context app, NshComponentContext context) {
        this.context = context;
        density = app.getResources().getDisplayMetrics().density;
        label = new TextView(app);
        label.setTextColor(Color.WHITE);
        label.setTextSize(17);
        label.setGravity(Gravity.CENTER);
        label.setOnClickListener(v -> context.emit("tap", Collections.singletonMap("count", ++taps)));
    }

    @Override
    public View view() {
        return label;
    }

    @Override
    public void setProp(String name, Object value) {
        if (name.equals("text")) {
            label.setText(value instanceof String ? (String) value : "");
            label.measure(0, 0);
            // Sizes are in dp.
            context.setSize(label.getMeasuredWidth() / density + 32, label.getMeasuredHeight() / density + 12);
        } else if (name.equals("color")) {
            label.setBackgroundColor(value instanceof String ? Color.parseColor((String) value) : 0xff8250df);
        }
    }

    @Override
    public void command(String name, Object args) {
        if (!name.equals("pulse")) return;
        label.setAlpha(0.3f);
        label.animate().alpha(1).setDuration(600).start();
    }
}

// Before loading the bundle:
nsh.registerComponent("embed.badge", context -> new Badge(this, context));
```

用 Kotlin 写法相同：

```kotlin
nsh.registerComponent("embed.badge") { context -> Badge(this, context) }
```

| API | 说明 |
|---|---|
| `NshView.registerComponent(String name, NshComponent.Factory factory)` | 以某个名称注册一个工厂；此后每创建一个元素，它就生成一个组件（UI 线程）。 |
| `interface NshComponent` | `View view()`、`void setProp(String, Object)`、`default void command(String, Object)`、`default void destroy()`（元素已移除）。在 UI 线程上调用。 |
| `setProp` 的值 | `String`、`Number`、`Boolean`、`java.util.List`、`java.util.Map`；被移除时（或 JSON `null`）为 `null`。 |
| `NshComponentContext.emit(String event, Object data)` | 发出一个事件；`data` 使用同样的 Java 类型，或 `null`。可在任意线程调用。 |
| `NshComponentContext.setSize(float width, float height)` | 以 dp 为单位上报组件自身的尺寸。可在任意线程调用。 |

## 从 Rust 注册

从 Rust 嵌入 iOS 运行时（`nsh-platform-ios`）的应用，在 `AppView` 上注册一个 `NativeComponent`：

```rust
use std::rc::Rc;
use nsh_core::JsonValue;
use nsh_platform_ios::{AppView, ComponentContext, NativeComponent};
use objc2_ui_kit::UIView;

struct Badge {
    view: objc2::rc::Retained<UIView>,
    context: ComponentContext,
}

impl NativeComponent for Badge {
    fn view(&self) -> &UIView {
        &self.view
    }

    fn set_prop(&mut self, name: &str, value: Option<&JsonValue>) {
        // ... update the view, then report its size in points:
        self.context.set_size(120.0, 40.0);
    }

    fn command(&mut self, name: &str, args: &JsonValue) {
        // optional
    }
}

fn register(app: &AppView, make_view: impl Fn() -> objc2::rc::Retained<UIView> + 'static) {
    app.register_component(
        "embed.badge",
        Rc::new(move |context: ComponentContext| -> Box<dyn NativeComponent> {
            Box::new(Badge { view: make_view(), context })
        }),
    );
}
```

`ComponentContext::emit(name, data)` 发送一个事件（`data` 是 `serde_json::Value`），`set_size(width, height)`
上报尺寸；两者都可以在任意线程调用。

## 规则与限制

- **先注册，再加载。** 工厂只对注册之后创建的元素生效。请在 `loadBundle` / `loadAssetBundle` / `run` 之前注册所有名称，这样第一帧就能找到它们。
- **装饰和触摸归容器管。** 背景、边框、圆角、阴影、不透明度、变换以及 `overflow: "hidden"` 都作用于 nsh 绘制的容器。你的视图铺满容器，并接收落在其中的触摸。
- **能力声明不适用。** 注册的组件是宿主应用自己的代码；应用声明的[能力](https://native-shell.com/zh-CN/docs/sandbox-and-capabilities.md)限制不了它做什么。如果你嵌入的是不受信任的应用，就只注册那些可以放心让它们使用的组件。
- **接口只传数据。** Props、事件负载和命令参数都以 JSON 文本跨越边界；每次变更时传递标识符和小数据，而不是大块数据。
- **一个 bundle，两个平台。** 在 iOS 和 Android 上用相同的 props 和事件注册相同的名称，同一个 bundle 就能在两个平台上运行。

## 另请参阅

- [`nsh/native` 参考](https://native-shell.com/zh-CN/modules/nsh-native.md)
- [嵌入 iOS 应用](https://native-shell.com/zh-CN/docs/embedding-ios.md)和[嵌入 Android 应用](https://native-shell.com/zh-CN/docs/embedding-android.md)
- [架构](https://native-shell.com/zh-CN/docs/architecture.md)：props 和事件如何跨线程传递
