Solid.js 绑定
使用 Solid.js 的细粒度响应式系统和 OpenTUI 构建终端用户界面。
安装
bun install solid-js @opentui/solid
运行时支持
@opentui/solid 发布一个 npm 包,通过包的 exports 映射提供运行时特定的入口点。根包和 JSX 运行时子路径在 Bun 和 Node 中均可工作。Bun 特有的辅助工具仅限 Bun 使用,如果从 Node 导入会抛出清晰的运行时错误。
| 入口点 | Bun | Node | 说明 |
|---|---|---|---|
@opentui/solid | 是 | 是 | 主渲染器和测试辅助工具 |
@opentui/solid/jsx-runtime | 是 | 是 | 编译后 JSX 的真实运行时模块 |
@opentui/solid/jsx-dev-runtime | 是 | 是 | 编译后 JSX 的开发运行时模块 |
@opentui/solid/preload | 是 | 否 | Bun 预加载钩子 |
@opentui/solid/bun-plugin | 是 | 否 | Bun 打包器/插件入口 |
@opentui/solid/runtime-plugin-support | 是 | 否 | Bun 运行时加载器支持 |
@opentui/solid/runtime-plugin-support/configure | 是 | 否 | 无副作用的 Bun 运行时加载器支持 |
设置
1. 配置 TypeScript
在 tsconfig.json 中添加 JSX 配置:
{
"compilerOptions": {
"jsx": "preserve",
"jsxImportSource": "@opentui/solid"
}
}
2. 配置 Bun
在 bunfig.toml 中添加预加载脚本:
preload = ["@opentui/solid/preload"]
3. 配置 Node 编译
Node 支持目前针对编译后的 Solid TSX。使用 Solid 的通用转换语义,并将生成的运行时导入指向已发布的 solid-js JS 文件:
import ts from "@babel/preset-typescript"
import moduleResolver from "babel-plugin-module-resolver"
import solid from "babel-preset-solid"
export default {
plugins: [
[
moduleResolver,
{
resolvePath(specifier) {
if (specifier === "solid-js") return "solid-js/dist/solid.js"
if (specifier === "solid-js/store") return "solid-js/store/dist/store.js"
return specifier
},
},
],
],
presets: [[solid, { moduleName: "@opentui/solid", generate: "universal" }], [ts]],
}
当前 Node 运行时路径仍比 Bun 更底层。OpenTUI 的原生渲染器目前依赖 Node 的实验性 FFI 支持,因此在运行前请确保使用 Node 26.3.0,并传递 --experimental-ffi、--permission 以及应用所需的文件系统或 FFI 权限。OpenTUI 不会自动安装 Node。
4. 启用运行时加载插件支持(如需要)
如果你的应用在运行时加载外部 TS/TSX 模块(例如基于文件的插件系统),请在动态导入之前在入口文件中导入一次:
import "@opentui/solid/runtime-plugin-support"
此入口点仅限 Bun 使用。
5. 创建你的应用
import { render } from "@opentui/solid"
const App = () => <text>Hello, World!</text>
render(App)
使用 bun index.tsx 运行。
组件
OpenTUI Solid 提供了映射到核心 Renderable 的 JSX 内置元素。
注意: Solid 使用 snake_case 命名多词组件名(例如 ascii_font、tab_select)。
布局与显示
<text>- 带样式的文本容器<box>- 带边框的布局容器<scrollbox>- 可滚动容器<ascii_font>- ASCII 艺术文本<markdown>- 渲染 Markdown 内容
二维码支持来自 @opentui/qrcode/solid,必须通过 registerQRCode() 显式注册。
输入
<input>- 单行文本输入<textarea>- 多行文本输入<select>- 列表选择<tab_select>- 基于标签的选择
代码与差异
<code>- 语法高亮代码<line_number>- 支持差异/诊断的行号<diff>- 统一或分栏差异查看器
文本修饰器
在 <text> 组件内使用:
<span>- 内联样式文本<strong>、<b>- 粗体文本<em>、<i>- 斜体文本<u>- 下划线文本<br>- 换行<a>- 带 href 的链接文本
API 参考
render(node, rendererOrConfig?)
将 Solid 组件树渲染到 CLI 渲染器中。
import { render } from "@opentui/solid"
// 简单用法
render(() => <App />)
// 使用渲染器配置
render(() => <App />, {
targetFps: 30,
exitOnCtrlC: false,
})
参数:
node- 返回 JSX 元素的函数rendererOrConfig- 可选的CliRenderer实例或CliRendererConfig
testRender(node, options?)
创建用于快照和交互测试的测试渲染器。
import { testRender } from "@opentui/solid"
const testSetup = await testRender(() => <App />, { width: 40, height: 10 })
extend(components)
将自定义 Renderable 注册为 JSX 内置元素。
import { extend } from "@opentui/solid"
extend({ custom_box: CustomBoxRenderable })
getComponentCatalogue()
返回驱动 JSX 标签查找的当前组件目录。
Scrollback 写入器
在 split-footer 模式下配合 externalOutputMode: "capture-stdout" 使用时,Solid 绑定提供辅助工具,将 JSX 渲染的输出追加到页脚上方。它们封装了 renderer.writeToScrollback,使你可以使用信号和组件编写 scrollback 内容。
writeSolidToScrollback(renderer, node, options?)
渲染一个 JSX 节点一次,并将其作为 scrollback 提交追加。
import { writeSolidToScrollback } from "@opentui/solid"
writeSolidToScrollback(renderer, () => <text fg="#8BD5CA">api responded in 12ms</text>)
createScrollbackWriter(node, options?)
如果你需要将相同的 JSX 渲染传递给多个 writeToScrollback 调用(或在自己的代码中持有写入器),请使用更底层的工厂函数:
import { createScrollbackWriter } from "@opentui/solid"
const writer = createScrollbackWriter(() => <text>logged at {new Date().toISOString()}</text>, { startOnNewLine: true })
renderer.writeToScrollback(writer)
选项
| 选项 | 类型 | 默认值 | 说明 |
|---|---|---|---|
width | number | 渲染器宽度 | 覆盖快照的列宽 |
height | number | 自动测量 | 覆盖快照的行高(否则从布局测量) |
rowColumns | number | 快照宽度 | 用于尾部跟踪的显式末行列数 |
startOnNewLine | boolean | true | 如果上一个提交在行中间结束,则在此提交前插入换行 |
trailingNewline | boolean | - | 在最后一行后追加换行 |
写入器返回一个 ScrollbackSnapshot,其 teardown 会在快照渲染后销毁内部的 Solid 子树。如果你需要流式提交来随时间重新渲染同一棵树,请直接使用核心 ScrollbackSurface API。
Hooks
useRenderer()
获取 OpenTUI 渲染器实例。
import { useRenderer } from "@opentui/solid"
import { onMount } from "solid-js"
const App = () => {
const renderer = useRenderer()
onMount(() => {
renderer.console.show()
console.log("Hello from console!")
})
return <box />
}
useKeyboard(handler, options?)
订阅键盘事件。
import { useKeyboard, useRenderer } from "@opentui/solid"
const App = () => {
const renderer = useRenderer()
useKeyboard((key) => {
if (key.name === "escape") {
renderer.destroy()
}
})
return <text>Press ESC to close</text>
}
处理按键释放事件:
import { createSignal } from "solid-js"
const App = () => {
const [pressedKeys, setPressedKeys] = createSignal(new Set<string>())
useKeyboard(
(event) => {
setPressedKeys((keys) => {
const newKeys = new Set(keys)
if (event.eventType === "release") {
newKeys.delete(event.name)
} else {
newKeys.add(event.name)
}
return newKeys
})
},
{ release: true },
)
return <text>Pressed: {Array.from(pressedKeys()).join(", ") || "none"}</text>
}
onResize(callback)
处理终端窗口大小调整事件。
import { onResize } from "@opentui/solid"
const App = () => {
onResize((width, height) => {
console.log(`Resized to ${width}x${height}`)
})
return <text>Resize-aware component</text>
}
onFocus(callback)
当终端窗口获得焦点时运行副作用。
import { onFocus } from "@opentui/solid"
const App = () => {
onFocus(() => {
console.log("Terminal focused")
})
return <text>Switch away and back to trigger focus events</text>
}
onBlur(callback)
当终端窗口失去焦点时运行副作用。
import { onBlur } from "@opentui/solid"
const App = () => {
onBlur(() => {
console.log("Terminal blurred")
})
return <text>Switch away and back to trigger blur events</text>
}
这些 hooks 在终端模拟器支持时监听终端的聚焦/失焦事件。
useTerminalDimensions()
获取响应式终端尺寸(返回 Solid signal)。
import { useTerminalDimensions } from "@opentui/solid"
const App = () => {
const dimensions = useTerminalDimensions()
return (
<text>
Terminal: {dimensions().width}x{dimensions().height}
</text>
)
}
usePaste(handler)
订阅粘贴事件。
import { usePaste } from "@opentui/solid"
const textDecoder = new TextDecoder()
const App = () => {
usePaste((event) => {
console.log("Pasted:", textDecoder.decode(event.bytes))
})
return <text>Paste something!</text>
}
useSelectionHandler(callback)
处理文本选择事件。
import { useSelectionHandler } from "@opentui/solid"
const App = () => {
useSelectionHandler((selection) => {
console.log("Selected:", selection)
})
return <text selectable>Select me!</text>
}
useTimeline(options?)
创建和管理动画。
import { useTimeline } from "@opentui/solid"
import { createSignal, onMount } from "solid-js"
const App = () => {
const [width, setWidth] = createSignal(0)
const timeline = useTimeline({
duration: 2000,
loop: false,
})
onMount(() => {
timeline.add(
{ width: width() },
{
width: 50,
duration: 2000,
ease: "linear",
onUpdate: (animation) => {
setWidth(animation.targets[0].width)
},
},
)
})
return <box style={{ width: width(), backgroundColor: "#6a5acd" }} />
}
特殊组件
Portal
将子元素渲染到不同的挂载点(适用于模态框和覆盖层)。
import { Portal, useRenderer } from "@opentui/solid"
const App = () => {
const renderer = useRenderer()
return (
<box>
<text>Main content</text>
<Portal mount={renderer.root}>
<box border>Overlay</box>
</Portal>
</box>
)
}
Dynamic
动态渲染任意内置元素或组件。
import { Dynamic } from "@opentui/solid"
import { createSignal } from "solid-js"
const App = () => {
const [isMultiline, setIsMultiline] = createSignal(false)
return <Dynamic component={isMultiline() ? "textarea" : "input"} />
}
生产构建
使用 Bun.build 配合 Solid 插件:
import solidPlugin from "@opentui/solid/bun-plugin"
await Bun.build({
entrypoints: ["./index.tsx"],
target: "bun",
outdir: "./build",
plugins: [solidPlugin],
})
编译为独立可执行文件:
await Bun.build({
entrypoints: ["./index.tsx"],
plugins: [solidPlugin],
compile: {
target: "bun-darwin-arm64",
outfile: "./app-macos",
},
})
如果该可执行文件在运行时加载外部插件/模块,请在应用入口中保留 import "@opentui/solid/runtime-plugin-support"。
示例:计数器
import { render, useKeyboard, useRenderer } from "@opentui/solid"
import { createSignal } from "solid-js"
const App = () => {
const [count, setCount] = createSignal(0)
const renderer = useRenderer()
useKeyboard((key) => {
if (key.name === "up") setCount((c) => c + 1)
if (key.name === "down") setCount((c) => c - 1)
if (key.name === "escape") renderer.destroy()
})
return (
<box border padding={2}>
<text>Count: {count()}</text>
<text fg="#888">Up/Down to change, ESC to close</text>
</box>
)
}
render(App)
与 React 绑定的差异
| 方面 | Solid | React |
|---|---|---|
| 渲染函数 | render(() => <App />) | createRoot(renderer).render(<App />) |
| 组件命名 | snake_case(ascii_font) | kebab-case(ascii-font) |
| 状态管理 | createSignal | useState |
| 副作用 | onMount、onCleanup | useEffect |
| 大小调整 hook | onResize(callback) | useOnResize(callback) |
| 尺寸 | 返回 signal:dimensions().width | 返回对象:dimensions.width |
| 额外 hooks | onFocus、onBlur、usePaste、useSelectionHandler | - |
| 特殊组件 | Portal、Dynamic | - |