Renderables 与 Constructs

OpenTUI 提供两种构建 UI 的方式:命令式 Renderable API 和声明式 Construct API。两种方式各有不同的权衡。

命令式(Renderables)

你使用 RenderContext 创建 Renderable 实例,并使用 add() 组合它们。你通过 setter 和方法直接修改实例上的状态和行为。

import { BoxRenderable, TextRenderable, InputRenderable, createCliRenderer, type RenderContext } from "@opentui/core"

const renderer = await createCliRenderer()

const loginForm = new BoxRenderable(renderer, {
  id: "login-form",
  width: 40,
  height: 10,
  padding: 1,
})

// 将多个 Renderable 组合成一个
function createLabeledInput(renderer: RenderContext, props: { label: string; placeholder: string; id: string }) {
  const container = new BoxRenderable(renderer, {
    id: `${props.id}-container`,
    flexDirection: "row",
  })

  container.add(
    new TextRenderable(renderer, {
      id: `${props.id}-label`,
      content: props.label + " ",
    }),
  )

  container.add(
    new InputRenderable(renderer, {
      id: `${props.id}-input`,
      placeholder: props.placeholder,
      width: 20,
    }),
  )

  return container
}

const username = createLabeledInput(renderer, {
  id: "username",
  label: "Username:",
  placeholder: "Enter username...",
})
loginForm.add(username)

// 你必须导航到嵌套组件才能聚焦它
username.getRenderable("username-input")?.focus()

renderer.root.add(loginForm)

特点

  • 创建时需要 RenderContext
  • 直接修改实例
  • 嵌套组件访问需要手动导航
  • 对组件生命周期有显式控制

声明式(Constructs)

使用函数式 constructs 构建轻量级的 VNode 图。实例在将节点添加到树之前不存在。VNodes 排队方法调用并在实例化时重放它们。

import { Text, Input, Box, createCliRenderer, delegate } from "@opentui/core"

const renderer = await createCliRenderer()

function LabeledInput(props: { id: string; label: string; placeholder: string }) {
  return delegate(
    { focus: `${props.id}-input` },
    Box(
      { flexDirection: "row" },
      Text({ content: props.label + " " }),
      Input({
        id: `${props.id}-input`,
        placeholder: props.placeholder,
        width: 20,
      }),
    ),
  )
}

const usernameInput = LabeledInput({
  id: "username",
  label: "Username:",
  placeholder: "Enter username...",
})

// delegate() 自动将焦点路由到嵌套的 input
usernameInput.focus()

const loginForm = Box(
  { width: 40, height: 10, padding: 1 },
  usernameInput,
  LabeledInput({
    id: "password",
    label: "Password:",
    placeholder: "Enter password...",
  }),
)

renderer.root.add(loginForm)

特点

  • 实例化之前不需要 RenderContext
  • VNodes 排队方法调用
  • delegate() 将 API 路由到嵌套组件
  • 声明式、类 React 的语法

delegate() 函数

delegate() 函数通过将方法调用从父元素路由到特定子元素,使 constructs 更易于使用:

function Button(props: { id: string; label: string; onClick: () => void }) {
  return delegate(
    {
      focus: `${props.id}-box`, // 将 focus() 路由到 box
    },
    Box(
      {
        id: `${props.id}-box`,
        border: true,
        onMouseDown: props.onClick,
      },
      Text({ content: props.label }),
    ),
  )
}

const button = Button({ id: "submit", label: "Submit", onClick: handleSubmit })
button.focus() // 聚焦内部的 Box

何时使用哪种方式

使用 Renderables 当

  • 你需要对组件生命周期进行细粒度控制
  • 你正在构建底层自定义组件
  • 你需要立即访问 Renderable 方法
  • 性能至关重要,你想避免 VNode 开销

使用 Constructs 当

  • 你偏好声明式、组合式的代码
  • 你正在构建更高级的 UI 组件
  • 你想要更清晰、更可读的组件定义
  • 你熟悉 React/Solid 模式

混合使用

你可以在同一应用中混合使用两种方式:

import { BoxRenderable, Text, Input } from "@opentui/core"

// 创建 Renderable 容器
const container = new BoxRenderable(renderer, {
  id: "container",
  flexDirection: "column",
})

// 向其添加 Constructs
container.add(Text({ content: "Title" }), Input({ placeholder: "Type here..." }))

renderer.root.add(container)